From 67f2f243f7d3170e1a823eb8997bafd970724360 Mon Sep 17 00:00:00 2001 From: Giuseppe Longo Date: Wed, 30 Sep 2026 14:01:02 +0200 Subject: [PATCH 1/5] app-layer: add flow accessors for probing parsers Probing parsers written in Rust cannot look into the Flow, which is opaque to them. Add SCFlowGetAppProtocolToClient() to get the protocol detected on the to-client side, and SCAppLayerProtoDetectGetStreamDataSize() to get the amount of stream data available for protocol detection in a given direction. These are needed by the IMAP probing parser to tell IMAP apart from FTP on "USER CAPABILITY". Ticket #8276 --- rust/sys/src/sys.rs | 9 +++++++++ src/app-layer-detect-proto.c | 11 +++++++++++ src/app-layer-detect-proto.h | 8 ++++++++ src/flow-bindgen.h | 1 + src/flow.c | 5 +++++ 5 files changed, 34 insertions(+) diff --git a/rust/sys/src/sys.rs b/rust/sys/src/sys.rs index 67097e5edb..20309cf5ed 100644 --- a/rust/sys/src/sys.rs +++ b/rust/sys/src/sys.rs @@ -1332,6 +1332,12 @@ extern "C" { pp_max_depth: u16, ) -> ::std::os::raw::c_int; } +extern "C" { + #[doc = " \\brief Get the size of the stream data available for protocol detection\n in the given direction, for use by probing parsers.\n\n \\retval bool false if the flow has no TCP session"] + pub fn SCAppLayerProtoDetectGetStreamDataSize( + f: *const Flow, direction: u8, size: *mut u32, + ) -> bool; +} extern "C" { pub fn SCAppLayerRequestProtocolTLSUpgrade(f: *mut Flow) -> bool; } @@ -1874,6 +1880,9 @@ extern "C" { extern "C" { pub fn SCFlowGetAppProtocol(f: *const Flow) -> AppProto; } +extern "C" { + pub fn SCFlowGetAppProtocolToClient(f: *const Flow) -> AppProto; +} #[doc = " \\brief Function type for flow initialization callbacks.\n\n Once registered with SCFlowRegisterInitCallback, this function will\n be called every time a flow is initialized, or in other words,\n every time Suricata picks up a flow.\n\n \\param tv The ThreadVars data structure for the thread creating the\n flow.\n \\param f The newly initialized flow.\n \\param p The packet related to creating the new flow.\n \\param user The user data provided during callback registration."] pub type SCFlowInitCallbackFn = ::std::option::Option< unsafe extern "C" fn( diff --git a/src/app-layer-detect-proto.c b/src/app-layer-detect-proto.c index 054cfc3b24..b3e954f141 100644 --- a/src/app-layer-detect-proto.c +++ b/src/app-layer-detect-proto.c @@ -1668,6 +1668,17 @@ int SCAppLayerProtoDetectPMRegisterPatternCI(uint8_t ipproto, AppProto alproto, SCReturnInt(r); } +bool SCAppLayerProtoDetectGetStreamDataSize(const Flow *f, uint8_t direction, uint32_t *size) +{ + TcpSession *ssn = (TcpSession *)f->protoctx; + if (f->proto != IPPROTO_TCP || ssn == NULL) { + return false; + } + TcpStream *stream = (direction & STREAM_TOSERVER) ? &ssn->client : &ssn->server; + *size = StreamDataAvailableForProtoDetect(stream); + return true; +} + /***** Setup/General Registration *****/ int AppLayerProtoDetectSetup(void) diff --git a/src/app-layer-detect-proto.h b/src/app-layer-detect-proto.h index 4e9147ca40..f333cf0640 100644 --- a/src/app-layer-detect-proto.h +++ b/src/app-layer-detect-proto.h @@ -93,6 +93,14 @@ int SCAppLayerProtoDetectPMRegisterPatternCIwPP(uint8_t ipproto, AppProto alprot const char *pattern, uint16_t depth, uint16_t offset, uint8_t direction, ProbingParserFPtr PPFunc, uint16_t pp_min_depth, uint16_t pp_max_depth); +/** + * \brief Get the size of the stream data available for protocol detection + * in the given direction, for use by probing parsers. + * + * \retval bool false if the flow has no TCP session + */ +bool SCAppLayerProtoDetectGetStreamDataSize(const Flow *f, uint8_t direction, uint32_t *size); + /***** Setup/General Registration *****/ /** diff --git a/src/flow-bindgen.h b/src/flow-bindgen.h index 11e793db5d..71617b47cb 100644 --- a/src/flow-bindgen.h +++ b/src/flow-bindgen.h @@ -47,5 +47,6 @@ const uint8_t *SCFlowGetDestinationAddressAsRawPtr(const Flow *flow); uint32_t SCFlowGetToServerPacketCount(const Flow *flow); uint32_t SCFlowGetToClientPacketCount(const Flow *flow); AppProto SCFlowGetAppProtocol(const Flow *f); +AppProto SCFlowGetAppProtocolToClient(const Flow *f); #endif /* SURICATA_FLOW_BINDGEN_H */ diff --git a/src/flow.c b/src/flow.c index d58b3d9b6d..e662e8838f 100644 --- a/src/flow.c +++ b/src/flow.c @@ -1290,6 +1290,11 @@ AppProto SCFlowGetAppProtocol(const Flow *f) return f->alproto; } +AppProto SCFlowGetAppProtocolToClient(const Flow *f) +{ + return f->alproto_tc; +} + /** * \brief Get flow destination port. * From 1d7da06d9ff32dd6636dcb2afd0371f2dc770a20 Mon Sep 17 00:00:00 2001 From: Giuseppe Longo Date: Fri, 23 Jan 2026 10:36:13 +0100 Subject: [PATCH 2/5] imap: implement parser This introduces a parser for IMAP protocol, replacing the detection-only implementation. An IMAP transaction has two states driven by the 'complete' field: - Open (complete = false): waiting for a matching tagged response. - Complete (complete = true): tagged response received, or special conditions met (BYE, server greeting). Completion logic (is_complete): - If a tagged request exists: requires a matching tagged response. - No tagged request + BYE: complete (server closing). - No tagged request + any response: complete (server greeting). Transactions are created in three places: 1. Request parser: every parsed command creates a new tx. 2. Response parser (tagged, no matching request): midstream/async; orphan tagged response gets its own tx. 3. Response parser (untagged, no incomplete tx): server greeting or unsolicited response. Once open, messages accumulate: - Request side: continuation and literal data attach to the most recent incomplete tx. - Response side: untagged responses attach to the most recent incomplete tx; tagged responses attach via find_request(tag). Literals, sent by the client (e.g. APPEND) or by the server (e.g. FETCH), are consumed incrementally and their content is not retained. Hard limits prevent unbounded growth: - IMAP_MAX_TX (256, configurable): total transactions. Exceeded: all incomplete txs force-completed with TooManyTransactions event. - IMAP_MAX_MSGS_PER_TX (512): requests per tx. Exceeded: request dropped, DataLimitReached event. - IMAP_MAX_LINES (512): response lines per tx. Exceeded: line dropped, DataLimitReached event. - IMAP_MAX_LINE_BYTES_PER_STATE (100MB): retained request and response data per flow. Exceeded: data dropped, DataLimitReached event. - IMAP_MAX_LINE_SIZE (8KB): request/response line max length. Exceeded: LineTooLong event. - Literal size: bounded by declared size in {N} specifier. IMAP_MAX_TX only is configurable limit via app-layer.protocols.imap.max-tx. Ticket #8276 --- rules/Makefile.am | 1 + rules/README.md | 1 + rules/imap-events.rules | 8 + rust/src/imap/imap.rs | 1394 +++++++++++++++++ src/app-layer-imap.h => rust/src/imap/mod.rs | 15 +- rust/src/imap/parser.rs | 1471 ++++++++++++++++++ rust/src/lib.rs | 1 + src/Makefile.am | 2 - src/app-layer-imap.c | 133 -- src/app-layer-parser.c | 3 +- suricata.yaml.in | 3 +- 11 files changed, 2883 insertions(+), 149 deletions(-) create mode 100644 rules/imap-events.rules create mode 100644 rust/src/imap/imap.rs rename src/app-layer-imap.h => rust/src/imap/mod.rs (72%) create mode 100644 rust/src/imap/parser.rs delete mode 100644 src/app-layer-imap.c diff --git a/rules/Makefile.am b/rules/Makefile.am index 73226efb3a..10f9af3715 100644 --- a/rules/Makefile.am +++ b/rules/Makefile.am @@ -12,6 +12,7 @@ files.rules \ ftp-events.rules \ http-events.rules \ http2-events.rules \ +imap-events.rules \ ipsec-events.rules \ kerberos-events.rules \ ldap-events.rules \ diff --git a/rules/README.md b/rules/README.md index 90a266e320..44e17ae17d 100644 --- a/rules/README.md +++ b/rules/README.md @@ -39,6 +39,7 @@ signature IDs. | PGSQL | 2241000 | 2241999 | | mDNS | 2242000 | 2242999 | | Bittorent| 2243000 | 2243999 | +| IMAP | 2244000 | 2244999 | | MODBUS | 2250000 | 2250999 | | DNP3 | 2270000 | 2270999 | | SIP | 2280000 | 2280999 | diff --git a/rules/imap-events.rules b/rules/imap-events.rules new file mode 100644 index 0000000000..87f6d953a2 --- /dev/null +++ b/rules/imap-events.rules @@ -0,0 +1,8 @@ +# IMAP app layer event rules +# +# SID's fall in the 2244000+ range. See rules/README.md + +alert imap any any -> any any (msg:"SURICATA IMAP too many transactions"; app-layer-event:imap.too_many_transactions; classtype:protocol-command-decode; sid:2244000; rev:1;) +alert imap any any -> any any (msg:"SURICATA IMAP invalid data"; app-layer-event:imap.invalid_data; classtype:protocol-command-decode; sid:2244001; rev:1;) +alert imap any any -> any any (msg:"SURICATA IMAP line too long"; app-layer-event:imap.line_too_long; classtype:protocol-command-decode; sid:2244002; rev:1;) +alert imap any any -> any any (msg:"SURICATA IMAP data limit reached"; app-layer-event:imap.data_limit_reached; classtype:protocol-command-decode; sid:2244003; rev:1;) diff --git a/rust/src/imap/imap.rs b/rust/src/imap/imap.rs new file mode 100644 index 0000000000..204495bca8 --- /dev/null +++ b/rust/src/imap/imap.rs @@ -0,0 +1,1394 @@ +/* Copyright (C) 2026 Open Information Security Foundation + * + * You can copy, redistribute or modify this Program under the terms of + * the GNU General Public License version 2 as published by the Free + * Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * version 2 along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +// Author: Giuseppe Longo + +use crate::applayer::{self, *}; +use crate::conf::conf_get; +use crate::core::*; +use crate::direction::Direction; +use crate::flow::Flow; +use crate::frames::*; +use suricata_derive::AppLayerState; + +use crate::imap::parser::{ + parse_command, parse_command_continuation, parse_continuation_data, parse_response, + parse_response_continuation, peek_untagged, probe_command_prefix, sequence_set_contains, + untagged_trailing_literal, CommandLiteral, ImapCommand, ImapMessage, ImapMessageType, + ImapResponseStatus, LiteralInfo, IMAP_MAX_LINE_SIZE, +}; +use nom8::error::{Error, ErrorKind}; +use nom8::Err; +use std; +use std::collections::VecDeque; +use std::ffi::CString; +use std::os::raw::{c_char, c_int, c_void}; +use suricata_sys::sys::{ + AppLayerParserState, AppProto, SCAppLayerParserConfParserEnabled, + SCAppLayerParserRegisterLogger, SCAppLayerParserStateIssetFlag, + SCAppLayerProtoDetectConfProtoDetectionEnabled, SCAppLayerProtoDetectGetStreamDataSize, + SCAppLayerProtoDetectPMRegisterPatternCI, SCAppLayerProtoDetectPMRegisterPatternCIwPP, + SCAppLayerRequestProtocolTLSUpgrade, SCFlowGetAppProtocolToClient, +}; + +const IMAP_MAX_TX_DEFAULT: usize = 256; +const IMAP_MAX_LINES: usize = 512; +const IMAP_MAX_MSGS_PER_TX: usize = 512; +const IMAP_MAX_LINE_BYTES_PER_STATE: usize = 100 * 1024 * 1024; + +static mut IMAP_MAX_TX: usize = IMAP_MAX_TX_DEFAULT; + +pub(super) static mut ALPROTO_IMAP: AppProto = ALPROTO_UNKNOWN; + +#[derive(AppLayerFrameType)] +pub enum ImapFrameType { + Pdu, +} + +#[derive(AppLayerEvent)] +enum ImapEvent { + TooManyTransactions, + InvalidData, + LineTooLong, + DataLimitReached, +} + +fn retained_request_bytes(request: &ImapMessage) -> usize { + let mut bytes = request.raw_line.capacity() + request.tag.as_ref().map_or(0, Vec::capacity); + if let ImapMessageType::Command { + command, arguments, .. + } = &request.message + { + bytes += arguments.capacity() * std::mem::size_of::>() + + arguments.iter().map(Vec::capacity).sum::(); + if let ImapCommand::Unknown(name) = command { + bytes += name.capacity(); + } + } + bytes +} + +fn retain_item(items: &mut Vec, item: T, mut bytes: usize, budget: &mut usize) -> bool { + if bytes > *budget { + return false; + } + if items.len() == items.capacity() { + let slots = ((*budget - bytes) / std::mem::size_of::()).min(items.len().max(1)); + if slots == 0 { + return false; + } + let mut grown = Vec::with_capacity(items.len() + slots); + bytes += (grown.capacity() - items.capacity()) * std::mem::size_of::(); + if bytes > *budget { + return false; + } + grown.append(items); + *items = grown; + } + items.push(item); + *budget -= bytes; + true +} + +fn extract_command(request: &ImapMessage) -> Vec { + if let ImapMessageType::Command { + command, arguments, .. + } = &request.message + { + // UID is a prefix, the actual command (FETCH, STORE, etc.) is in arguments[0]. + if matches!(command, ImapCommand::Uid) { + arguments + .first() + .map(|arg| arg.to_ascii_uppercase()) + .unwrap_or_default() + } else { + command.to_string().into_bytes() + } + } else { + Vec::new() + } +} + +#[derive(AppLayerState, Copy, Clone, Debug, PartialOrd, PartialEq, Eq)] +#[suricata(alstate_strip_prefix = "ImapState")] +pub enum ImapStateProgress { + ImapStateInProgress = 0, + ImapStateComplete = 1, +} + +#[derive(Debug)] +pub struct ImapTransaction { + pub tx_id: u64, + + progress_ts: ImapStateProgress, + progress_tc: ImapStateProgress, + + pub requests: Vec, + pub response_lines: Vec>, + + request_tag: Option>, + pub command: Vec, + line_bytes: usize, + data_limit_event_set: bool, + + tx_data: AppLayerTxData, +} + +impl ImapTransaction { + pub fn new() -> ImapTransaction { + Self { + tx_id: 0, + progress_ts: ImapStateProgress::ImapStateInProgress, + progress_tc: ImapStateProgress::ImapStateInProgress, + requests: Vec::new(), + response_lines: Vec::new(), + request_tag: None, + command: Vec::new(), + line_bytes: 0, + data_limit_event_set: false, + tx_data: AppLayerTxData::new(), + } + } + + fn complete(&self) -> bool { + self.progress_tc == ImapStateProgress::ImapStateComplete + } + + fn awaiting_response(&self) -> bool { + !self.complete() && self.request_tag.is_some() + } + + fn update_completion_from_response(&mut self, response: &ImapMessage) { + let completes = match &self.request_tag { + Some(request_tag) => { + response.tag.as_deref() == Some(request_tag.as_slice()) + && matches!(response.message, ImapMessageType::Response { .. }) + } + None => true, + }; + if completes { + if self.request_tag.is_some() { + self.complete_request(); + } + self.progress_tc = ImapStateProgress::ImapStateComplete; + } + } + + fn complete_request(&mut self) { + if self.progress_ts != ImapStateProgress::ImapStateComplete { + self.progress_ts = ImapStateProgress::ImapStateComplete; + self.tx_data.0.updated_ts = true; + } + } + + fn complete_response(&mut self) { + if self.progress_tc != ImapStateProgress::ImapStateComplete { + self.progress_tc = ImapStateProgress::ImapStateComplete; + self.tx_data.0.updated_tc = true; + } + } + + fn mark_data_limit(&mut self) { + if !self.data_limit_event_set { + self.tx_data.set_event(ImapEvent::DataLimitReached as u8); + self.data_limit_event_set = true; + } + } + + fn add_request(&mut self, req: ImapMessage, line_budget: usize) -> bool { + self.tx_data.0.updated_ts = true; + if req.line_truncated { + self.tx_data.set_event(ImapEvent::LineTooLong as u8); + } + let mut rem = line_budget; + if self.request_tag.is_none() && matches!(req.message, ImapMessageType::Command { .. }) { + let tag = req.tag.clone(); + let command = extract_command(&req); + let bytes = tag.as_ref().map_or(0, Vec::capacity) + command.capacity(); + if bytes > rem { + self.mark_data_limit(); + return false; + } + rem -= bytes; + self.request_tag = tag; + self.command = command; + } + let request_bytes = retained_request_bytes(&req); + if self.requests.len() >= IMAP_MAX_MSGS_PER_TX + || !retain_item(&mut self.requests, req, request_bytes, &mut rem) + { + self.mark_data_limit(); + } + self.line_bytes += line_budget - rem; + true + } + + fn add_response(&mut self, mut resp: ImapMessage, line_budget: usize) { + self.tx_data.0.updated_tc = true; + let mut rem = line_budget; + let bytes = resp.raw_line.capacity(); + if !resp.raw_line.is_empty() + && (self.response_lines.len() >= IMAP_MAX_LINES + || !retain_item( + &mut self.response_lines, + std::mem::take(&mut resp.raw_line), + bytes, + &mut rem, + )) + { + self.mark_data_limit(); + } + self.line_bytes += line_budget - rem; + if resp.line_truncated { + self.tx_data.set_event(ImapEvent::LineTooLong as u8); + } + self.update_completion_from_response(&resp); + } +} + +impl Default for ImapTransaction { + fn default() -> Self { + Self::new() + } +} + +impl Transaction for ImapTransaction { + fn id(&self) -> u64 { + self.tx_id + } +} + +#[derive(Debug)] +struct PendingLiteral { + tx_id: u64, + literal: LiteralInfo, + continuation_received: bool, + awaiting_line_rest: bool, + parenthesis_depth: usize, +} + +impl PendingLiteral { + fn new(tx_id: u64, literal: CommandLiteral) -> Self { + Self { + tx_id, + literal: LiteralInfo::new(literal.size, literal.is_literal_plus), + continuation_received: false, + awaiting_line_rest: false, + parenthesis_depth: literal.parenthesis_depth, + } + } + + fn is_ready(&self) -> bool { + self.literal.is_literal_plus || self.continuation_received + } +} + +#[derive(Debug)] +struct PendingResponseLiteral { + tx_id: Option, + // Untagged response announcing the literal. The text following the + // literal octets is appended to its line until the response ends. + response: ImapMessage, + literal: LiteralInfo, + awaiting_line_rest: bool, +} + +impl PendingResponseLiteral { + fn new(tx_id: Option, response: ImapMessage, size: u64) -> Self { + Self { + tx_id, + response, + literal: LiteralInfo::new(size, false), + awaiting_line_rest: size == 0, + } + } + + fn append_line_rest(&mut self, rest: &[u8]) { + let line = &mut self.response.raw_line; + let room = IMAP_MAX_LINE_SIZE.saturating_sub(line.len()); + if rest.len() > room { + self.response.line_truncated = true; + } + line.extend_from_slice(&rest[..rest.len().min(room)]); + } +} + +const UNTAGGED_KEYWORD_COMMANDS: &[(&[u8], &[&[u8]])] = &[ + (b"FETCH", &[b"FETCH"]), + (b"SEARCH", &[b"SEARCH"]), + (b"ESEARCH", &[b"SEARCH"]), + (b"FLAGS", &[b"SELECT", b"EXAMINE"]), + (b"EXISTS", &[b"SELECT", b"EXAMINE"]), + (b"RECENT", &[b"SELECT", b"EXAMINE"]), + (b"CAPABILITY", &[b"CAPABILITY"]), + (b"LIST", &[b"LIST"]), + (b"LSUB", &[b"LSUB"]), + (b"STATUS", &[b"STATUS"]), + (b"ID", &[b"ID"]), + (b"NAMESPACE", &[b"NAMESPACE"]), + (b"ENABLED", &[b"ENABLE"]), + (b"SORT", &[b"SORT"]), + (b"THREAD", &[b"THREAD"]), + (b"QUOTA", &[b"GETQUOTA", b"GETQUOTAROOT", b"SETQUOTA"]), + (b"QUOTAROOT", &[b"GETQUOTAROOT"]), +]; + +pub struct ImapState { + state_data: AppLayerStateData, + tx_id: u64, + transactions: VecDeque, + request_frame: Option, + response_frame: Option, + request_gap: bool, + response_gap: bool, + // Transaction currently receiving a group of untagged responses. + active_response_tx_id: Option, + // Transaction that owns the next client continuation line. + continuation_tx_id: Option, + // Literal announced by the current client command line, until its line ends. + pending_literal: Option, + // Literal announced by an untagged non-FETCH response, until its line ends. + pending_response_literal: Option, +} + +impl State for ImapState { + fn get_transaction_count(&self) -> usize { + self.transactions.len() + } + + fn get_transaction_by_index(&self, index: usize) -> Option<&ImapTransaction> { + self.transactions.get(index) + } +} + +impl Default for ImapState { + fn default() -> Self { + Self::new() + } +} + +impl ImapState { + pub fn new() -> Self { + Self { + state_data: AppLayerStateData::default(), + tx_id: 0, + transactions: VecDeque::new(), + request_frame: None, + response_frame: None, + request_gap: false, + response_gap: false, + active_response_tx_id: None, + continuation_tx_id: None, + pending_literal: None, + pending_response_literal: None, + } + } + + fn line_retain_budget(&self) -> usize { + let used: usize = self.transactions.iter().map(|tx| tx.line_bytes).sum(); + IMAP_MAX_LINE_BYTES_PER_STATE.saturating_sub(used) + } + + fn tx_by_id(&self, tx_id: u64) -> Option<&ImapTransaction> { + self.transactions.iter().find(|tx| tx.tx_id == tx_id) + } + + fn tx_by_id_mut(&mut self, tx_id: u64) -> Option<&mut ImapTransaction> { + self.transactions.iter_mut().find(|tx| tx.tx_id == tx_id) + } + + fn active_response_tx_id(&self) -> Option { + self.active_response_tx_id + .and_then(|tx_id| self.tx_by_id(tx_id)) + .filter(|tx| tx.awaiting_response()) + .map(|tx| tx.tx_id) + } + + fn single_pending_tx_id(&self) -> Option { + let mut outstanding = self.transactions.iter().filter(|tx| tx.awaiting_response()); + match (outstanding.next(), outstanding.next()) { + (Some(tx), None) => Some(tx.tx_id), + _ => None, + } + } + + fn fetch_request_may_include_sequence( + tx: &ImapTransaction, sequence_number: Option, + ) -> bool { + let Some(sequence_number) = sequence_number else { + return true; + }; + let Some(request) = tx.requests.first() else { + return true; + }; + + match &request.message { + ImapMessageType::Command { + command: ImapCommand::Fetch, + arguments, + .. + } => arguments + .first() + .and_then(|set| sequence_set_contains(set, sequence_number)) + .unwrap_or(true), + // An untagged UID FETCH response starts with the message sequence + // number, not the UID requested by the client. + _ => true, + } + } + + fn resolve_fetch_response_target(&self, sequence_number: Option) -> Option { + let mut candidate = None; + let mut found_fetch_request = false; + + for tx in self + .transactions + .iter() + .filter(|tx| tx.awaiting_response() && tx.command.eq_ignore_ascii_case(b"FETCH")) + { + found_fetch_request = true; + if !Self::fetch_request_may_include_sequence(tx, sequence_number) { + continue; + } + if candidate.is_some() { + return None; + } + candidate = Some(tx.tx_id); + } + + if found_fetch_request { + candidate + } else { + self.single_pending_tx_id() + } + } + + fn resolve_untagged_response_target( + &self, sequence_number: Option, keyword: &[u8], + ) -> Option { + if keyword.eq_ignore_ascii_case(b"FETCH") { + return self.resolve_fetch_response_target(sequence_number); + } + + if let Some((_, expected_commands)) = UNTAGGED_KEYWORD_COMMANDS + .iter() + .find(|(untagged, _)| keyword.eq_ignore_ascii_case(untagged)) + { + let mut candidate = None; + for tx in self.transactions.iter().filter(|tx| tx.awaiting_response()) { + if expected_commands + .iter() + .any(|command| tx.command.eq_ignore_ascii_case(command)) + { + if candidate.is_some() { + return None; + } + candidate = Some(tx.tx_id); + } + } + return candidate.or_else(|| self.single_pending_tx_id()); + } + + if let Some(tx_id) = self.active_response_tx_id() { + return Some(tx_id); + } + + self.single_pending_tx_id() + } + + fn commit_untagged_response_target(&mut self, target: Option) -> Option { + self.active_response_tx_id = target; + target + } + + fn continuation_response_tx_id(&self) -> Option { + self.pending_literal + .as_ref() + .map(|pending| pending.tx_id) + .or_else(|| { + self.transactions + .iter() + .rev() + .find(|tx| { + !tx.complete() + && tx.progress_ts == ImapStateProgress::ImapStateInProgress + && (tx.command.eq_ignore_ascii_case(b"AUTHENTICATE") + || tx.command.eq_ignore_ascii_case(b"IDLE")) + }) + .map(|tx| tx.tx_id) + }) + .or_else(|| { + self.transactions + .iter() + .rev() + .find(|tx| !tx.complete()) + .map(|tx| tx.tx_id) + }) + } + + fn forget_tx(&mut self, tx_id: u64) { + if self.active_response_tx_id == Some(tx_id) { + self.active_response_tx_id = None; + } + if self.continuation_tx_id == Some(tx_id) { + self.continuation_tx_id = None; + } + if self + .pending_literal + .as_ref() + .is_some_and(|pending| pending.tx_id == tx_id) + { + self.pending_literal = None; + } + if let Some(pending) = self + .pending_response_literal + .as_mut() + .filter(|pending| pending.tx_id == Some(tx_id)) + { + pending.tx_id = None; + } + } + + fn free_tx(&mut self, tx_id: u64) { + if let Some(index) = self + .transactions + .iter() + .position(|tx| tx.tx_id == tx_id + 1) + { + self.forget_tx(tx_id + 1); + self.transactions.remove(index); + } + } + + pub fn get_tx(&mut self, tx_id: u64) -> Option<&ImapTransaction> { + self.transactions.iter().find(|tx| tx.tx_id == tx_id + 1) + } + + pub fn new_tx(&mut self) -> Option { + if self.transactions.len() >= unsafe { IMAP_MAX_TX } { + self.active_response_tx_id = None; + self.continuation_tx_id = None; + self.pending_literal = None; + self.pending_response_literal = None; + self.response_frame = None; + for tx_old in &mut self.transactions { + if !tx_old.complete() { + tx_old.tx_data.0.updated_tc = true; + tx_old.tx_data.0.updated_ts = true; + tx_old.progress_ts = ImapStateProgress::ImapStateComplete; + tx_old.progress_tc = ImapStateProgress::ImapStateComplete; + tx_old + .tx_data + .set_event(ImapEvent::TooManyTransactions as u8); + } + } + return None; + } + let mut tx = ImapTransaction::new(); + self.tx_id += 1; + tx.tx_id = self.tx_id; + return Some(tx); + } + + /// At end of stream, complete the given direction of every open + /// transaction so buffers that are already populated (e.g. the response + /// lines) are inspected even without a tagged completion reply. + fn complete_transactions_at_eof(&mut self, direction: Direction) { + if matches!(direction, Direction::ToClient) { + if let Some(pending) = self.pending_response_literal.take() { + self.complete_pending_response(pending); + } + } + for tx in &mut self.transactions { + match direction { + Direction::ToServer => tx.complete_request(), + Direction::ToClient => tx.complete_response(), + } + } + } + + fn push_response_tx(&mut self) -> Option<&mut ImapTransaction> { + let mut tx = self.new_tx()?; + tx.tx_data = AppLayerTxData::for_direction(Direction::ToClient); + tx.progress_ts = ImapStateProgress::ImapStateComplete; + self.transactions.push_back(tx); + self.transactions.back_mut() + } + + fn set_event(&mut self, e: ImapEvent) { + if let Some(tx) = self.transactions.back_mut() { + tx.tx_data.set_event(e as u8); + } + } + + fn reject_oversized_tag(&mut self, direction: Direction) -> AppLayerResult { + if let Some(mut tx) = self.new_tx() { + tx.tx_data = AppLayerTxData::for_direction(direction); + tx.tx_data.set_event(ImapEvent::LineTooLong as u8); + tx.progress_ts = ImapStateProgress::ImapStateComplete; + tx.progress_tc = ImapStateProgress::ImapStateComplete; + self.transactions.push_back(tx); + } + AppLayerResult::err() + } + + fn find_request(&mut self, tag: &[u8]) -> Option<&mut ImapTransaction> { + self.transactions + .iter_mut() + .find(|tx| tx.request_tag.as_deref() == Some(tag) && !tx.complete()) + } + + fn parse_request(&mut self, flow: *mut Flow, stream_slice: StreamSlice) -> AppLayerResult { + let input = stream_slice.as_slice(); + if input.is_empty() { + return AppLayerResult::ok(); + } + + if self.request_gap { + if parse_command(input).is_err() { + return AppLayerResult::ok(); + } + self.request_gap = false; + } + + let incomplete = |rest: &[u8]| { + AppLayerResult::incomplete((input.len() - rest.len()) as u32, (rest.len() + 1) as u32) + }; + let mut start = input; + while !start.is_empty() { + if let Some((tx_id, depth)) = self + .pending_literal + .as_ref() + .filter(|pending| pending.awaiting_line_rest) + .map(|pending| (pending.tx_id, pending.parenthesis_depth)) + { + match parse_command_continuation(start, depth) { + Ok((rem, (request, literal))) => { + if !request.raw_line.is_empty() { + let line_budget = self.line_retain_budget(); + if let Some(tx) = self.tx_by_id_mut(tx_id) { + tx.add_request(request, line_budget); + } + } + if let Some(literal) = literal { + self.pending_literal = Some(PendingLiteral::new(tx_id, literal)); + } else { + self.pending_literal = None; + if let Some(tx) = self.tx_by_id_mut(tx_id) { + tx.complete_request(); + } + } + start = rem; + continue; + } + Err(Err::Incomplete(_)) => return incomplete(start), + Err(_) => { + self.set_event(ImapEvent::InvalidData); + return AppLayerResult::err(); + } + } + } + + if self.request_frame.is_none() { + self.request_frame = Frame::new( + flow, + &stream_slice, + start, + -1_i64, + ImapFrameType::Pdu as u8, + None, + ); + SCLogDebug!("ts: pdu {:?}", self.request_frame); + } + + if let Some(mut pending) = self.pending_literal.take_if(|pending| pending.is_ready()) { + let consumed = pending.literal.consume_chunk(start); + if pending.literal.remaining() != 0 { + self.pending_literal = Some(pending); + return AppLayerResult::ok(); + } + + let tx_id = pending.tx_id; + if let Some(tx) = self.tx_by_id_mut(tx_id) { + tx.tx_data.0.updated_ts = true; + let frame_len = i64::try_from(pending.literal.size).unwrap_or(i64::MAX); + self.set_frame_ts(flow, tx_id, frame_len); + } + + pending.awaiting_line_rest = true; + self.pending_literal = Some(pending); + start = &start[consumed..]; + continue; + } + + if let Some(continuation_tx_id) = self.continuation_tx_id { + match parse_continuation_data(start) { + Ok((rem, request)) => { + let consumed = start.len() - rem.len(); + + self.continuation_tx_id = None; + + let line_budget = self.line_retain_budget(); + if let Some(tx) = self + .tx_by_id_mut(continuation_tx_id) + .filter(|tx| !tx.complete()) + { + let tx_id = tx.id(); + let data = request.raw_line.as_slice(); + let completes_request = (tx.command.eq_ignore_ascii_case(b"IDLE") + && data.eq_ignore_ascii_case(b"DONE")) + || (tx.command.eq_ignore_ascii_case(b"AUTHENTICATE") + && data == b"*"); + tx.add_request(request, line_budget); + if completes_request { + tx.complete_request(); + } + start = rem; + self.set_frame_ts(flow, tx_id, consumed as i64); + continue; + } + } + Err(Err::Incomplete(_)) => return incomplete(start), + Err(_) => {} + } + self.continuation_tx_id = None; + if let Some(pos) = start.iter().position(|&c| c == b'\n') { + start = &start[pos + 1..]; + continue; + } else { + break; + } + } + + match parse_command(start) { + Ok((rem, request)) => { + let consumed = start.len() - rem.len(); + + let mut setup_literal = None; + let mut client_data_pending = false; + if let ImapMessageType::Command { + command, literal, .. + } = &request.message + { + if let Some(literal) = literal { + setup_literal = Some(*literal); + client_data_pending = true; + } + if matches!(command, ImapCommand::Authenticate | ImapCommand::Idle) { + client_data_pending = true; + } + } + + let line_budget = self.line_retain_budget(); + let Some(mut tx) = self.new_tx() else { + return AppLayerResult::err(); + }; + let tx_id = tx.id(); + if !tx.add_request(request, line_budget) { + tx.progress_ts = ImapStateProgress::ImapStateComplete; + tx.progress_tc = ImapStateProgress::ImapStateComplete; + self.transactions.push_back(tx); + return AppLayerResult::err(); + } + if !client_data_pending { + tx.complete_request(); + } + self.transactions.push_back(tx); + start = rem; + self.set_frame_ts(flow, tx_id, consumed as i64); + + if let Some(literal) = setup_literal { + self.pending_literal = Some(PendingLiteral::new(tx_id, literal)); + } + } + Err(Err::Incomplete(_)) => return incomplete(start), + Err(Err::Error(e)) if e.code == ErrorKind::Eof => { + break; + } + Err(Err::Failure(e)) if e.code == ErrorKind::TooLarge => { + return self.reject_oversized_tag(Direction::ToServer); + } + Err(_e) => { + self.set_event(ImapEvent::InvalidData); + return AppLayerResult::err(); + } + } + } + + return AppLayerResult::ok(); + } + + fn parse_response(&mut self, flow: *mut Flow, stream_slice: StreamSlice) -> AppLayerResult { + let input = stream_slice.as_slice(); + if input.is_empty() { + return AppLayerResult::ok(); + } + + if self.response_gap { + if probe_response(input).is_err() { + return AppLayerResult::ok(); + } + self.response_gap = false; + } + + let incomplete = |rest: &[u8]| { + AppLayerResult::incomplete((input.len() - rest.len()) as u32, (rest.len() + 1) as u32) + }; + let mut start = input; + while !start.is_empty() { + if let Some(mut pending) = self + .pending_response_literal + .take_if(|pending| pending.awaiting_line_rest) + { + match parse_response_continuation(start) { + Ok((rem, (rest, literal_size))) => { + pending.append_line_rest(rest); + if let Some(size) = literal_size { + self.pending_response_literal = Some(PendingResponseLiteral::new( + pending.tx_id, + pending.response, + size, + )); + } else { + self.complete_pending_response(pending); + } + start = rem; + continue; + } + Err(Err::Incomplete(_)) => { + self.pending_response_literal = Some(pending); + return incomplete(start); + } + Err(_) => { + self.set_event(ImapEvent::InvalidData); + return AppLayerResult::err(); + } + } + } + + if self.response_frame.is_none() { + self.response_frame = Frame::new( + flow, + &stream_slice, + start, + -1_i64, + ImapFrameType::Pdu as u8, + None, + ); + SCLogDebug!("tc: pdu {:?}", self.response_frame); + } + + if let Some(pending) = self + .pending_response_literal + .as_mut() + .filter(|pending| !pending.awaiting_line_rest) + { + let consumed = pending.literal.consume_chunk(start); + if pending.literal.remaining() != 0 { + return AppLayerResult::ok(); + } + + pending.awaiting_line_rest = true; + let tx_id = pending.tx_id; + let frame_len = i64::try_from(pending.literal.size).unwrap_or(i64::MAX); + if let Some(tx_id) = tx_id { + self.set_frame_tc(flow, tx_id, frame_len); + } else { + self.response_frame = None; + } + start = &start[consumed..]; + continue; + } + + let untagged = peek_untagged(start); + let untagged_response_target = untagged.and_then(|(sequence_number, keyword)| { + self.resolve_untagged_response_target(sequence_number, keyword) + }); + let line_budget = self.line_retain_budget(); + + match parse_response(start) { + Ok((rem, response)) => { + let consumed = start.len() - rem.len(); + + if let Some(ref tag) = response.tag { + if let Some(tx) = self.find_request(tag) { + let tx_id = tx.id(); + let starttls_ok = tx.command.eq_ignore_ascii_case(b"STARTTLS") + && matches!( + response.message, + ImapMessageType::Response { + status: ImapResponseStatus::Ok + } + ); + tx.add_response(response, line_budget); + if starttls_ok { + SCLogDebug!("IMAP: STARTTLS completed"); + unsafe { + SCAppLayerRequestProtocolTLSUpgrade(flow); + } + } + /* The tagged reply completes the command, so no + * pending client or server state may refer to it. */ + self.forget_tx(tx_id); + self.set_frame_tc(flow, tx_id, consumed as i64); + } else { + let Some(tx) = self.push_response_tx() else { + return AppLayerResult::err(); + }; + let tx_id = tx.id(); + tx.add_response(response, line_budget); + self.set_frame_tc(flow, tx_id, consumed as i64); + } + } else { + let is_continuation = + matches!(response.message, ImapMessageType::Continuation); + + let untagged_target = if let ImapMessageType::Untagged { + seq_number, + keyword, + .. + } = &response.message + { + Some(untagged_response_target.or_else(|| { + self.resolve_untagged_response_target(*seq_number, keyword) + })) + } else { + None + }; + let response_tx_id = if let Some(target) = untagged_target { + self.commit_untagged_response_target(target) + } else if is_continuation { + self.continuation_response_tx_id() + } else { + self.transactions + .iter() + .rev() + .find(|tx| !tx.complete()) + .map(|tx| tx.tx_id) + }; + + if is_continuation { + if let Some(pending) = self.pending_literal.as_mut() { + if !pending.literal.is_literal_plus { + pending.continuation_received = true; + } + } else { + self.continuation_tx_id = response_tx_id; + } + } + + let tx_id = + match response_tx_id.filter(|&tx_id| self.tx_by_id(tx_id).is_some()) { + Some(tx_id) => tx_id, + None => { + if untagged_target.is_some() { + self.active_response_tx_id = None; + } + if is_continuation { + self.continuation_tx_id = None; + } + let Some(tx) = self.push_response_tx() else { + return AppLayerResult::err(); + }; + tx.id() + } + }; + + /* A data response (LIST, STATUS, ID, ...) may end in a + * literal specifier: its octets follow the CRLF. */ + let literal_size = match &response.message { + ImapMessageType::Untagged { keyword, .. } => untagged_trailing_literal( + keyword, + &start[..consumed.saturating_sub(2)], + ), + _ => None, + }; + + if let Some(size) = literal_size { + self.pending_response_literal = + Some(PendingResponseLiteral::new(Some(tx_id), response, size)); + } else if let Some(tx) = self.tx_by_id_mut(tx_id) { + tx.add_response(response, line_budget); + } + self.set_frame_tc(flow, tx_id, consumed as i64); + } + start = rem; + } + Err(Err::Incomplete(_)) => return incomplete(start), + Err(Err::Error(e)) if e.code == ErrorKind::Eof => { + break; + } + Err(Err::Failure(e)) if e.code == ErrorKind::TooLarge => { + return self.reject_oversized_tag(Direction::ToClient); + } + Err(_e) => { + self.set_event(ImapEvent::InvalidData); + return AppLayerResult::err(); + } + } + } + + return AppLayerResult::ok(); + } + + fn set_frame_ts(&mut self, flow: *const Flow, tx_id: u64, consumed: i64) { + if let Some(frame) = &self.request_frame { + frame.set_len(flow, consumed); + frame.set_tx(flow, tx_id - 1); + self.request_frame = None; + } + } + + fn set_frame_tc(&mut self, flow: *const Flow, tx_id: u64, consumed: i64) { + if let Some(frame) = &self.response_frame { + frame.set_len(flow, consumed); + frame.set_tx(flow, tx_id - 1); + self.response_frame = None; + } + } + + fn on_request_gap(&mut self, _size: u32) { + self.request_gap = true; + self.continuation_tx_id = None; + self.pending_literal = None; + self.request_frame = None; + } + + fn on_response_gap(&mut self, _size: u32) { + self.response_gap = true; + self.active_response_tx_id = None; + self.continuation_tx_id = None; + self.pending_literal = None; + if let Some(pending) = self.pending_response_literal.take() { + self.complete_pending_response(pending); + } + self.response_frame = None; + } + + fn complete_pending_response(&mut self, pending: PendingResponseLiteral) { + let line_budget = self.line_retain_budget(); + if let Some(tx) = pending.tx_id.and_then(|tx_id| self.tx_by_id_mut(tx_id)) { + tx.add_response(pending.response, line_budget); + } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum ProbeResult { + Match { flip: bool }, + Incomplete, + Mismatch, +} + +fn probe_request(input: &[u8]) -> Result>> { + match parse_command(input) { + Ok((_, request)) => Ok(!matches!( + request.message, + ImapMessageType::Command { + command: ImapCommand::Unknown(_), + .. + } + )), + Err(Err::Incomplete(needed)) => { + if matches!(probe_command_prefix(input), Ok((_, true))) { + Ok(true) + } else { + Err(Err::Incomplete(needed)) + } + } + Err(e) => Err(e), + } +} + +fn probe_response(input: &[u8]) -> Result>> { + parse_response(input).map(|_| true) +} + +fn probe(input: &[u8], dir: Direction) -> ProbeResult { + let to_server = dir.is_to_server(); + let res = if to_server { + probe_request(input) + } else { + probe_response(input) + }; + match res { + Ok(true) => return ProbeResult::Match { flip: false }, + Err(Err::Incomplete(_)) => return ProbeResult::Incomplete, + Ok(false) | Err(_) => {} + } + let res = if to_server { + probe_response(input) + } else { + probe_request(input) + }; + match res { + Ok(true) => ProbeResult::Match { flip: true }, + Err(Err::Incomplete(_)) => ProbeResult::Incomplete, + Ok(false) | Err(_) => ProbeResult::Mismatch, + } +} + +unsafe extern "C" fn imap_probing_parser( + _flow: *const Flow, direction: u8, input: *const u8, input_len: u32, rdir: *mut u8, +) -> AppProto { + if input_len > 1 && !input.is_null() { + let slice = build_slice!(input, input_len as usize); + let dir: Direction = direction.into(); + return match probe(slice, dir) { + ProbeResult::Match { flip } => { + if flip { + *rdir = match dir { + Direction::ToServer => Direction::ToClient, + Direction::ToClient => Direction::ToServer, + } + .into(); + } + ALPROTO_IMAP + } + ProbeResult::Incomplete => ALPROTO_UNKNOWN, + ProbeResult::Mismatch => ALPROTO_FAILED, + }; + } + return ALPROTO_UNKNOWN; +} + +fn capability_tag_has_digit(input: &[u8]) -> bool { + input + .iter() + .take_while(|&&b| b != b' ') + .any(|b| b.is_ascii_digit()) +} + +unsafe extern "C" fn imap_capability_probing_parser( + flow: *const Flow, _direction: u8, input: *const u8, input_len: u32, _rdir: *mut u8, +) -> AppProto { + let alproto_tc = SCFlowGetAppProtocolToClient(flow); + if alproto_tc == ALPROTO_IMAP { + return ALPROTO_IMAP; + } + if !input.is_null() && capability_tag_has_digit(build_slice!(input, input_len as usize)) { + return ALPROTO_IMAP; + } + let mut size_tc: u32 = 0; + if !SCAppLayerProtoDetectGetStreamDataSize(flow, STREAM_TOCLIENT, &mut size_tc) { + return ALPROTO_FAILED; + } + if size_tc < 8 && alproto_tc == ALPROTO_UNKNOWN { + return ALPROTO_UNKNOWN; + } + return ALPROTO_FAILED; +} + +extern "C" fn imap_state_new(_orig_state: *mut c_void, _orig_proto: AppProto) -> *mut c_void { + let state = ImapState::new(); + let boxed = Box::new(state); + return Box::into_raw(boxed) as *mut c_void; +} + +unsafe extern "C" fn imap_state_free(state: *mut c_void) { + std::mem::drop(Box::from_raw(state as *mut ImapState)); +} + +unsafe extern "C" fn imap_state_tx_free(state: *mut c_void, tx_id: u64) { + let state = cast_pointer!(state, ImapState); + state.free_tx(tx_id); +} + +unsafe extern "C" fn imap_parse_request( + flow: *mut Flow, state: *mut c_void, pstate: *mut AppLayerParserState, + stream_slice: StreamSlice, _data: *mut c_void, +) -> AppLayerResult { + let eof = SCAppLayerParserStateIssetFlag(pstate, APP_LAYER_PARSER_EOF_TS) > 0; + if stream_slice.is_empty() && !eof { + return AppLayerResult::err(); + } + let state = cast_pointer!(state, ImapState); + + let result = if stream_slice.is_gap() { + state.on_request_gap(stream_slice.gap_size()); + AppLayerResult::ok() + } else { + state.parse_request(flow, stream_slice) + }; + if eof { + state.complete_transactions_at_eof(Direction::ToServer); + } + result +} + +unsafe extern "C" fn imap_parse_response( + flow: *mut Flow, state: *mut c_void, pstate: *mut AppLayerParserState, + stream_slice: StreamSlice, _data: *mut c_void, +) -> AppLayerResult { + let eof = SCAppLayerParserStateIssetFlag(pstate, APP_LAYER_PARSER_EOF_TC) > 0; + if stream_slice.is_empty() && !eof { + return AppLayerResult::err(); + } + let state = cast_pointer!(state, ImapState); + let result = if stream_slice.is_gap() { + state.on_response_gap(stream_slice.gap_size()); + AppLayerResult::ok() + } else { + state.parse_response(flow, stream_slice) + }; + if eof { + state.complete_transactions_at_eof(Direction::ToClient); + } + result +} + +unsafe extern "C" fn imap_state_get_tx(state: *mut c_void, tx_id: u64) -> *mut c_void { + let state = cast_pointer!(state, ImapState); + match state.get_tx(tx_id) { + Some(tx) => { + return tx as *const _ as *mut _; + } + None => { + return std::ptr::null_mut(); + } + } +} + +unsafe extern "C" fn imap_state_get_tx_count(state: *mut c_void) -> u64 { + let state = cast_pointer!(state, ImapState); + return state.tx_id; +} + +unsafe extern "C" fn imap_tx_get_alstate_progress(tx: *mut c_void, direction: u8) -> c_int { + let tx = cast_pointer!(tx, ImapTransaction); + if direction == STREAM_TOSERVER { + return tx.progress_ts as c_int; + } + return tx.progress_tc as c_int; +} + +export_tx_data_get!(imap_get_tx_data, ImapTransaction); +export_state_data_get!(imap_get_state_data, ImapState); + +const PARSER_NAME: &[u8] = b"imap\0"; + +fn register_pattern_probe() -> i8 { + const PATTERNS: &[&[u8]] = &[ + b"* OK \0", + b"* NO \0", + b"* BAD \0", + b"* LIST \0", + b"* ESEARCH \0", + b"* STATUS \0", + b"* FLAGS \0", + ]; + for pattern in PATTERNS { + let depth = (pattern.len() - 1) as u16; + let r = unsafe { + SCAppLayerProtoDetectPMRegisterPatternCI( + IPPROTO_TCP, + ALPROTO_IMAP, + pattern.as_ptr() as *const c_char, + depth, + 0, + Direction::ToClient as u8, + ) + }; + if r < 0 { + return -1; + } + } + // The client CAPABILITY command has no fixed server response to key on, so + // detect it on the request side, independent of port. Depth 17 allows up to + // a 6-char tag + space + "CAPABILITY", matching the historical detector. + // "USER CAPABILITY" may be FTP as well, so a probing parser disambiguates. + let capability = b" CAPABILITY\0"; + let r = unsafe { + SCAppLayerProtoDetectPMRegisterPatternCIwPP( + IPPROTO_TCP, + ALPROTO_IMAP, + capability.as_ptr() as *const c_char, + 17, + 0, + Direction::ToServer as u8, + Some(imap_capability_probing_parser), + 12, + 17, + ) + }; + if r < 0 { + return -1; + } + 0 +} + +#[no_mangle] +pub unsafe extern "C" fn SCRegisterImapParser() { + let default_port = CString::new("[143]").unwrap(); + let parser = RustParser { + name: PARSER_NAME.as_ptr() as *const c_char, + default_port: default_port.as_ptr(), + ipproto: IPPROTO_TCP, + probe_ts: Some(imap_probing_parser), + probe_tc: Some(imap_probing_parser), + min_depth: 0, + max_depth: 16, + state_new: imap_state_new, + state_free: imap_state_free, + tx_free: imap_state_tx_free, + parse_ts: imap_parse_request, + parse_tc: imap_parse_response, + get_tx_count: imap_state_get_tx_count, + get_tx: imap_state_get_tx, + tx_comp_st_ts: 1, + tx_comp_st_tc: 1, + tx_get_progress: imap_tx_get_alstate_progress, + get_eventinfo: Some(ImapEvent::get_event_info), + get_eventinfo_byid: Some(ImapEvent::get_event_info_by_id), + localstorage_new: None, + localstorage_free: None, + get_tx_files: None, + get_tx_iterator: Some(applayer::state_get_tx_iterator::), + get_tx_data: imap_get_tx_data, + get_state_data: imap_get_state_data, + apply_tx_config: None, + flags: APP_LAYER_PARSER_OPT_ACCEPT_GAPS, + get_frame_id_by_name: Some(ImapFrameType::ffi_id_from_name), + get_frame_name_by_id: Some(ImapFrameType::ffi_name_from_id), + get_state_id_by_name: Some(ImapStateProgress::ffi_id_from_name), + get_state_name_by_id: Some(ImapStateProgress::ffi_name_from_id), + }; + + let ip_proto_str = CString::new("tcp").unwrap(); + if SCAppLayerProtoDetectConfProtoDetectionEnabled(ip_proto_str.as_ptr(), parser.name) != 0 { + let alproto = applayer_register_protocol_detection(&parser, 1); + ALPROTO_IMAP = alproto; + if register_pattern_probe() < 0 { + return; + } + if SCAppLayerParserConfParserEnabled(ip_proto_str.as_ptr(), parser.name) != 0 { + let _ = AppLayerRegisterParser(&parser, alproto); + } + if let Some(val) = conf_get("app-layer.protocols.imap.max-tx") { + if let Ok(v) = val.parse::() { + if IMAP_MAX_TX == IMAP_MAX_TX_DEFAULT { + IMAP_MAX_TX = v; + } + } else { + SCLogError!("Invalid value for imap.max-tx"); + } + } + SCAppLayerParserRegisterLogger(IPPROTO_TCP, ALPROTO_IMAP); + } else { + SCLogDebug!("Protocol detection and parser disabled for IMAP/TCP."); + } +} diff --git a/src/app-layer-imap.h b/rust/src/imap/mod.rs similarity index 72% rename from src/app-layer-imap.h rename to rust/src/imap/mod.rs index 497992f71a..91f3c0d300 100644 --- a/src/app-layer-imap.h +++ b/rust/src/imap/mod.rs @@ -1,4 +1,4 @@ -/* Copyright (C) 2024 Open Information Security Foundation +/* Copyright (C) 2026 Open Information Security Foundation * * You can copy, redistribute or modify this Program under the terms of * the GNU General Public License version 2 as published by the Free @@ -15,14 +15,7 @@ * 02110-1301, USA. */ -/** - * \file - * - * \author Mahmoud Maatuq - * - */ +// Author: Giuseppe Longo -#ifndef SURICATA_APP_LAYER_IMAP_H -#define SURICATA_APP_LAYER_IMAP_H -void RegisterIMAPParsers(void); -#endif +pub mod imap; +pub mod parser; diff --git a/rust/src/imap/parser.rs b/rust/src/imap/parser.rs new file mode 100644 index 0000000000..6faec74d3a --- /dev/null +++ b/rust/src/imap/parser.rs @@ -0,0 +1,1471 @@ +/* Copyright (C) 2026 Open Information Security Foundation + * + * You can copy, redistribute or modify this Program under the terms of + * the GNU General Public License version 2 as published by the Free + * Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * version 2 along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +// Author: Giuseppe Longo + +use nom8::branch::alt; +use nom8::bytes::streaming::{tag, tag_no_case, take_till, take_while1}; +use nom8::character::complete::{char as complete_char, u32 as complete_u32}; +use nom8::character::streaming::{char, crlf, space0, space1, u64 as streaming_u64}; +use nom8::combinator::{all_consuming, complete, map, opt, value, verify}; +use nom8::error::{Error, ErrorKind}; +use nom8::sequence::preceded; +use nom8::{Err, IResult, Needed, Parser}; +use std::borrow::Cow; +use std::fmt; + +pub const IMAP_MAX_LINE_SIZE: usize = 8 * 1024; +const IMAP_MAX_ARGUMENTS: usize = IMAP_MAX_LINE_SIZE / 2; +const IMAP_MAX_LITERAL_SIZE: u64 = i64::MAX as u64; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImapCommand { + // Any state commands + Capability, + Noop, + Logout, + + // Not authenticated state + StartTls, + Authenticate, + Login, + + // Authenticated state + Select, + Examine, + Create, + Delete, + Rename, + Subscribe, + Unsubscribe, + List, + Lsub, + Status, + Append, + + // Selected state + Check, + Close, + Expunge, + Search, + Fetch, + Store, + Copy, + Uid, + + // Extensions + Idle, + Id, + + // Unknown command + Unknown(Vec), +} + +const COMMANDS: &[(&str, ImapCommand)] = &[ + ("CAPABILITY", ImapCommand::Capability), + ("NOOP", ImapCommand::Noop), + ("LOGOUT", ImapCommand::Logout), + ("STARTTLS", ImapCommand::StartTls), + ("AUTHENTICATE", ImapCommand::Authenticate), + ("LOGIN", ImapCommand::Login), + ("SELECT", ImapCommand::Select), + ("EXAMINE", ImapCommand::Examine), + ("CREATE", ImapCommand::Create), + ("DELETE", ImapCommand::Delete), + ("RENAME", ImapCommand::Rename), + ("SUBSCRIBE", ImapCommand::Subscribe), + ("UNSUBSCRIBE", ImapCommand::Unsubscribe), + ("LIST", ImapCommand::List), + ("LSUB", ImapCommand::Lsub), + ("STATUS", ImapCommand::Status), + ("APPEND", ImapCommand::Append), + ("CHECK", ImapCommand::Check), + ("CLOSE", ImapCommand::Close), + ("EXPUNGE", ImapCommand::Expunge), + ("SEARCH", ImapCommand::Search), + ("FETCH", ImapCommand::Fetch), + ("STORE", ImapCommand::Store), + ("COPY", ImapCommand::Copy), + ("UID", ImapCommand::Uid), + ("IDLE", ImapCommand::Idle), + ("ID", ImapCommand::Id), +]; + +impl fmt::Display for ImapCommand { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let ImapCommand::Unknown(bytes) = self { + return write!(f, "{}", String::from_utf8_lossy(bytes)); + } + let name = COMMANDS + .iter() + .find(|(_, command)| command == self) + .map_or("", |(name, _)| name); + f.write_str(name) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImapResponseStatus { + Ok, + No, + Bad, + PreAuth, + Bye, +} + +impl fmt::Display for ImapResponseStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ImapResponseStatus::Ok => write!(f, "OK"), + ImapResponseStatus::No => write!(f, "NO"), + ImapResponseStatus::Bad => write!(f, "BAD"), + ImapResponseStatus::PreAuth => write!(f, "PREAUTH"), + ImapResponseStatus::Bye => write!(f, "BYE"), + } + } +} + +#[derive(Debug, Clone)] +pub struct LiteralInfo { + pub size: u64, + pub is_literal_plus: bool, + pub bytes_consumed: u64, +} + +impl LiteralInfo { + pub fn new(size: u64, is_literal_plus: bool) -> Self { + Self { + size, + is_literal_plus, + bytes_consumed: 0, + } + } + + pub fn remaining(&self) -> u64 { + self.size.saturating_sub(self.bytes_consumed) + } + + pub fn consume_chunk(&mut self, chunk: &[u8]) -> usize { + let consumed = match usize::try_from(self.remaining()) { + Ok(remaining) => std::cmp::min(chunk.len(), remaining), + Err(_) => chunk.len(), + }; + self.bytes_consumed = self + .bytes_consumed + .saturating_add(u64::try_from(consumed).unwrap_or(u64::MAX)); + consumed + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CommandLiteral { + pub size: u64, + pub is_literal_plus: bool, + pub parenthesis_depth: usize, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ImapMessageType { + Command { + command: ImapCommand, + arguments: Vec>, + literal: Option, + }, + Response { + status: ImapResponseStatus, + }, + Untagged { + seq_number: Option, + keyword: Vec, + }, + Continuation, + ContinuationData, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ImapMessage { + pub tag: Option>, + pub message: ImapMessageType, + pub raw_line: Vec, + pub line_truncated: bool, +} + +impl ImapMessage { + pub fn tag_str(&self) -> Cow<'_, str> { + self.tag + .as_deref() + .map(String::from_utf8_lossy) + .unwrap_or_default() + } +} + +#[inline] +fn is_line_ending(b: u8) -> bool { + b == b'\r' || b == b'\n' +} + +#[inline] +fn is_tag_char(c: u8) -> bool { + c.is_ascii_graphic() && !b"(){}%*\"\\+ ".contains(&c) +} + +#[inline] +fn is_atom_char(c: u8) -> bool { + c.is_ascii_graphic() && !b"(){}%*\"\\ ".contains(&c) +} + +fn cap_raw_line(line: &[u8]) -> (Vec, bool) { + if line.len() > IMAP_MAX_LINE_SIZE { + (line[..IMAP_MAX_LINE_SIZE].to_vec(), true) + } else { + (line.to_vec(), false) + } +} + +fn parse_tag(i: &[u8]) -> IResult<&[u8], &[u8]> { + if i.iter() + .take(IMAP_MAX_LINE_SIZE + 1) + .take_while(|&&b| is_tag_char(b)) + .count() + > IMAP_MAX_LINE_SIZE + { + return Err(Err::Failure(Error::new(i, ErrorKind::TooLarge))); + } + take_while1(is_tag_char).parse(i) +} + +fn parse_command_keyword(i: &[u8]) -> IResult<&[u8], ImapCommand> { + let (i, cmd) = parse_atom(i)?; + let command = COMMANDS + .iter() + .find(|(name, _)| cmd.eq_ignore_ascii_case(name.as_bytes())) + .map_or_else( + || ImapCommand::Unknown(cmd[..cmd.len().min(IMAP_MAX_LINE_SIZE)].to_vec()), + |(_, command)| command.clone(), + ); + Ok((i, command)) +} + +pub fn probe_command_prefix(i: &[u8]) -> IResult<&[u8], bool> { + let (i, _tag) = parse_tag(i)?; + let (i, _) = space1.parse(i)?; + let (i, command) = parse_command_keyword(i)?; + Ok((i, !matches!(command, ImapCommand::Unknown(_)))) +} + +fn parse_status(i: &[u8]) -> IResult<&[u8], ImapResponseStatus> { + alt(( + value(ImapResponseStatus::Ok, tag_no_case("OK")), + value(ImapResponseStatus::No, tag_no_case("NO")), + value(ImapResponseStatus::Bad, tag_no_case("BAD")), + value(ImapResponseStatus::PreAuth, tag_no_case("PREAUTH")), + value(ImapResponseStatus::Bye, tag_no_case("BYE")), + )) + .parse(i) +} + +fn parse_quoted_string(i: &[u8], retain_limit: usize) -> IResult<&[u8], Vec> { + let (mut rem, _) = char('"').parse(i)?; + let mut value = Vec::new(); + + loop { + let Some((&byte, after_byte)) = rem.split_first() else { + return Err(Err::Incomplete(Needed::new(1))); + }; + + match byte { + b'"' => { + value.shrink_to_fit(); + return Ok((after_byte, value)); + } + b'\\' => { + let Some((&escaped, after_escape)) = after_byte.split_first() else { + return Err(Err::Incomplete(Needed::new(1))); + }; + if escaped != b'"' && escaped != b'\\' { + return Err(Err::Error(Error::new(rem, ErrorKind::Escaped))); + } + if value.len() < retain_limit { + value.push(escaped); + } + rem = after_escape; + } + b'\0' | b'\r' | b'\n' => { + return Err(Err::Error(Error::new(rem, ErrorKind::Char))); + } + _ => { + if value.len() < retain_limit { + value.push(byte); + } + rem = after_byte; + } + } + } +} + +fn parse_atom(i: &[u8]) -> IResult<&[u8], &[u8]> { + take_while1(is_atom_char).parse(i) +} + +#[derive(Debug, Default)] +struct ParenthesisScanner { + depth: usize, + in_quoted: bool, + escaped: bool, + saw_open: bool, +} + +impl ParenthesisScanner { + fn scan(&mut self, i: &[u8]) -> Result, ErrorKind> { + for (pos, &b) in i.iter().enumerate() { + if self.in_quoted { + if self.escaped { + if b != b'"' && b != b'\\' { + return Err(ErrorKind::Escaped); + } + self.escaped = false; + continue; + } + + match b { + b'\\' => self.escaped = true, + b'"' => self.in_quoted = false, + b'\0' | b'\r' | b'\n' => return Err(ErrorKind::Char), + _ => {} + } + continue; + } + + match b { + b'"' => self.in_quoted = true, + b'(' => { + self.depth = self.depth.checked_add(1).ok_or(ErrorKind::TooLarge)?; + self.saw_open = true; + } + b')' => { + if self.depth == 0 { + return Err(ErrorKind::Char); + } + self.depth -= 1; + if self.depth == 0 { + return Ok(Some(pos)); + } + } + b'\r' | b'\n' => return Err(ErrorKind::Char), + _ => {} + } + } + Ok(None) + } + + fn quote_is_closed(&self) -> bool { + !self.in_quoted && !self.escaped + } +} + +fn parse_list( + i: &[u8], depth: usize, retain_limit: usize, +) -> IResult<&[u8], (Vec, Option)> { + if depth == 0 { + let _ = char('(').parse(i)?; + } else if let Some(&first) = i.first() { + if !matches!(first, b' ' | b'\t' | b')') { + return Err(Err::Error(Error::new(i, ErrorKind::Char))); + } + } + let mut scanner = ParenthesisScanner { + depth, + ..Default::default() + }; + let line_end = i.iter().position(|&b| is_line_ending(b)).unwrap_or(i.len()); + if let Some(pos) = scanner + .scan(&i[..line_end]) + .map_err(|kind| Err::Error(Error::new(i, kind)))? + { + return Ok(( + &i[pos + 1..], + (i[..(pos + 1).min(retain_limit)].to_vec(), None), + )); + } + if line_end == i.len() { + return Err(Err::Incomplete(Needed::new(1))); + } + let line = &i[..line_end]; + if scanner.quote_is_closed() { + if let Some((prefix, (size, is_literal_plus))) = detect_trailing_literal(line) { + if matches!(prefix.last(), Some(b' ' | b'\t' | b'(')) { + let literal = CommandLiteral { + size, + is_literal_plus, + parenthesis_depth: scanner.depth, + }; + return Ok(( + &i[line.len()..], + (line[..line.len().min(retain_limit)].to_vec(), Some(literal)), + )); + } + } + } + Err(Err::Error(Error::new(i, ErrorKind::Char))) +} + +fn parse_literal_as_argument( + i: &[u8], retain_limit: usize, +) -> IResult<&[u8], (Vec, Option)> { + let start = i; + let (i, (size, is_literal_plus)) = parse_literal_specifier(i)?; + let len = start.len() - i.len(); + let literal = CommandLiteral { + size, + is_literal_plus, + parenthesis_depth: 0, + }; + Ok((i, (start[..len.min(retain_limit)].to_vec(), Some(literal)))) +} + +#[inline] +fn is_list_char(c: u8) -> bool { + is_atom_char(c) || c == b'%' || c == b'*' || c == b'\\' +} + +fn parse_sequence_value(i: &[u8]) -> Option> { + all_consuming(alt(( + value(None, complete_char::<_, Error<_>>('*')), + map(verify(complete_u32::<_, Error<_>>, |num| *num != 0), Some), + ))) + .parse(i) + .ok() + .map(|(_, val)| val) +} + +pub fn sequence_set_contains(set: &[u8], sequence_number: u32) -> Option { + let mut has_unknown_item = false; + + for item in set.split(|b| *b == b',') { + let mut vals = item.split(|b| *b == b':'); + let first_val = parse_sequence_value(vals.next()?)?; + let second_val = match vals.next() { + Some(val) => Some(parse_sequence_value(val)?), + None => None, + }; + if vals.next().is_some() { + return None; + } + + let item_contains = match (first_val, second_val) { + (Some(num), None) => Some(sequence_number == num), + (None, None) => None, + (Some(first_val), Some(Some(second_val))) => { + let lower = first_val.min(second_val); + let upper = first_val.max(second_val); + Some((lower..=upper).contains(&sequence_number)) + } + (Some(val), Some(None)) | (None, Some(Some(val))) => { + if sequence_number >= val { + Some(true) + } else { + None + } + } + (None, Some(None)) => None, + }; + + match item_contains { + Some(true) => return Some(true), + Some(false) => {} + None => has_unknown_item = true, + } + } + + if has_unknown_item { + None + } else { + Some(false) + } +} + +fn parse_body_section_argument( + i: &[u8], retain_limit: usize, +) -> IResult<&[u8], (Vec, Option)> { + let (_, line) = take_till(is_line_ending).parse(i)?; + let (rem, _) = complete(parse_body_section).parse(line)?; + if rem.first().is_some_and(|b| !matches!(b, b' ' | b'\t')) { + return Err(Err::Error(Error::new(i, ErrorKind::Verify))); + } + let consumed = line.len() - rem.len(); + Ok(( + &i[consumed..], + (i[..consumed.min(retain_limit)].to_vec(), None), + )) +} + +fn parse_argument( + i: &[u8], retain_limit: usize, +) -> IResult<&[u8], (Vec, Option)> { + alt(( + map(|i| parse_quoted_string(i, retain_limit), |arg| (arg, None)), + |i| parse_list(i, 0, retain_limit), + |i| parse_literal_as_argument(i, retain_limit), + map(take_while1(is_list_char), |s: &[u8]| { + (s[..s.len().min(retain_limit)].to_vec(), None) + }), + )) + .parse(i) +} + +type CommandArguments = (Vec>, Option); + +fn parse_arguments<'a>( + mut i: &'a [u8], depth: usize, command: Option<&ImapCommand>, +) -> IResult<&'a [u8], CommandArguments> { + let mut arguments = Vec::new(); + let mut retain_left = if command.is_some() { + IMAP_MAX_LINE_SIZE + } else { + 0 + }; + let mut position = 0; + let mut literal = None; + if depth != 0 { + let (rem, (argument, pending)) = parse_list(i, depth, retain_left)?; + if retain_left != 0 { + retain_left -= argument.len(); + arguments.push(argument); + } + position += 1; + literal = pending; + i = rem; + } + while literal.is_none() { + let fetch_attribute = match command { + Some(ImapCommand::Fetch) => position == 1, + Some(ImapCommand::Uid) => { + position == 2 + && arguments + .first() + .is_some_and(|arg| arg.eq_ignore_ascii_case(b"FETCH")) + } + _ => false, + }; + let (rem, argument) = opt(preceded(space1, |input| { + if fetch_attribute { + alt(( + |i| parse_body_section_argument(i, retain_left), + |i| parse_argument(i, retain_left), + )) + .parse(input) + } else { + parse_argument(input, retain_left) + } + })) + .parse(i)?; + let Some((argument, pending)) = argument else { + break; + }; + if retain_left != 0 { + retain_left -= argument.len(); + arguments.push(argument); + if arguments.len() == IMAP_MAX_ARGUMENTS { + retain_left = 0; + } + } + position += 1; + literal = pending; + i = rem; + } + arguments.shrink_to_fit(); + Ok((i, (arguments, literal))) +} + +pub fn parse_command_continuation( + i: &[u8], depth: usize, +) -> IResult<&[u8], (ImapMessage, Option)> { + let start = i; + let (i, (_, literal)) = parse_arguments(i, depth, None)?; + let (i, _) = crlf.parse(i)?; + + let raw_len = start.len() - i.len() - 2; + let suffix = &start[..raw_len]; + let offset = suffix + .iter() + .position(|&b| b != b' ') + .unwrap_or(suffix.len()); + let (raw_line, line_truncated) = cap_raw_line(&suffix[offset..]); + Ok(( + i, + ( + ImapMessage { + tag: None, + message: ImapMessageType::ContinuationData, + raw_line, + line_truncated, + }, + literal, + ), + )) +} + +pub fn parse_response_continuation(i: &[u8]) -> IResult<&[u8], (&[u8], Option)> { + let (i, rest) = take_till(is_line_ending).parse(i)?; + let (i, _) = crlf.parse(i)?; + Ok(( + i, + ( + rest, + detect_trailing_literal(rest).map(|(_, (size, _))| size), + ), + )) +} + +fn detect_trailing_literal(line: &[u8]) -> Option<(&[u8], (u64, bool))> { + let brace_pos = line.iter().rposition(|&c| c == b'{')?; + match parse_literal_specifier(&line[brace_pos..]) { + Ok(([], literal)) => Some((&line[..brace_pos], literal)), + _ => None, + } +} + +pub fn untagged_trailing_literal(keyword: &[u8], line: &[u8]) -> Option { + if all_consuming(parse_status).parse(keyword).is_ok() { + return None; + } + detect_trailing_literal(line).map(|(_, (size, _))| size) +} + +fn parse_partial_offset(i: &[u8]) -> IResult<&[u8], u64> { + let (i, _) = complete_char('<').parse(i)?; + let (i, origin) = complete_u32::<_, Error<_>>(i)?; + let (i, _) = opt(preceded(complete_char('.'), complete_u32::<_, Error<_>>)).parse(i)?; + let (i, _) = complete_char('>').parse(i)?; + Ok((i, u64::from(origin))) +} + +fn parse_body_section(i: &[u8]) -> IResult<&[u8], ()> { + let (i, _) = alt((tag_no_case("BODY.PEEK"), tag_no_case("BODY"))).parse(i)?; + let (i, _) = char('[').parse(i)?; + let (i, _) = take_till(|c| c == b']').parse(i)?; + let (i, _) = char(']').parse(i)?; + let (i, _) = opt(parse_partial_offset).parse(i)?; + Ok((i, ())) +} + +pub fn parse_command(i: &[u8]) -> IResult<&[u8], ImapMessage> { + let start = i; + let (i, tag_bytes) = parse_tag(i)?; + let (i, _) = space1.parse(i)?; + let (i, command) = parse_command_keyword(i)?; + let (i, (arguments, literal)) = parse_arguments(i, 0, Some(&command))?; + let (i, _) = crlf.parse(i)?; + + let raw_len = start.len() - i.len() - 2; + let (raw_line, line_truncated) = cap_raw_line(&start[..raw_len]); + + Ok(( + i, + ImapMessage { + tag: Some(tag_bytes.to_vec()), + message: ImapMessageType::Command { + command, + arguments, + literal, + }, + raw_line, + line_truncated, + }, + )) +} + +fn parse_tagged_response(i: &[u8]) -> IResult<&[u8], ImapMessage> { + let start = i; + let (i, tag_bytes) = parse_tag(i)?; + let (i, _) = space1.parse(i)?; + let (i, status) = parse_status(i)?; + let (i, _) = opt(preceded(space1, take_till(is_line_ending))).parse(i)?; + let (i, _) = crlf.parse(i)?; + + let raw_len = start.len() - i.len() - 2; + let (raw_line, line_truncated) = cap_raw_line(&start[..raw_len]); + + Ok(( + i, + ImapMessage { + tag: Some(tag_bytes.to_vec()), + message: ImapMessageType::Response { status }, + raw_line, + line_truncated, + }, + )) +} + +fn parse_untagged_prefix(i: &[u8]) -> IResult<&[u8], (Option, &[u8])> { + let (i, _) = tag("* ").parse(i)?; + let (i, first_token) = take_while1(|c: u8| c.is_ascii_alphanumeric()).parse(i)?; + + if first_token.iter().all(|c| c.is_ascii_digit()) { + let (i, _) = space1.parse(i)?; + let (i, keyword) = take_while1(|c: u8| c.is_ascii_alphanumeric()).parse(i)?; + let seq: u32 = std::str::from_utf8(first_token) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + Ok((i, (Some(seq), keyword))) + } else { + Ok((i, (None, first_token))) + } +} + +pub fn peek_untagged(i: &[u8]) -> Option<(Option, &[u8])> { + parse_untagged_prefix(i).ok().map(|(_, prefix)| prefix) +} + +fn parse_untagged_response(i: &[u8]) -> IResult<&[u8], ImapMessage> { + let start = i; + let (rem, (seq_number, keyword)) = parse_untagged_prefix(i)?; + + let (i, _) = space0.parse(rem)?; + let (i, _) = take_till(is_line_ending).parse(i)?; + let (i, _) = crlf.parse(i)?; + let raw_len = start.len() - i.len() - 2; + let (raw_line, line_truncated) = cap_raw_line(&start[..raw_len]); + + Ok(( + i, + ImapMessage { + tag: None, + message: ImapMessageType::Untagged { + seq_number, + keyword: keyword.to_vec(), + }, + raw_line, + line_truncated, + }, + )) +} + +fn parse_continuation(i: &[u8]) -> IResult<&[u8], ImapMessage> { + let start = i; + let (i, _) = tag("+").parse(i)?; + let (i, _) = space0.parse(i)?; + let (i, _) = take_till(is_line_ending).parse(i)?; + let (i, _) = crlf.parse(i)?; + + let raw_len = start.len() - i.len() - 2; + let (raw_line, line_truncated) = cap_raw_line(&start[..raw_len]); + + Ok(( + i, + ImapMessage { + tag: None, + message: ImapMessageType::Continuation, + raw_line, + line_truncated, + }, + )) +} + +pub fn parse_continuation_data(i: &[u8]) -> IResult<&[u8], ImapMessage> { + let (i, data) = take_till(is_line_ending).parse(i)?; + let (i, _) = crlf.parse(i)?; + + let (raw_line, line_truncated) = cap_raw_line(data); + + Ok(( + i, + ImapMessage { + tag: None, + message: ImapMessageType::ContinuationData, + raw_line, + line_truncated, + }, + )) +} + +pub fn parse_response(i: &[u8]) -> IResult<&[u8], ImapMessage> { + alt(( + parse_untagged_response, + parse_continuation, + parse_tagged_response, + )) + .parse(i) +} + +fn parse_number64(i: &[u8]) -> IResult<&[u8], u64> { + verify(streaming_u64, |value| *value <= IMAP_MAX_LITERAL_SIZE).parse(i) +} + +pub fn parse_literal_specifier(i: &[u8]) -> IResult<&[u8], (u64, bool)> { + let (i, _) = char('{').parse(i)?; + let (i, size) = parse_number64(i)?; + let (i, is_plus) = opt(char('+')).parse(i)?; + let (i, _) = char('}').parse(i)?; + + Ok((i, (size, is_plus.is_some()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_incomplete_at_each_boundary( + input: &[u8], parser: for<'a> fn(&'a [u8]) -> IResult<&'a [u8], O>, + ) { + for split in 1..input.len() { + assert!( + matches!(parser(&input[..split]), Err(Err::Incomplete(_))), + "expected incomplete at byte boundary {split}" + ); + } + + let (rem, _) = parser(input).expect("complete input should parse"); + assert!(rem.is_empty()); + } + + #[test] + fn test_command_incomplete_at_each_boundary() { + let input = b"A001 UID FETCH 1:* (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO)])\r\n"; + assert_incomplete_at_each_boundary(input, parse_command); + } + + #[test] + fn test_responses_incomplete_at_each_boundary() { + for input in [ + b"A001 OK FETCH completed\r\n".as_slice(), + b"* 23 EXISTS\r\n".as_slice(), + ] { + assert_incomplete_at_each_boundary(input, parse_response); + } + } + + #[test] + fn test_unterminated_list_at_line_end_is_invalid() { + assert!(matches!( + parse_command(b"A001 FETCH 1 (FLAGS\r\n"), + Err(Err::Error(_)) + )); + } + + #[test] + fn test_parse_command_metadata_limits() { + let large = "x".repeat(1_000_000); + for command in [ + format!("A1 CREATE \"{large}\""), + format!("A1 CREATE {large}"), + format!("A1 SEARCH ({large})"), + format!("A1 FETCH 1 BODY[HEADER.FIELDS ({large})]"), + format!("A1 FETCH {large} BODY[TEXT]"), + format!("A1 {large}"), + format!("A1 SEARCH{}", " \"\"".repeat(100_000)), + ] { + let input = format!("{command}\r\nA2 NOOP\r\n"); + let (rem, msg) = parse_command(input.as_bytes()).unwrap(); + assert_eq!(rem, b"A2 NOOP\r\n"); + assert!(msg.line_truncated); + assert!(msg.raw_line.capacity() <= IMAP_MAX_LINE_SIZE); + let ImapMessageType::Command { + command, arguments, .. + } = msg.message + else { + panic!("Expected Command"); + }; + if let ImapCommand::Unknown(name) = command { + assert_eq!(name, vec![b'x'; IMAP_MAX_LINE_SIZE]); + assert_eq!(name.capacity(), IMAP_MAX_LINE_SIZE); + } else { + assert!(!arguments.is_empty()); + } + assert!(arguments.capacity() <= IMAP_MAX_ARGUMENTS); + assert!(arguments.iter().map(Vec::capacity).sum::() <= IMAP_MAX_LINE_SIZE); + assert!(parse_command(rem).unwrap().0.is_empty()); + } + } + + #[test] + fn test_command_limits_preserve_syntax_and_literals() { + for arguments in [ + format!(" {}", "x".repeat(IMAP_MAX_LINE_SIZE)), + " \"\"".repeat(IMAP_MAX_ARGUMENTS + 1), + ] { + let input = format!("A1 SEARCH{arguments} {{3+}}\r\nfoo SUBJECT secret\r\nA2 NOOP\r\n"); + let (rem, msg) = parse_command(input.as_bytes()).unwrap(); + let ImapMessageType::Command { + literal: Some(literal), + .. + } = msg.message + else { + panic!("Lost literal after retention limit"); + }; + assert_eq!(literal.size, 3); + assert!(literal.is_literal_plus); + let (rem, (suffix, next_literal)) = parse_command_continuation(&rem[3..], 0).unwrap(); + assert_eq!(suffix.raw_line, b"SUBJECT secret"); + assert!(next_literal.is_none()); + assert_eq!(rem, b"A2 NOOP\r\n"); + let invalid = format!("A1 SEARCH{arguments} \"bad\\escape\"\r\n"); + assert!(matches!( + parse_command(invalid.as_bytes()), + Err(Err::Error(_)) + )); + let incomplete = format!("A1 SEARCH{arguments} \"unfinished"); + assert!(matches!( + parse_command(incomplete.as_bytes()), + Err(Err::Incomplete(_)) + )); + let suffix = format!("{arguments} {{3+}}\r\n"); + let (_, (retained, literal)) = parse_arguments(suffix.as_bytes(), 0, None).unwrap(); + assert_eq!(retained.capacity(), 0); + assert!(literal.is_some()); + } + } + + #[test] + fn test_tag_size_limit() { + for len in [ + IMAP_MAX_LINE_SIZE - 1, + IMAP_MAX_LINE_SIZE, + IMAP_MAX_LINE_SIZE + 1, + 1_000_000, + ] { + let tag = vec![b'A'; len]; + for suffix in [b" NOOP\r\n".as_slice(), b" OK done\r\n"] { + let mut input = tag.clone(); + input.extend_from_slice(suffix); + let result = if suffix == b" NOOP\r\n" { + parse_command(&input) + } else { + parse_response(&input) + }; + if len <= IMAP_MAX_LINE_SIZE { + let (rem, msg) = result.unwrap(); + assert!(rem.is_empty()); + assert_eq!(msg.tag.as_deref(), Some(tag.as_slice())); + } else { + assert!(matches!( + result, + Err(Err::Failure(Error { + code: ErrorKind::TooLarge, + .. + })) + )); + } + } + let result = parse_tag(&tag); + if len <= IMAP_MAX_LINE_SIZE { + assert!(matches!(result, Err(Err::Incomplete(_)))); + } else { + assert!(matches!( + result, + Err(Err::Failure(Error { + code: ErrorKind::TooLarge, + .. + })) + )); + } + } + } + + #[test] + fn test_parse_fetch_arguments() { + for (input, expected) in [ + ( + b"A1 FETCH 1 BODY[HEADER.FIELDS (SUBJECT FROM)]\r\n".as_slice(), + b"BODY[HEADER.FIELDS (SUBJECT FROM)]".as_slice(), + ), + ( + b"A1 FETCH 1 BODY.PEEK[HEADER.FIELDS (TO)]\r\n", + b"BODY.PEEK[HEADER.FIELDS (TO)]", + ), + ( + b"A1 FETCH 1 BODY[HEADER.FIELDS (SUBJECT)]\r\n", + b"BODY[HEADER.FIELDS (SUBJECT)]", + ), + (b"A1 FETCH 1 BODY[TEXT]\r\n", b"BODY[TEXT]"), + (b"A1 FETCH 1 BODY[HEADER]\r\n", b"BODY[HEADER]"), + (b"A1 FETCH 1 BODYSTRUCTURE\r\n", b"BODYSTRUCTURE"), + ] { + let (rem, msg) = parse_command(input).unwrap(); + assert!(rem.is_empty(), "unconsumed input for {input:?}"); + match msg.message { + ImapMessageType::Command { arguments, .. } => { + assert_eq!(arguments, [b"1".to_vec(), expected.to_vec()]); + } + _ => panic!("Expected Command"), + } + } + } + + #[test] + fn test_parse_fetch_section_argument_streaming() { + for input in [ + b"A1 FETCH 1 BODY[]<0.128>\r\n".as_slice(), + b"A1 FETCH 1 BODY[HEADER.FIELDS (SUBJECT FROM)]<0.128>\r\n", + b"A1 UID FETCH 1 body.peek[header.fields.not (subject)]<123.456>\r\n", + b"A1 FETCH 1 BODY[TEXT]\r\n", + b"A1 FETCH 1 BODYSTRUCTURE\r\n", + ] { + assert_incomplete_at_each_boundary(input, parse_command); + let mut commands = input.to_vec(); + commands.extend_from_slice(b"A2 NOOP\r\n"); + let (rem, msg) = parse_command(&commands).unwrap(); + assert_eq!(msg.raw_line, &input[..input.len() - 2]); + assert_eq!(rem, b"A2 NOOP\r\n"); + } + } + + #[test] + fn test_parse_body_prefix_as_ordinary_argument() { + for (input, expected) in [ + ( + b"A1 SELECT BODY[Archive]suffix\r\n".as_slice(), + b"BODY[Archive]suffix".as_slice(), + ), + (b"A1 SELECT BODY[Archive\r\n", b"BODY[Archive"), + ( + b"A1 CREATE body.peek[TEXT]suffix\r\n", + b"body.peek[TEXT]suffix", + ), + (b"A1 CREATE BODY[]<0.\r\n", b"BODY[]<0."), + (b"A1 LOGIN user BODY[password\r\n", b"BODY[password"), + (b"A1 UID COPY 1 BODY[Archive\r\n", b"BODY[Archive"), + ( + b"A1 SEARCH BODY BODY[Archive]suffix\r\n", + b"BODY[Archive]suffix", + ), + ] { + assert_incomplete_at_each_boundary(input, parse_command); + let mut commands = input.to_vec(); + commands.extend_from_slice(b"A2 FETCH 1 BODY[TEXT]\r\n"); + let (rem, msg) = parse_command(&commands).unwrap(); + assert_eq!(rem, b"A2 FETCH 1 BODY[TEXT]\r\n"); + let ImapMessageType::Command { + arguments, literal, .. + } = msg.message + else { + panic!("Expected Command"); + }; + assert_eq!(arguments.last().unwrap(), expected); + assert_eq!(literal, None); + } + for input in [b" BODY[Archive\r\n".as_slice(), b" BODY[Archive]suffix\r\n"] { + assert_incomplete_at_each_boundary(input, |i| parse_command_continuation(i, 0)); + let (rem, (msg, literal)) = parse_command_continuation(input, 0).unwrap(); + assert!(rem.is_empty()); + assert_eq!(msg.raw_line, &input[1..input.len() - 2]); + assert_eq!(literal, None); + } + } + + #[test] + fn test_parse_fetch_section_argument_malformed() { + for input in [ + b"A1 FETCH 1 BODY[HEADER.FIELDS (SUBJECT\r\nA2 NOOP\r\n".as_slice(), + b"A1 FETCH 1 BODY[HEADER.FIELDS (SUBJECT)]\nA2 NOOP\r\n", + ] { + assert!( + matches!(parse_command(input), Err(Err::Error(_))), + "{input:?}" + ); + } + } + + #[test] + fn test_parse_login_quoted_args() { + let i = b"A001 LOGIN \"user name\" \"pass word\"\r\n"; + let (rem, msg) = parse_command(i).unwrap(); + assert!(rem.is_empty()); + match msg.message { + ImapMessageType::Command { + command, arguments, .. + } => { + assert_eq!(command, ImapCommand::Login); + assert_eq!(arguments.len(), 2); + assert_eq!(arguments[0], b"user name".to_vec()); + assert_eq!(arguments[1], b"pass word".to_vec()); + } + _ => panic!("Expected Command"), + } + } + + #[test] + fn test_parse_quoted_args_with_escapes() { + let i = b"A001 LOGIN \"user\\\"name\" \"pa\\\\ss\"\r\n"; + let (rem, msg) = parse_command(i).unwrap(); + assert!(rem.is_empty()); + assert_eq!(msg.raw_line, &i[..i.len() - 2]); + match msg.message { + ImapMessageType::Command { + command, arguments, .. + } => { + assert_eq!(command, ImapCommand::Login); + assert_eq!(arguments, [b"user\"name".to_vec(), b"pa\\ss".to_vec()]); + } + _ => panic!("Expected Command"), + } + } + + #[test] + fn test_parse_quoted_string_validation() { + assert_eq!( + parse_quoted_string(b"\"\"", IMAP_MAX_LINE_SIZE), + Ok((b"".as_slice(), Vec::new())) + ); + assert!(matches!( + parse_quoted_string(b"\"bad\\escape\"", IMAP_MAX_LINE_SIZE), + Err(Err::Error(_)) + )); + assert!(matches!( + parse_quoted_string(b"\"bad\0value\"", IMAP_MAX_LINE_SIZE), + Err(Err::Error(_)) + )); + assert!(matches!( + parse_quoted_string(b"\"bad\r\n", IMAP_MAX_LINE_SIZE), + Err(Err::Error(_)) + )); + assert!(matches!( + parse_quoted_string(b"\"unfinished\\", IMAP_MAX_LINE_SIZE), + Err(Err::Incomplete(_)) + )); + } + + #[test] + fn test_parse_list_ignores_quoted_parentheses() { + let i = b"A001 ID (\"name\" \"value ) ( \\\"quoted\\\" \\\\folder\")\r\n"; + assert_incomplete_at_each_boundary(i, parse_command); + let (rem, msg) = parse_command(i).unwrap(); + assert!(rem.is_empty()); + match msg.message { + ImapMessageType::Command { + command, arguments, .. + } => { + assert_eq!(command, ImapCommand::Id); + assert_eq!( + arguments, + [b"(\"name\" \"value ) ( \\\"quoted\\\" \\\\folder\")".to_vec()] + ); + } + _ => panic!("Expected Command"), + } + } + + #[test] + fn test_literal_info_consume_chunk_boundaries() { + let cases: &[(u64, &[u8], usize)] = &[ + (0, b"", 0), + (0, b"next", 0), + (4, b"", 0), + (4, b"ab", 2), + (4, b"abcd", 4), + (4, b"abcdnext", 4), + ]; + for &(size, input, expected) in cases { + for split in 0..=input.len() { + let mut literal = LiteralInfo::new(size, false); + let mut total = 0; + for (chunk, consumed) in [ + (&input[..split], split.min(expected)), + (&input[split..], expected.saturating_sub(split)), + ] { + assert_eq!(literal.consume_chunk(chunk), consumed); + total += consumed; + assert_eq!(literal.bytes_consumed, total as u64); + assert_eq!(literal.remaining(), size - total as u64); + } + } + } + } + + #[test] + fn test_command_quoted_literal_lookalikes() { + for input in [ + b"A1 CREATE INBOX\r\n".as_slice(), + b"A1 CREATE \"{3}\"\r\n", + b"A1 CREATE \"{3+}\"\r\n", + b"A1 SEARCH (SUBJECT \"{3}\")\r\n", + b"A1 SEARCH (SUBJECT \"{3+}\")\r\n", + ] { + assert_incomplete_at_each_boundary(input, parse_command); + let (rem, msg) = parse_command(input).unwrap(); + assert!(rem.is_empty()); + assert!(matches!( + msg.message, + ImapMessageType::Command { literal: None, .. } + )); + } + } + + #[test] + fn test_command_parenthesized_literals() { + for (input, size, is_literal_plus, depth) in [ + ( + b"A1 SEARCH (SUBJECT {3}\r\nfoo)\r\nA2 NOOP\r\n".as_slice(), + 3, + false, + 1, + ), + ( + b"A1 SEARCH ((SUBJECT {4+}\r\n)(\r\n)) UNSEEN\r\nA2 NOOP\r\n", + 4, + true, + 2, + ), + (b"A1 SEARCH (SUBJECT {0+}\r\n)\r\nA2 NOOP\r\n", 0, true, 1), + ] { + let (rem, msg) = parse_command(input).unwrap(); + let syntax_len = input.len() - rem.len(); + assert_incomplete_at_each_boundary(&input[..syntax_len], parse_command); + let ImapMessageType::Command { + command, literal, .. + } = msg.message + else { + panic!("Expected Command"); + }; + assert_eq!(command, ImapCommand::Search); + assert_eq!( + literal, + Some(CommandLiteral { + size, + is_literal_plus, + parenthesis_depth: depth, + }) + ); + + for split in 0..=size as usize { + let mut data = LiteralInfo::new(size, is_literal_plus); + assert_eq!(data.consume_chunk(&rem[..split]), split); + assert_eq!(data.consume_chunk(&rem[split..]), size as usize - split); + assert_eq!(data.remaining(), 0); + let (next, (_, literal)) = + parse_command_continuation(&rem[size as usize..], depth).unwrap(); + assert_eq!(literal, None); + assert_eq!(next, b"A2 NOOP\r\n"); + assert!(parse_command(next).unwrap().0.is_empty()); + } + } + } + + #[test] + fn test_command_nested_literal_continuations() { + for input in [b" BODY {0}\r\n".as_slice(), b") (TEXT {2+}\r\n"] { + assert_incomplete_at_each_boundary(input, |i| parse_command_continuation(i, 2)); + let (rem, (_, literal)) = parse_command_continuation(input, 2).unwrap(); + assert!(rem.is_empty()); + assert_eq!(literal.unwrap().parenthesis_depth, 2); + } + let input = b")) UNSEEN\r\n"; + assert_incomplete_at_each_boundary(input, |i| parse_command_continuation(i, 2)); + let (rem, (msg, literal)) = parse_command_continuation(input, 2).unwrap(); + assert!(rem.is_empty()); + assert_eq!(msg.raw_line, b")) UNSEEN"); + assert_eq!(literal, None); + } + + #[test] + fn test_command_continuation_quoted_literal_lookalikes() { + for (input, depth) in [ + (b" \"{5+}\"\r\n".as_slice(), 0), + (b" \"{5}\"\r\n", 0), + (b" SUBJECT \"{5+}\")\r\n", 1), + (b") (SUBJECT \"{5}\")\r\n", 1), + ] { + for end in 0..input.len() { + assert!(matches!( + parse_command_continuation(&input[..end], depth), + Err(Err::Incomplete(_)) + )); + } + let (rem, (_, literal)) = parse_command_continuation(input, depth).unwrap(); + assert!(rem.is_empty()); + assert_eq!(literal, None); + } + } + + #[test] + fn test_parse_command_continuation_caps_raw_line() { + for size in [ + IMAP_MAX_LINE_SIZE - 1, + IMAP_MAX_LINE_SIZE, + IMAP_MAX_LINE_SIZE + 1, + ] { + let mut input = b" ".to_vec(); + input.resize(input.len() + size, b'x'); + input.extend_from_slice(b"\r\nA2 NOOP\r\n"); + let (rem, (msg, literal)) = parse_command_continuation(&input, 0).unwrap(); + assert_eq!(rem, b"A2 NOOP\r\n"); + assert_eq!(msg.raw_line, vec![b'x'; size.min(IMAP_MAX_LINE_SIZE)]); + assert_eq!(msg.line_truncated, size > IMAP_MAX_LINE_SIZE); + assert_eq!(literal, None); + } + } + + #[test] + fn test_parse_command_continuation_literal_beyond_raw_line_limit() { + let mut input = b" ".to_vec(); + input.resize(input.len() + IMAP_MAX_LINE_SIZE + 1, b'x'); + input.extend_from_slice(b" {4+}\r\ndata\r\nA2 NOOP\r\n"); + let (rem, (msg, literal)) = parse_command_continuation(&input, 0).unwrap(); + assert_eq!(rem, b"data\r\nA2 NOOP\r\n"); + assert_eq!(msg.raw_line, vec![b'x'; IMAP_MAX_LINE_SIZE]); + assert!(msg.line_truncated); + assert_eq!( + literal, + Some(CommandLiteral { + size: 4, + is_literal_plus: true, + parenthesis_depth: 0, + }) + ); + } + + #[test] + fn test_parse_response_continuation_incomplete_at_each_boundary() { + for input in [ + b"\r\n".as_slice(), + b" (MESSAGES 1)\r\n".as_slice(), + b" {6}\r\n".as_slice(), + ] { + assert_incomplete_at_each_boundary(input, |i| { + parse_response_continuation(i).map(|(rem, (_, literal))| (rem, literal)) + }); + } + } + + #[test] + fn test_parse_response_continuation_malformed() { + for input in [b"\n".as_slice(), b" a\rb\r\n".as_slice()] { + assert!( + matches!(parse_response_continuation(input), Err(Err::Error(_))), + "expected error for {input:?}" + ); + } + } + + #[test] + fn test_parse_fetch_single_star() { + let i = b"A005 FETCH * FLAGS\r\n"; + let (rem, msg) = parse_command(i).unwrap(); + assert!(rem.is_empty()); + match msg.message { + ImapMessageType::Command { + command, arguments, .. + } => { + assert_eq!(command, ImapCommand::Fetch); + assert_eq!(arguments[0], b"*".to_vec()); + assert_eq!(arguments[1], b"FLAGS".to_vec()); + } + _ => panic!("Expected Command"), + } + } + + #[test] + fn test_parse_fetch_no_literal_still_works() { + let i = b"* 1 FETCH (UID 1 FLAGS (\\Seen))\r\n"; + let (rem, msg) = parse_response(i).unwrap(); + assert!(rem.is_empty()); + assert_eq!(msg.raw_line, b"* 1 FETCH (UID 1 FLAGS (\\Seen))".to_vec()); + match msg.message { + ImapMessageType::Untagged { + seq_number, + keyword, + } => { + assert_eq!(seq_number, Some(1)); + assert_eq!(keyword, b"FETCH".to_vec()); + } + _ => panic!("Expected Untagged"), + } + } + + #[test] + fn test_detect_trailing_literal() { + assert_eq!( + detect_trailing_literal(b"BODY[] {100}"), + Some((b"BODY[] ".as_slice(), (100, false))) + ); + assert_eq!( + detect_trailing_literal(b"{50+}"), + Some((b"".as_slice(), (50, true))) + ); + assert_eq!(detect_trailing_literal(b"no literal here"), None); + assert_eq!(detect_trailing_literal(b"middle {10} stuff"), None); + } + + #[test] + fn test_untagged_trailing_literal() { + assert_eq!( + untagged_trailing_literal(b"LIST", b"* LIST () \"/\" {5}"), + Some(5) + ); + assert_eq!( + untagged_trailing_literal(b"list", b"* list () \"/\" {5}"), + Some(5) + ); + assert_eq!(untagged_trailing_literal(b"ID", b"* ID ({4}"), Some(4)); + assert_eq!( + untagged_trailing_literal(b"STATUS", b"* STATUS INBOX (MESSAGES 1)"), + None + ); + assert_eq!( + untagged_trailing_literal(b"LIST", b"* LIST () \"/\" \"{5}\""), + None + ); + assert_eq!(untagged_trailing_literal(b"EXISTS", b"* 12 EXISTS"), None); + for status in [b"OK".as_slice(), b"ok", b"NO", b"BAD", b"BYE", b"PREAUTH"] { + assert_eq!(untagged_trailing_literal(status, b"* OK done {3}"), None); + } + } + + #[test] + fn test_parse_body_section() { + for input in [ + b"BODY[]<1024>".as_slice(), + b"BODY[HEADER]<50>", + b"BODY[]<0.512>", + b"BODY[]", + b"BODY.PEEK[]", + b"BODY[1.2]", + b"BODY[TEXT]", + b"body.peek[header.fields (subject)]", + ] { + let (rem, _) = parse_body_section(input).unwrap(); + assert!(rem.is_empty(), "unconsumed input for {input:?}"); + } + } + + #[test] + fn test_parse_untagged_response_stops_at_literal_specifier() { + for (input, keyword, raw_line, rem_expected) in [ + ( + b"* LIST () \"/\" {5}\r\nINBOX\r\n".as_slice(), + b"LIST".as_slice(), + b"* LIST () \"/\" {5}".as_slice(), + b"INBOX\r\n".as_slice(), + ), + ( + b"* 2 FETCH (BODY[] {10}\r\nHelloWorld)\r\n", + b"FETCH", + b"* 2 FETCH (BODY[] {10}", + b"HelloWorld)\r\n", + ), + ] { + let (rem, msg) = parse_response(input).unwrap(); + assert_eq!(rem, rem_expected); + assert_eq!(msg.raw_line, raw_line); + assert!(matches!( + msg.message, + ImapMessageType::Untagged { keyword: ref kw, .. } if kw == keyword + )); + } + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 0b39f5ed3c..f69fa2ddcf 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -140,6 +140,7 @@ pub mod util; pub mod websocket; pub mod x509; pub use suricata_ffi::direction; +pub mod imap; pub mod llmnr; #[allow(unused_imports)] diff --git a/src/Makefile.am b/src/Makefile.am index a606981fe1..491fcb4b03 100755 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -37,7 +37,6 @@ noinst_HEADERS = \ app-layer-htp.h \ app-layer-http2.h \ app-layer-ike.h \ - app-layer-imap.h \ app-layer-modbus.h \ app-layer-nfs-tcp.h \ app-layer-nfs-udp.h \ @@ -615,7 +614,6 @@ libsuricata_c_a_SOURCES = \ app-layer-htp.c \ app-layer-http2.c \ app-layer-ike.c \ - app-layer-imap.c \ app-layer-modbus.c \ app-layer-nfs-tcp.c \ app-layer-nfs-udp.c \ diff --git a/src/app-layer-imap.c b/src/app-layer-imap.c deleted file mode 100644 index d1264e72de..0000000000 --- a/src/app-layer-imap.c +++ /dev/null @@ -1,133 +0,0 @@ -/* Copyright (C) 2024 Open Information Security Foundation - * - * You can copy, redistribute or modify this Program under the terms of - * the GNU General Public License version 2 as published by the Free - * Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * version 2 along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA - * 02110-1301, USA. - */ - -/** - * \file - * - * \author Mahmoud Maatuq - * - */ - -#include "suricata-common.h" -#include "app-layer-detect-proto.h" -#include "app-layer-imap.h" -#include "app-layer-protos.h" -#include "rust.h" -#include "stream-tcp-private.h" -#include "stream-tcp-reassemble.h" -#include "util-debug.h" - -static AppProto ImapClientProbingParser( - const Flow *f, uint8_t direction, const uint8_t *input, uint32_t len, uint8_t *rdir) -{ - // This is called after " CAPABILITY" pattern has been found - if (f->alproto_tc == ALPROTO_IMAP) { - // server was already recognised as IMAP, so ok - return ALPROTO_IMAP; - } - for (size_t i = 0; i < len; i++) { - if (input[i] >= '0' && input[i] <= '9') { - // We check if the tag at the beginning of the input contains a number - return ALPROTO_IMAP; - } - if (input[i] == ' ') { - break; - } - } - - TcpSession *ssn = (TcpSession *)f->protoctx; - if (ssn == NULL) { - return ALPROTO_FAILED; - } - const uint32_t size_tc = StreamDataAvailableForProtoDetect(&ssn->server); - - if (size_tc < 8 && f->alproto_tc == ALPROTO_UNKNOWN) { - // wait for another packet from server - return ALPROTO_UNKNOWN; - } - return ALPROTO_FAILED; -} - -static int IMAPRegisterPatternsForProtocolDetection(void) -{ - if (SCAppLayerProtoDetectPMRegisterPatternCI( - IPPROTO_TCP, ALPROTO_IMAP, "* OK ", 5, 0, STREAM_TOCLIENT) < 0) { - return -1; - } - - if (SCAppLayerProtoDetectPMRegisterPatternCI( - IPPROTO_TCP, ALPROTO_IMAP, "* NO ", 5, 0, STREAM_TOCLIENT) < 0) { - return -1; - } - - if (SCAppLayerProtoDetectPMRegisterPatternCI( - IPPROTO_TCP, ALPROTO_IMAP, "* BAD ", 6, 0, STREAM_TOCLIENT) < 0) { - return -1; - } - - if (SCAppLayerProtoDetectPMRegisterPatternCI( - IPPROTO_TCP, ALPROTO_IMAP, "* LIST ", 7, 0, STREAM_TOCLIENT) < 0) { - return -1; - } - - if (SCAppLayerProtoDetectPMRegisterPatternCI( - IPPROTO_TCP, ALPROTO_IMAP, "* ESEARCH ", 10, 0, STREAM_TOCLIENT) < 0) { - return -1; - } - - if (SCAppLayerProtoDetectPMRegisterPatternCI( - IPPROTO_TCP, ALPROTO_IMAP, "* STATUS ", 9, 0, STREAM_TOCLIENT) < 0) { - return -1; - } - - if (SCAppLayerProtoDetectPMRegisterPatternCI( - IPPROTO_TCP, ALPROTO_IMAP, "* FLAGS ", 8, 0, STREAM_TOCLIENT) < 0) { - return -1; - } - - /** - * there is no official document that limits the length of the tag - * some practical implementations limit it to 20 characters - * but keeping depth equal to 31 fails unit tests such AppLayerTest10 - * so keeping depth 17 for now to pass unit tests, that might miss some detections - * until we find a better solution for the unit tests. - * - * AppLayerTest10 fails because it expects protocol detection to be completed with only 17 bytes - * as input, and with this new pattern, we would need more bytes to finish protocol detection. - */ - if (SCAppLayerProtoDetectPMRegisterPatternCIwPP(IPPROTO_TCP, ALPROTO_IMAP, " CAPABILITY", - 17 /*6 for max tag len + space + len(CAPABILITY)*/, 0, STREAM_TOSERVER, - ImapClientProbingParser, 12, 17) < 0) { - return -1; - } - - return 0; -} - -void RegisterIMAPParsers(void) -{ - const char *proto_name = "imap"; - - if (SCAppLayerProtoDetectConfProtoDetectionEnabled("tcp", proto_name)) { - SCLogDebug("IMAP protocol detection is enabled."); - AppLayerProtoDetectRegisterProtocol(ALPROTO_IMAP, proto_name); - if (IMAPRegisterPatternsForProtocolDetection() < 0) - SCLogError("Failed to register IMAP protocol detection patterns."); - } else { - SCLogDebug("Protocol detector and parser disabled for IMAP."); - } -} diff --git a/src/app-layer-parser.c b/src/app-layer-parser.c index dab0b17f53..95289c0898 100644 --- a/src/app-layer-parser.c +++ b/src/app-layer-parser.c @@ -55,7 +55,6 @@ #include "app-layer-tftp.h" #include "app-layer-ike.h" #include "app-layer-http2.h" -#include "app-layer-imap.h" #include "conf.h" #include "decode.h" #include "detect.h" @@ -2056,9 +2055,9 @@ void AppLayerParserRegisterProtocolParsers(void) SCRegisterRdpParser(); RegisterHTTP2Parsers(); SCRegisterTelnetParser(); - RegisterIMAPParsers(); SCRegisterLLMNRUdpParser(); SCRegisterLLMNRTcpParser(); + SCRegisterImapParser(); for (size_t i = 0; i < preregistered_callbacks_nb; i++) { PreRegisteredCallbacks[i](); diff --git a/suricata.yaml.in b/suricata.yaml.in index 49c1df696c..ed76e86c7e 100644 --- a/suricata.yaml.in +++ b/suricata.yaml.in @@ -1082,7 +1082,8 @@ app-layer: content-inspect-min-size: 32768 content-inspect-window: 4096 imap: - enabled: detection-only + enabled: yes + # max-tx: 256 pop3: enabled: yes detection-ports: From c3d583349b234c8d73734d1cdc779bb3fb8384b4 Mon Sep 17 00:00:00 2001 From: Giuseppe Longo Date: Fri, 23 Jan 2026 10:36:44 +0100 Subject: [PATCH 3/5] imap: implement logger This introduces a logger for IMAP protocol. Ticket #8276 --- etc/schema.json | 21 +++++++++ rust/src/imap/logger.rs | 97 +++++++++++++++++++++++++++++++++++++++++ rust/src/imap/mod.rs | 1 + src/output.c | 5 +++ suricata.yaml.in | 1 + 5 files changed, 125 insertions(+) create mode 100644 rust/src/imap/logger.rs diff --git a/etc/schema.json b/etc/schema.json index 4cff77cefb..f355ecb03e 100644 --- a/etc/schema.json +++ b/etc/schema.json @@ -2832,6 +2832,27 @@ } } }, + "imap": { + "type": "object", + "description": "IMAP transaction data", + "additionalProperties": false, + "properties": { + "requests": { + "type": "array", + "description": "Array of IMAP request lines in this transaction", + "items": { + "type": "string" + } + }, + "responses": { + "type": "array", + "description": "Array of IMAP response lines in this transaction", + "items": { + "type": "string" + } + } + } + }, "in_iface": { "type": "string" }, diff --git a/rust/src/imap/logger.rs b/rust/src/imap/logger.rs new file mode 100644 index 0000000000..6ff4d1657c --- /dev/null +++ b/rust/src/imap/logger.rs @@ -0,0 +1,97 @@ +/* Copyright (C) 2026 Open Information Security Foundation + * + * You can copy, redistribute or modify this Program under the terms of + * the GNU General Public License version 2 as published by the Free + * Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * version 2 along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +// Author: Giuseppe Longo + +use std::borrow::Cow; + +use crate::imap::imap::ImapTransaction; +use crate::imap::parser::{ImapCommand, ImapMessage, ImapMessageType}; +use crate::jsonbuilder::{JsonBuilder, JsonError}; + +const REDACTED: &str = ""; + +fn log_request_line( + request: &ImapMessage, suppress_continuation_data: bool, +) -> Option> { + if request.raw_line.is_empty() { + return None; + } + + match &request.message { + ImapMessageType::ContinuationData if suppress_continuation_data => None, + ImapMessageType::Command { + command: ImapCommand::Login, + arguments, + .. + } if !arguments.is_empty() => { + let tag = request.tag_str(); + Some(Cow::Owned(format!("{tag} LOGIN {REDACTED}"))) + } + ImapMessageType::Command { + command: ImapCommand::Authenticate, + arguments, + .. + } if arguments.len() > 1 => { + let tag = request.tag_str(); + let mechanism = String::from_utf8_lossy(&arguments[0]); + Some(Cow::Owned(format!( + "{tag} AUTHENTICATE {mechanism} {REDACTED}" + ))) + } + _ => Some(String::from_utf8_lossy(&request.raw_line)), + } +} + +fn log_imap(tx: &ImapTransaction, js: &mut JsonBuilder) -> Result<(), JsonError> { + js.open_object("imap")?; + + if !tx.requests.is_empty() { + js.open_array("requests")?; + + let suppress_continuation_data = tx.command.eq_ignore_ascii_case(b"AUTHENTICATE") + || tx.command.eq_ignore_ascii_case(b"LOGIN"); + for req in &tx.requests { + if let Some(line) = log_request_line(req, suppress_continuation_data) { + js.append_string(&line)?; + } + } + + js.close()?; + } + + if !tx.response_lines.is_empty() { + js.open_array("responses")?; + + for line in &tx.response_lines { + js.append_string(&String::from_utf8_lossy(line))?; + } + js.close()?; + } + + js.close()?; + + Ok(()) +} + +#[no_mangle] +pub unsafe extern "C" fn SCImapLoggerLog( + tx: *mut std::os::raw::c_void, js: &mut JsonBuilder, +) -> bool { + let tx = cast_pointer!(tx, ImapTransaction); + log_imap(tx, js).is_ok() +} diff --git a/rust/src/imap/mod.rs b/rust/src/imap/mod.rs index 91f3c0d300..739931da27 100644 --- a/rust/src/imap/mod.rs +++ b/rust/src/imap/mod.rs @@ -18,4 +18,5 @@ // Author: Giuseppe Longo pub mod imap; +pub mod logger; pub mod parser; diff --git a/src/output.c b/src/output.c index 651a6eb644..ca9f87ea24 100644 --- a/src/output.c +++ b/src/output.c @@ -990,6 +990,7 @@ void OutputRegisterRootLoggers(void) // underscore instead of dash for bittorrent_dht RegisterSimpleJsonApplayerLogger(ALPROTO_BITTORRENT_DHT, (EveJsonSimpleTxLogFunc)SCBittorrentDhtLogger, "bittorrent_dht"); + RegisterSimpleJsonApplayerLogger(ALPROTO_IMAP, (EveJsonSimpleTxLogFunc)SCImapLoggerLog, NULL); OutputPacketLoggerRegister(); OutputFiledataLoggerRegister(); @@ -1236,6 +1237,10 @@ void OutputRegisterLoggers(void) JsonArpLogRegister(); /* LLMNR JSON logger */ JsonLLMNRLogRegister(); + /* IMAP JSON logger. */ + OutputRegisterTxSubModule(LOGGER_JSON_TX, "eve-log", "JsonImapLog", "eve-log.imap", + OutputJsonLogInitSub, ALPROTO_IMAP, JsonGenericDirFlowLogger, JsonLogThreadInit, + JsonLogThreadDeinit); for (size_t i = 0; i < preregistered_loggers_nb; i++) { OutputRegisterTxSubModule(LOGGER_JSON_TX, "eve-log", preregistered_loggers[i].logname, diff --git a/suricata.yaml.in b/suricata.yaml.in index ed76e86c7e..4719ad308b 100644 --- a/suricata.yaml.in +++ b/suricata.yaml.in @@ -347,6 +347,7 @@ outputs: - pop3 - llmnr #- modbus + - imap - arp: enabled: no # Many events can be logged. Disabled by default - dhcp: From 295e96a4df3c65111d1f839b0aa47ab16d30113a Mon Sep 17 00:00:00 2001 From: Giuseppe Longo Date: Fri, 6 Feb 2026 14:02:08 +0100 Subject: [PATCH 4/5] imap: implement sticky buffers This implements the following sticky buffers for IMAP protocol: - imap.request - imap.response Both match on the request/response lines as sent on the wire and support firewall rules. Ticket #8276 --- etc/schema.json | 10 +++ rust/src/imap/detect.rs | 153 +++++++++++++++++++++++++++++++++++ rust/src/imap/mod.rs | 1 + src/detect-engine-register.c | 1 + 4 files changed, 165 insertions(+) create mode 100644 rust/src/imap/detect.rs diff --git a/etc/schema.json b/etc/schema.json index f355ecb03e..65d3a3793e 100644 --- a/etc/schema.json +++ b/etc/schema.json @@ -2842,6 +2842,11 @@ "description": "Array of IMAP request lines in this transaction", "items": { "type": "string" + }, + "suricata": { + "keywords": [ + "imap.request" + ] } }, "responses": { @@ -2849,6 +2854,11 @@ "description": "Array of IMAP response lines in this transaction", "items": { "type": "string" + }, + "suricata": { + "keywords": [ + "imap.response" + ] } } } diff --git a/rust/src/imap/detect.rs b/rust/src/imap/detect.rs new file mode 100644 index 0000000000..6c1e6f93eb --- /dev/null +++ b/rust/src/imap/detect.rs @@ -0,0 +1,153 @@ +/* Copyright (C) 2026 Open Information Security Foundation + * + * You can copy, redistribute or modify this Program under the terms of + * the GNU General Public License version 2 as published by the Free + * Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * version 2 along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +// Author: Giuseppe Longo + +use crate::core::{STREAM_TOCLIENT, STREAM_TOSERVER}; +use crate::detect::{ + SIGMATCH_INFO_MULTI_BUFFER, SIGMATCH_INFO_STICKY_BUFFER, SIGMATCH_NOOPT, + SIGMATCH_SUPPORT_FIREWALL, +}; +use crate::imap::imap::{ImapTransaction, ALPROTO_IMAP}; +use std::ffi::CString; +use std::os::raw::{c_int, c_void}; +use suricata_sys::sys::{ + DetectEngineCtx, DetectEngineThreadCtx, SCDetectBufferSetActiveList, + SCDetectHelperKeywordRegister, SCDetectHelperKeywordSetCleanCString, + SCDetectHelperMultiBufferProgressMpmRegister, SCDetectSignatureSetAppProto, + SCSigTableAppLiteElmt, Signature, +}; + +static mut G_IMAP_REQUEST_BUFFER_ID: c_int = 0; +static mut G_IMAP_RESPONSE_BUFFER_ID: c_int = 0; + +unsafe extern "C" fn imap_detect_request_setup( + de: *mut DetectEngineCtx, s: *mut Signature, _raw: *const std::os::raw::c_char, +) -> c_int { + if SCDetectSignatureSetAppProto(s, ALPROTO_IMAP) != 0 { + return -1; + } + if SCDetectBufferSetActiveList(de, s, G_IMAP_REQUEST_BUFFER_ID) < 0 { + return -1; + } + 0 +} + +unsafe extern "C" fn imap_tx_get_request( + _de: *mut DetectEngineThreadCtx, tx: *const c_void, _flags: u8, local_id: u32, + buffer: *mut *const u8, buffer_len: *mut u32, +) -> bool { + let tx = cast_pointer!(tx, ImapTransaction); + + let Some(request) = tx.requests.get(local_id as usize) else { + return false; + }; + *buffer = request.raw_line.as_ptr(); + *buffer_len = request.raw_line.len() as u32; + true +} + +unsafe extern "C" fn imap_detect_response_setup( + de: *mut DetectEngineCtx, s: *mut Signature, _raw: *const std::os::raw::c_char, +) -> c_int { + if SCDetectSignatureSetAppProto(s, ALPROTO_IMAP) != 0 { + return -1; + } + if SCDetectBufferSetActiveList(de, s, G_IMAP_RESPONSE_BUFFER_ID) < 0 { + return -1; + } + 0 +} + +unsafe extern "C" fn imap_tx_get_response( + _de: *mut DetectEngineThreadCtx, tx: *const c_void, _flags: u8, local_id: u32, + buffer: *mut *const u8, buffer_len: *mut u32, +) -> bool { + let tx = cast_pointer!(tx, ImapTransaction); + + let Some(line) = tx.response_lines.get(local_id as usize) else { + return false; + }; + *buffer = line.as_ptr(); + *buffer_len = line.len() as u32; + true +} + +#[no_mangle] +pub unsafe extern "C" fn SCDetectImapRegister() { + let name = CString::new("imap.request").unwrap().into_raw(); + let desc = CString::new("match on IMAP request line") + .unwrap() + .into_raw(); + let url = CString::new("/rules/imap-keywords.html#imap-request") + .unwrap() + .into_raw(); + let kw = SCSigTableAppLiteElmt { + name, + desc, + url, + Setup: Some(imap_detect_request_setup), + flags: SIGMATCH_NOOPT + | SIGMATCH_INFO_STICKY_BUFFER + | SIGMATCH_INFO_MULTI_BUFFER + | SIGMATCH_SUPPORT_FIREWALL, + AppLayerTxMatch: None, + Free: None, + }; + let r = SCDetectHelperKeywordRegister(&kw); + SCDetectHelperKeywordSetCleanCString(r); + + G_IMAP_REQUEST_BUFFER_ID = SCDetectHelperMultiBufferProgressMpmRegister( + b"imap.request\0".as_ptr() as *const libc::c_char, + b"IMAP REQUEST LINE\0".as_ptr() as *const libc::c_char, + ALPROTO_IMAP, + STREAM_TOSERVER, + Some(imap_tx_get_request), + 0, + ); + + let name = CString::new("imap.response").unwrap().into_raw(); + let desc = CString::new("match on IMAP response line") + .unwrap() + .into_raw(); + let url = CString::new("/rules/imap-keywords.html#imap-response") + .unwrap() + .into_raw(); + let kw = SCSigTableAppLiteElmt { + name, + desc, + url, + Setup: Some(imap_detect_response_setup), + flags: SIGMATCH_NOOPT + | SIGMATCH_INFO_STICKY_BUFFER + | SIGMATCH_INFO_MULTI_BUFFER + | SIGMATCH_SUPPORT_FIREWALL, + AppLayerTxMatch: None, + Free: None, + }; + let r = SCDetectHelperKeywordRegister(&kw); + SCDetectHelperKeywordSetCleanCString(r); + + G_IMAP_RESPONSE_BUFFER_ID = SCDetectHelperMultiBufferProgressMpmRegister( + b"imap.response\0".as_ptr() as *const libc::c_char, + b"IMAP RESPONSE LINE\0".as_ptr() as *const libc::c_char, + ALPROTO_IMAP, + STREAM_TOCLIENT, + Some(imap_tx_get_response), + 1, + ); +} diff --git a/rust/src/imap/mod.rs b/rust/src/imap/mod.rs index 739931da27..612a2aa13a 100644 --- a/rust/src/imap/mod.rs +++ b/rust/src/imap/mod.rs @@ -17,6 +17,7 @@ // Author: Giuseppe Longo +pub mod detect; pub mod imap; pub mod logger; pub mod parser; diff --git a/src/detect-engine-register.c b/src/detect-engine-register.c index 0c94152a10..c9d5f73e90 100644 --- a/src/detect-engine-register.c +++ b/src/detect-engine-register.c @@ -861,6 +861,7 @@ void SigTableSetup(void) SCDetectSipRegister(); SCDetectTemplateRegister(); SCDetectLdapRegister(); + SCDetectImapRegister(); SCDetectSdpRegister(); SCDetectDNSRegister(); SCDetectPgsqlRegister(); From 5b1f50ef0de09c11fb9ba513eb47386911f33521 Mon Sep 17 00:00:00 2001 From: Giuseppe Longo Date: Wed, 11 Feb 2026 10:54:45 +0100 Subject: [PATCH 5/5] doc: add imap parser and sticky buffers --- doc/userguide/output/eve/eve-json-format.rst | 24 ++++++ doc/userguide/rules/imap-keywords.rst | 79 +++++++++++++++++++ doc/userguide/rules/index.rst | 1 + doc/userguide/rules/multi-buffer-matching.rst | 2 + 4 files changed, 106 insertions(+) create mode 100644 doc/userguide/rules/imap-keywords.rst diff --git a/doc/userguide/output/eve/eve-json-format.rst b/doc/userguide/output/eve/eve-json-format.rst index 0f36559bd7..0a333e4c84 100644 --- a/doc/userguide/output/eve/eve-json-format.rst +++ b/doc/userguide/output/eve/eve-json-format.rst @@ -3388,3 +3388,27 @@ Example :: "min_ttl": 1, "max_ttl": 1 } + +Event type: IMAP +---------------- + +Fields +~~~~~~ + +* "requests": Array of IMAP request lines sent by the client. Each entry is + formatted as "tag command arguments". ``LOGIN`` arguments and SASL initial + responses on ``AUTHENTICATE`` commands are replaced with ````. + Client responses to SASL continuation requests are not logged. +* "responses": Array of IMAP response lines from the server. + +Example :: + + "imap": { + "requests": [ + "8 UID fetch 1 (UID RFC822.SIZE BODY.PEEK[])" + ], + "responses": [ + "* 1 FETCH (UID 1 RFC822.SIZE 452 BODY[] {452})", + "8 OK UID FETCH completed" + ] + } diff --git a/doc/userguide/rules/imap-keywords.rst b/doc/userguide/rules/imap-keywords.rst new file mode 100644 index 0000000000..f8b791a80c --- /dev/null +++ b/doc/userguide/rules/imap-keywords.rst @@ -0,0 +1,79 @@ +IMAP Keywords +============= + +.. role:: example-rule-action +.. role:: example-rule-header +.. role:: example-rule-options +.. role:: example-rule-emphasis + +imap.request +------------ + +Matches on IMAP request lines sent from the client to the server. + +Each buffer is a request line as sent by the client, without the trailing +CRLF. Literal data is not part of the buffer. Unlike the EVE +``imap.requests`` field, ``LOGIN`` and ``AUTHENTICATE`` arguments are not +redacted. + +Syntax:: + + imap.request; content:""; + +``imap.request`` is a 'sticky buffer' and can be used as a ``fast_pattern``. + +``imap.request`` supports multiple buffer matching, see :doc:`multi-buffer-matching`. + +This keyword maps to the EVE field ``imap.requests[]`` + +Examples +^^^^^^^^ + +Example of a signature that would alert if an IMAP request contains a LOGIN command: + +.. container:: example-rule + + alert imap any any -> any any (msg:"IMAP LOGIN request"; :example-rule-emphasis:`imap.request; content:"LOGIN";` sid:1;) + +imap.response +------------- + +Matches on IMAP response lines sent from the server to the client. + +Syntax:: + + imap.response; content:""; + +``imap.response`` is a 'sticky buffer' and can be used as a ``fast_pattern``. + +``imap.response`` supports multiple buffer matching, see :doc:`multi-buffer-matching`. + +This keyword maps to the EVE field ``imap.responses[]`` + +Examples +^^^^^^^^ + +Example of a signature that would alert if an IMAP response contains "OK": + +.. container:: example-rule + + alert imap any any -> any any (msg:"IMAP OK response"; :example-rule-emphasis:`imap.response; content:"OK";` sid:1;) + +Frames +------ + +The IMAP parser supports the following frames: + +* imap.pdu + +imap.pdu +^^^^^^^^ + +A single IMAP request or response PDU. Each command from the client or +response line from the server creates a separate frame. + +.. container:: example-rule + + alert imap any any -> any any (\ + :example-rule-options:`frame:imap.pdu; content:"LOGIN"; startswith;` \ + sid:1;) diff --git a/doc/userguide/rules/index.rst b/doc/userguide/rules/index.rst index b8d21f0c59..7a2f183487 100644 --- a/doc/userguide/rules/index.rst +++ b/doc/userguide/rules/index.rst @@ -41,6 +41,7 @@ Suricata Rules quic-keywords nfs-keywords smtp-keywords + imap-keywords websocket-keywords app-layer decode-layer diff --git a/doc/userguide/rules/multi-buffer-matching.rst b/doc/userguide/rules/multi-buffer-matching.rst index fd54819d87..60f07d602b 100644 --- a/doc/userguide/rules/multi-buffer-matching.rst +++ b/doc/userguide/rules/multi-buffer-matching.rst @@ -94,6 +94,8 @@ following keywords: * ``http.response_header`` * ``http2.header_name`` * ``ike.vendor`` +* ``imap.request`` +* ``imap.response`` * ``krb5.cname`` * ``krb5.sname`` * ``ldap.request.attribute_type``