From f6217be0e940f3041eca8ac38d4a04e197cc791c Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 09:49:54 -0500 Subject: [PATCH 01/18] docs: add TCP stream reassembly design spec Forensic-grade TCP reassembly module design covering: - ISN-relative sequence tracking with wraparound handling - First-wins overlap policy for evasion resilience - Incremental stream exposure via StreamHandler callbacks - Configurable memory limits (10MB/direction, 1GB global) - Mid-stream flow pickup and LRU eviction --- .../specs/2026-04-06-tcp-reassembly-design.md | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-06-tcp-reassembly-design.md diff --git a/docs/superpowers/specs/2026-04-06-tcp-reassembly-design.md b/docs/superpowers/specs/2026-04-06-tcp-reassembly-design.md new file mode 100644 index 0000000..205beb5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-tcp-reassembly-design.md @@ -0,0 +1,341 @@ +# TCP Stream Reassembly — Design Spec + +## Goal + +Add forensic-grade TCP stream reassembly to wirerust so that TCP-based protocol analyzers (HTTP, TLS, SMB, etc.) can operate on complete, ordered byte streams instead of individual packet payloads. + +## Why + +Without reassembly, TCP-based analyzers only see whatever fits in a single packet. HTTP requests span multiple segments, TLS ClientHellos can be fragmented, and any protocol over TCP is unreliable to parse packet-by-packet. pcapper (Python/Scapy) has basic reassembly via `TCPSession` but handles retransmissions and overlapping segments poorly. wirerust's reassembly is a correctness advantage over pcapper, not just a speed advantage. + +## Architecture + +The reassembly module sits between the decoder and stream-based analyzers: + +``` +Reader → Decoder → TcpReassembler ──→ StreamAnalyzers (HTTP, TLS, SMB...) + │ + └──→ per-packet Analyzers (DNS, port scan...) +``` + +Every decoded packet goes through both paths. The reassembler tracks TCP flows and delivers contiguous byte streams to stream analyzers via callbacks. Per-packet analyzers (DNS, etc.) continue to receive individual packets unchanged. + +## Core Data Model + +### FlowKey + +Identifies a TCP connection. Canonicalized so both directions map to the same key. + +```rust +pub struct FlowKey { + pub lower_ip: IpAddr, + pub lower_port: u16, + pub upper_ip: IpAddr, + pub upper_port: u16, +} +``` + +Canonicalization: compare `(ip, port)` tuples lexicographically; the smaller one is `lower`. This means `A→B` and `B→A` produce the same `FlowKey`. + +### FlowDirection + +One side of a TCP connection. Each flow has two: client→server and server→client. + +```rust +pub struct FlowDirection { + pub isn: Option, + pub base_offset: u32, + pub segments: BTreeMap>, + pub reassembled_bytes: usize, + pub fin_seen: bool, + pub rst_seen: bool, + pub depth_exceeded: bool, +} +``` + +- `isn`: Initial Sequence Number. Set from SYN or inferred from first data packet. +- `base_offset`: The next contiguous byte expected, ISN-relative. Starts at 1 (ISN+1 is the first data byte after SYN). +- `segments`: Out-of-order buffer. Keyed by ISN-relative offset (`seq.wrapping_sub(isn)`). BTreeMap provides ordered iteration for flush. +- `reassembled_bytes`: Total bytes flushed so far. Used to enforce depth limit. +- `fin_seen`, `rst_seen`: Terminal flag tracking. +- `depth_exceeded`: Set when `reassembled_bytes` exceeds the per-direction limit. + +### TcpFlow + +A complete TCP connection. + +```rust +pub struct TcpFlow { + pub key: FlowKey, + pub client_to_server: FlowDirection, + pub server_to_client: FlowDirection, + pub state: FlowState, + pub partial: bool, + pub first_seen: u32, + pub last_seen: u32, +} +``` + +- `state`: `New`, `SynSent`, `Established`, `Closing`, `Closed`, `TimedOut`. +- `partial`: `true` if the flow was picked up mid-stream (no SYN observed). Forensic reports include this flag. + +### FlowState Transitions + +``` +New → SynSent (SYN seen) +SynSent → Established (SYN+ACK seen, or data seen) +New → Established (data without SYN — mid-stream pickup, sets partial=true) +Established → Closing (FIN seen on either direction) +Closing → Closed (FIN seen on both directions, or timeout) +Any → Closed (RST seen) +Any → TimedOut (flow_timeout_secs exceeded) +``` + +## Sequence Number Handling + +All segment keys are stored as ISN-relative offsets: `seq.wrapping_sub(isn)`. This solves wraparound because realistic streams stay within a ~4GB window, so relative offsets don't wrap and BTreeMap ordering works correctly. + +Comparison helpers for raw seq numbers use wrapping arithmetic: + +```rust +fn seq_lt(a: u32, b: u32) -> bool { + (a.wrapping_sub(b)) as i32 > 0 // b is "before" a in sequence space +} +``` + +## Overlap Handling + +**Policy: first-wins (hardcoded).** When a new segment overlaps existing data, the existing bytes are kept. This matches the behavior of Windows, macOS, and BSD — the majority of real-world targets. It also matches what Zeek and NetworkMiner do. + +Implementation: on segment insertion, check BTreeMap neighbors. If the new segment's range overlaps any existing segment, trim the new one to only cover gaps. If fully covered, discard it (retransmission dedup). + +## Mid-Stream Pickup + +If data arrives for a flow with no SYN observed: +1. Set `isn` to the first segment's sequence number minus 1. +2. Set state to `Established`. +3. Set `partial = true`. +4. Reassembly proceeds normally from there. + +This handles common scenarios: pcap capture started after connection was established, asymmetric SPAN port configs, or SYN packets dropped. + +## Reassembly Engine + +### TcpReassembler + +```rust +pub struct TcpReassembler { + flows: HashMap, + config: ReassemblyConfig, + total_memory_used: usize, +} + +pub struct ReassemblyConfig { + pub max_depth_per_direction: usize, // default: 10MB (10_485_760) + pub global_memcap: usize, // default: 1GB (1_073_741_824) + pub flow_timeout_secs: u32, // default: 300 +} +``` + +### Public API + +```rust +impl TcpReassembler { + pub fn new(config: ReassemblyConfig) -> Self; + + /// Process a decoded packet. Calls handler callbacks when new + /// contiguous data becomes available. Uses pcap timestamp for + /// timeout tracking (not wall clock). + pub fn process_packet( + &mut self, + packet: &ParsedPacket, + timestamp: u32, + handler: &mut dyn StreamHandler, + ); + + /// Expire flows older than flow_timeout_secs. + /// Evicts non-established first, then LRU. + pub fn expire_flows( + &mut self, + current_time: u32, + handler: &mut dyn StreamHandler, + ); + + /// Expire all remaining flows. Call at end of pcap. + pub fn finalize(&mut self, handler: &mut dyn StreamHandler); + + pub fn stats(&self) -> ReassemblyStats; +} +``` + +### Processing Flow (per packet) + +1. Skip non-TCP packets. +2. Extract FlowKey from ParsedPacket (canonicalize). +3. Look up or create TcpFlow in HashMap. +4. Determine direction (client→server or server→client) by comparing src against flow initiator. +5. Handle TCP flags: + - SYN: record ISN, update state. + - SYN+ACK: record server ISN, transition to Established. + - FIN: mark `fin_seen` on that direction, transition state. + - RST: mark `rst_seen`, transition to Closed, call `handler.on_flow_close()`. +6. If payload present and depth not exceeded and memcap not exceeded: + - Compute ISN-relative offset. + - Check for overlaps with existing segments (first-wins: trim new). + - Insert into BTreeMap. + - Flush: iterate from `base_offset`, move contiguous segments out, call `handler.on_data()` with the new bytes. + - Advance `base_offset`, increment `reassembled_bytes`. + - Update `total_memory_used`. +7. Update `last_seen` with pcap timestamp. + +### Contiguous Flush + +After inserting a segment, scan the BTreeMap starting from `base_offset`: + +``` +While BTreeMap contains a segment at base_offset: + Remove it from BTreeMap + Call handler.on_data(flow_key, direction, &data, base_offset) + base_offset += data.len() + reassembled_bytes += data.len() +``` + +If `reassembled_bytes` exceeds `max_depth_per_direction`, set `depth_exceeded = true` and generate a Finding. + +## StreamHandler Trait + +Analyzers that need reassembled streams implement this: + +```rust +pub enum Direction { + ClientToServer, + ServerToClient, +} + +pub enum CloseReason { + Fin, + Rst, + Timeout, + MemoryPressure, +} + +pub trait StreamHandler { + fn on_data( + &mut self, + flow_key: &FlowKey, + direction: Direction, + data: &[u8], + offset: u32, + ); + + fn on_flow_close( + &mut self, + flow_key: &FlowKey, + reason: CloseReason, + ); +} +``` + +### StreamAnalyzer Trait + +Extends StreamHandler with reporting methods compatible with the existing Reporter system: + +```rust +pub trait StreamAnalyzer: StreamHandler { + fn name(&self) -> &'static str; + fn summarize(&self) -> AnalysisSummary; + fn findings(&self) -> Vec; +} +``` + +Reporters consume `Vec` and `Vec` from both `ProtocolAnalyzer` (per-packet) and `StreamAnalyzer` (stream-based) — no changes to reporters needed. + +## Memory Management + +### Per-Direction Depth Limit (default 10MB) + +Once `reassembled_bytes` exceeds `max_depth_per_direction` on a flow direction: +- Stop storing new payload for that direction. +- Continue tracking the flow for metadata (packet counts, flags, timing). +- Generate a Finding: `[Anomaly] INCONCLUSIVE (LOW) — Flow {key} exceeded reassembly depth (10MB), stream truncated.` + +### Global Memory Cap (default 1GB) + +Before inserting a new segment, check `total_memory_used` against `global_memcap`. If exceeded: + +1. Evict non-established flows first (SynSent, Closing, half-open) — likely port scans. +2. If still over, evict established flows by LRU (`last_seen` oldest first). +3. Each eviction: flush accumulated data to handler, call `on_flow_close(MemoryPressure)`. +4. Increment `stats.flows_evicted`. +5. Log warning to stderr: "Reassembly memory cap reached, evicting flows." + +The final report includes: "Warning: reassembly memory cap reached, N flows evicted. Re-run with --reassembly-memcap to increase." + +## Auto-Detect Activation + +Reassembly is not always-on. It activates automatically when any TCP-based analyzer is enabled: + +```rust +let needs_reassembly = enable_http || enable_tls || enable_smb || cli.reassemble; +let skip_reassembly = cli.no_reassemble; + +let mut reassembler = if needs_reassembly && !skip_reassembly { + Some(TcpReassembler::new(config)) +} else { + None +}; +``` + +When no TCP analyzers are active and `--reassemble` is not set, the reassembler is not created. Zero overhead for DNS-only or summary-only runs. + +## CLI Flags + +``` +--reassemble Force TCP reassembly on +--no-reassemble Force TCP reassembly off (quick header scan) +--reassembly-depth Per-direction stream limit in MB (default: 10) +--reassembly-memcap Global reassembly memory cap in MB (default: 1024) +``` + +## Edge Cases + +| Scenario | Handling | +|----------|----------| +| Malformed TCP header | Skip packet, increment `stats.malformed_packets` | +| SYN retransmit with different ISN | Keep first ISN (first-wins) | +| Zero-window probes / keepalives | Detect single byte at `expected_seq - 1`, ignore | +| One-sided capture (only one direction) | Visible direction reassembles, other stays empty | +| Duplicate FIN/RST | Ignore after the first | +| Half-close (FIN one direction, data continues other) | Each direction tracked independently | +| Pcap timestamp not monotonic | Use packet timestamps as-is; timeout still works since we compare against `last_seen` | + +## File Structure + +``` +src/reassembly/ +├── mod.rs — TcpReassembler, ReassemblyConfig, ReassemblyStats, public API +├── flow.rs — FlowKey, TcpFlow, FlowDirection, FlowState +├── segment.rs — Segment insertion, overlap handling, contiguous flush logic +└── handler.rs — StreamHandler trait, StreamAnalyzer trait, Direction, CloseReason +``` + +## Testing Strategy + +### Unit Tests + +- **segment.rs**: Insert ordered, out-of-order, overlapping, retransmitted, and wrapping segments. Verify first-wins overlap. Verify contiguous flush produces correct bytes at correct offsets. +- **flow.rs**: State transitions through full lifecycle. FlowKey canonicalization (A→B == B→A). Mid-stream pickup sets partial flag. ISN inference from first data packet. +- **mod.rs**: Depth limit enforcement (>10MB stops buffering, generates Finding). Memcap eviction (non-established first, then LRU). Stats tracking. + +### Integration Tests + +All tests use synthetic packet bytes (same pattern as existing wirerust tests — no external fixtures). + +- Three-packet stream in order → reassembled payload matches concatenation. +- Three-packet stream out of order [1, 3, 2] → same result after reordering. +- Retransmission of packet 1 → deduplicated, single copy in stream. +- Overlapping segments with different data → first-wins, original data preserved. +- 11MB stream → first 10MB reassembled, truncation Finding generated. +- 100+ flows exceeding memcap → eviction occurs, stats.flows_evicted > 0. +- Mid-stream flow (no SYN) → reassembles correctly, partial flag set. +- RST mid-stream → on_flow_close(Rst) called, accumulated data flushed. From 6ebfb5a0e4affc5fb4da6d621b9ddbb53fc6b308 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 09:52:32 -0500 Subject: [PATCH 02/18] docs: update TCP reassembly spec with Perplexity review findings - Use u64 for ISN-relative offsets (handles >4GB streams) - Add overlap anomaly detection (>50 overlaps = evasion Finding) - Add conflicting overlap data detection (HIGH confidence Finding) - Add small segment flood detection (>2048 tiny segments) - Add depth truncation mid-segment (partial store, not full drop) - Add Future Considerations section for deferred items (TFO, PAWS, etc.) - Confirm ACK tracking not needed for offline analysis --- .../specs/2026-04-06-tcp-reassembly-design.md | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-04-06-tcp-reassembly-design.md b/docs/superpowers/specs/2026-04-06-tcp-reassembly-design.md index 205beb5..0df2f8c 100644 --- a/docs/superpowers/specs/2026-04-06-tcp-reassembly-design.md +++ b/docs/superpowers/specs/2026-04-06-tcp-reassembly-design.md @@ -44,9 +44,11 @@ One side of a TCP connection. Each flow has two: client→server and server→cl ```rust pub struct FlowDirection { pub isn: Option, - pub base_offset: u32, - pub segments: BTreeMap>, + pub base_offset: u64, + pub segments: BTreeMap>, pub reassembled_bytes: usize, + pub overlap_count: u32, + pub small_segment_count: u32, pub fin_seen: bool, pub rst_seen: bool, pub depth_exceeded: bool, @@ -54,9 +56,11 @@ pub struct FlowDirection { ``` - `isn`: Initial Sequence Number. Set from SYN or inferred from first data packet. -- `base_offset`: The next contiguous byte expected, ISN-relative. Starts at 1 (ISN+1 is the first data byte after SYN). -- `segments`: Out-of-order buffer. Keyed by ISN-relative offset (`seq.wrapping_sub(isn)`). BTreeMap provides ordered iteration for flush. +- `base_offset`: The next contiguous byte expected, ISN-relative. Starts at 1 (ISN+1 is the first data byte after SYN). Uses `u64` to handle streams >4GB without key-space wraparound. +- `segments`: Out-of-order buffer. Keyed by ISN-relative offset as `u64` (`(seq.wrapping_sub(isn)) as u64`). BTreeMap provides ordered iteration for flush. - `reassembled_bytes`: Total bytes flushed so far. Used to enforce depth limit. +- `overlap_count`: Number of overlapping segments seen. If >50, generate an evasion-attempt Finding. +- `small_segment_count`: Consecutive segments <8 bytes. If >2048, generate an evasion-attempt Finding. - `fin_seen`, `rst_seen`: Terminal flag tracking. - `depth_exceeded`: Set when `reassembled_bytes` exceeds the per-direction limit. @@ -93,13 +97,18 @@ Any → TimedOut (flow_timeout_secs exceeded) ## Sequence Number Handling -All segment keys are stored as ISN-relative offsets: `seq.wrapping_sub(isn)`. This solves wraparound because realistic streams stay within a ~4GB window, so relative offsets don't wrap and BTreeMap ordering works correctly. +All segment keys are stored as ISN-relative offsets cast to `u64`: `(seq.wrapping_sub(isn)) as u64`. The wrapping subtraction handles the 32-bit sequence space correctly, and promoting to `u64` ensures BTreeMap key ordering works for streams of any size (including >4GB transfers that wrap the sequence space). Comparison helpers for raw seq numbers use wrapping arithmetic: ```rust -fn seq_lt(a: u32, b: u32) -> bool { - (a.wrapping_sub(b)) as i32 > 0 // b is "before" a in sequence space +fn seq_before(a: u32, b: u32) -> bool { + // a is "before" b in TCP sequence space (signed comparison of difference) + (a.wrapping_sub(b) as i32) < 0 +} + +fn seq_offset(seq: u32, isn: u32) -> u64 { + seq.wrapping_sub(isn) as u64 } ``` @@ -109,6 +118,15 @@ fn seq_lt(a: u32, b: u32) -> bool { Implementation: on segment insertion, check BTreeMap neighbors. If the new segment's range overlaps any existing segment, trim the new one to only cover gaps. If fully covered, discard it (retransmission dedup). +**Anomaly detection on overlaps:** +- Increment `overlap_count` on every overlap. +- If `overlap_count > 50` on a flow direction, generate a Finding: `[Anomaly] LIKELY (MEDIUM) — Excessive TCP segment overlaps on flow {key} ({count} overlaps), possible evasion attempt. MITRE: T1036.` +- If overlapping data differs from existing data (not a simple retransmit), generate: `[Anomaly] LIKELY (HIGH) — Conflicting data in overlapping TCP segments on flow {key}, possible insertion/evasion attack.` + +**Depth truncation mid-segment:** When inserting a segment that would exceed `max_depth_per_direction`, truncate it to `depth - reassembled_bytes` rather than dropping entirely. This captures as much as possible before cutting off. + +**Small segment flood detection:** Track consecutive segments <8 bytes in `small_segment_count`. If >2048, generate: `[Anomaly] INCONCLUSIVE (MEDIUM) — Excessive small TCP segments on flow {key} ({count} segments <8 bytes), possible IDS evasion.` + ## Mid-Stream Pickup If data arrives for a flow with no SYN observed: @@ -225,7 +243,7 @@ pub trait StreamHandler { flow_key: &FlowKey, direction: Direction, data: &[u8], - offset: u32, + offset: u64, ); fn on_flow_close( @@ -334,8 +352,23 @@ All tests use synthetic packet bytes (same pattern as existing wirerust tests - Three-packet stream in order → reassembled payload matches concatenation. - Three-packet stream out of order [1, 3, 2] → same result after reordering. - Retransmission of packet 1 → deduplicated, single copy in stream. -- Overlapping segments with different data → first-wins, original data preserved. +- Overlapping segments with different data → first-wins, original data preserved, Finding generated for conflicting data. +- Excessive overlaps (>50) → evasion-attempt Finding generated. +- Small segment flood (>2048 segments <8 bytes) → evasion-attempt Finding generated. +- Depth truncation mid-segment → partial segment stored up to limit, truncation Finding generated. - 11MB stream → first 10MB reassembled, truncation Finding generated. - 100+ flows exceeding memcap → eviction occurs, stats.flows_evicted > 0. - Mid-stream flow (no SYN) → reassembles correctly, partial flag set. - RST mid-stream → on_flow_close(Rst) called, accumulated data flushed. + +## Future Considerations (Not In Scope) + +These are deliberately deferred. They can be added later without changing the core architecture: + +- **Configurable overlap policy** (last-wins, per-OS policy) — first-wins covers the majority of targets; add configurability only if users request it. +- **TCP Fast Open (TFO)** — Data in SYN packets. Rare in practice. Would require special handling of payload attached to SYN. +- **PAWS (Protection Against Wrapped Sequence Numbers)** — Uses TCP timestamps option to reject old duplicates. Not needed for offline pcap analysis since we see all packets. +- **Urgent pointer / out-of-band data** — Rarely used. Would need special offset handling. +- **ACK-based progress tracking** — Not needed for offline analysis. Sequence-number-only tracking is sufficient. +- **Live capture mode** — Current design uses pcap timestamps. Live mode would need wall-clock timeouts and periodic expiration on a timer. +- **Parallel reassembly with rayon** — Flow-level parallelism is possible since flows are independent, but adds complexity to the callback model. From c0ebf0aa11d8d6a3f46e0046534eb88c394bc761 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 09:58:57 -0500 Subject: [PATCH 03/18] docs: add TCP reassembly implementation plan --- .../plans/2026-04-06-tcp-reassembly.md | 1787 +++++++++++++++++ 1 file changed, 1787 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-06-tcp-reassembly.md diff --git a/docs/superpowers/plans/2026-04-06-tcp-reassembly.md b/docs/superpowers/plans/2026-04-06-tcp-reassembly.md new file mode 100644 index 0000000..3fdff5e --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-tcp-reassembly.md @@ -0,0 +1,1787 @@ +# TCP Stream Reassembly Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add forensic-grade TCP stream reassembly to wirerust with overlapping segment handling, memory limits, anomaly detection, and incremental stream delivery via callbacks. + +**Architecture:** Standalone `src/reassembly/` module between decoder and analyzers. FlowKey identifies connections, BTreeMap> stores out-of-order segments keyed by ISN-relative offset, contiguous data is flushed to StreamHandler callbacks incrementally. First-wins overlap policy. Configurable depth (10MB/direction) and memcap (1GB global). + +**Tech Stack:** Rust 2024 edition. No new crate dependencies — uses std collections (HashMap, BTreeMap) and existing wirerust types (ParsedPacket, TransportInfo, Finding). + +--- + +## File Structure + +``` +src/ +├── decoder.rs — MODIFY: add seq_number to TransportInfo::Tcp +├── cli.rs — MODIFY: add --reassemble, --no-reassemble, --reassembly-depth, --reassembly-memcap +├── lib.rs — MODIFY: add pub mod reassembly; +├── main.rs — MODIFY: wire reassembler into analyze pipeline +├── reassembly/ +│ ├── mod.rs — TcpReassembler, ReassemblyConfig, ReassemblyStats, public API +│ ├── flow.rs — FlowKey, TcpFlow, FlowDirection, FlowState, Direction, CloseReason +│ ├── segment.rs — insert_segment(), flush_contiguous(), overlap trimming +│ └── handler.rs — StreamHandler trait, StreamAnalyzer trait +tests/ +├── decoder_tests.rs — MODIFY: update tests for new seq_number field +├── reassembly_flow_tests.rs — FlowKey canonicalization, state transitions +├── reassembly_segment_tests.rs — Segment insertion, overlap, flush, wraparound +├── reassembly_engine_tests.rs — Full engine: depth limit, memcap, eviction, anomaly detection +``` + +--- + +### Task 1: Add seq_number to TransportInfo::Tcp + +**Files:** +- Modify: `src/decoder.rs` +- Modify: `tests/decoder_tests.rs` +- Modify: `tests/analyzer_tests.rs` +- Modify: `tests/summary_tests.rs` +- Modify: `tests/integration_test.rs` + +The reassembler needs the TCP sequence number from each packet. Currently `TransportInfo::Tcp` only has ports and flags. + +- [ ] **Step 1: Update TransportInfo::Tcp to include seq_number** + +In `src/decoder.rs`, change the `Tcp` variant: + +```rust +#[derive(Debug, Clone)] +pub enum TransportInfo { + Tcp { + src_port: u16, + dst_port: u16, + seq_number: u32, + syn: bool, + ack: bool, + fin: bool, + rst: bool, + }, + Udp { + src_port: u16, + dst_port: u16, + }, + None, +} +``` + +In the `decode_packet` function, update the TCP match arm: + +```rust +Some(etherparse::TransportSlice::Tcp(tcp)) => ( + Protocol::Tcp, + TransportInfo::Tcp { + src_port: tcp.source_port(), + dst_port: tcp.destination_port(), + seq_number: tcp.sequence_number(), + syn: tcp.syn(), + ack: tcp.ack(), + fin: tcp.fin(), + rst: tcp.rst(), + }, +), +``` + +- [ ] **Step 2: Fix all test files that construct TransportInfo::Tcp** + +In `tests/decoder_tests.rs`, the existing tests use pattern matching with `..` so they should still compile. Verify by running: + +Run: `cargo test --test decoder_tests` + +In `tests/analyzer_tests.rs`, update `make_non_dns_packet()`: + +```rust +fn make_non_dns_packet() -> ParsedPacket { + ParsedPacket { + src_ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + dst_ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)), + protocol: Protocol::Tcp, + transport: TransportInfo::Tcp { + src_port: 12345, + dst_port: 80, + seq_number: 1000, + syn: true, + ack: false, + fin: false, + rst: false, + }, + payload: vec![], + packet_len: 54, + } +} +``` + +In `tests/summary_tests.rs`, update `make_parsed()`: + +```rust +fn make_parsed(src: [u8; 4], dst: [u8; 4], src_port: u16, dst_port: u16) -> ParsedPacket { + ParsedPacket { + src_ip: IpAddr::V4(Ipv4Addr::from(src)), + dst_ip: IpAddr::V4(Ipv4Addr::from(dst)), + protocol: Protocol::Tcp, + transport: TransportInfo::Tcp { + src_port, + dst_port, + seq_number: 1000, + syn: false, + ack: false, + fin: false, + rst: false, + }, + payload: vec![], + packet_len: 54, + } +} +``` + +- [ ] **Step 3: Run all tests** + +Run: `cargo test` +Expected: All 19 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/decoder.rs tests/decoder_tests.rs tests/analyzer_tests.rs tests/summary_tests.rs tests/integration_test.rs +git commit -m "feat: add seq_number to TransportInfo::Tcp for reassembly" +``` + +--- + +### Task 2: StreamHandler and Flow Types (handler.rs + flow.rs) + +**Files:** +- Create: `src/reassembly/handler.rs` +- Create: `src/reassembly/flow.rs` +- Create: `src/reassembly/mod.rs` (stub) +- Modify: `src/lib.rs` +- Create: `tests/reassembly_flow_tests.rs` + +- [ ] **Step 1: Write the failing test for FlowKey canonicalization** + +Create `tests/reassembly_flow_tests.rs`: + +```rust +use std::net::{IpAddr, Ipv4Addr}; + +use wirerust::reassembly::flow::{FlowDirection, FlowKey, FlowState, TcpFlow}; +use wirerust::reassembly::handler::Direction; + +#[test] +fn test_flow_key_canonicalization() { + let ip_a = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let ip_b = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)); + + let key_ab = FlowKey::new(ip_a, 12345, ip_b, 80); + let key_ba = FlowKey::new(ip_b, 80, ip_a, 12345); + + assert_eq!(key_ab, key_ba); + assert_eq!(key_ab.lower_ip, IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))); + assert_eq!(key_ab.lower_port, 80); + assert_eq!(key_ab.upper_ip, IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2))); + assert_eq!(key_ab.upper_port, 12345); +} + +#[test] +fn test_flow_key_same_ip_different_ports() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + + let key1 = FlowKey::new(ip, 80, ip, 12345); + let key2 = FlowKey::new(ip, 12345, ip, 80); + + assert_eq!(key1, key2); + assert_eq!(key1.lower_port, 80); + assert_eq!(key1.upper_port, 12345); +} + +#[test] +fn test_flow_direction_determines_client_server() { + let ip_client = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let ip_server = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)); + + let mut flow = TcpFlow::new(FlowKey::new(ip_client, 12345, ip_server, 80), 1000); + flow.set_initiator(ip_client, 12345); + + assert_eq!( + flow.direction(ip_client, 12345), + Direction::ClientToServer + ); + assert_eq!( + flow.direction(ip_server, 80), + Direction::ServerToClient + ); +} + +#[test] +fn test_flow_state_transitions() { + let ip_a = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let ip_b = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)); + + let mut flow = TcpFlow::new(FlowKey::new(ip_a, 12345, ip_b, 80), 1000); + assert_eq!(flow.state, FlowState::New); + + flow.on_syn(); + assert_eq!(flow.state, FlowState::SynSent); + + flow.on_syn_ack(); + assert_eq!(flow.state, FlowState::Established); + + flow.on_fin(); + assert_eq!(flow.state, FlowState::Closing); + + flow.on_fin(); + assert_eq!(flow.state, FlowState::Closed); +} + +#[test] +fn test_flow_rst_from_any_state() { + let ip_a = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let ip_b = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)); + + let mut flow = TcpFlow::new(FlowKey::new(ip_a, 12345, ip_b, 80), 1000); + flow.on_syn(); + assert_eq!(flow.state, FlowState::SynSent); + + flow.on_rst(); + assert_eq!(flow.state, FlowState::Closed); +} + +#[test] +fn test_mid_stream_pickup() { + let ip_a = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let ip_b = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)); + + let mut flow = TcpFlow::new(FlowKey::new(ip_a, 12345, ip_b, 80), 1000); + flow.on_data_without_syn(); + assert_eq!(flow.state, FlowState::Established); + assert!(flow.partial); +} + +#[test] +fn test_flow_direction_new() { + let dir = FlowDirection::new(); + assert_eq!(dir.isn, None); + assert_eq!(dir.base_offset, 0); + assert!(dir.segments.is_empty()); + assert_eq!(dir.reassembled_bytes, 0); + assert!(!dir.fin_seen); + assert!(!dir.rst_seen); + assert!(!dir.depth_exceeded); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --test reassembly_flow_tests` +Expected: FAIL — module `reassembly` not found. + +- [ ] **Step 3: Create handler.rs with traits and enums** + +Create `src/reassembly/handler.rs`: + +```rust +use crate::analyzer::AnalysisSummary; +use crate::findings::Finding; +use crate::reassembly::flow::FlowKey; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + ClientToServer, + ServerToClient, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloseReason { + Fin, + Rst, + Timeout, + MemoryPressure, +} + +pub trait StreamHandler { + fn on_data( + &mut self, + flow_key: &FlowKey, + direction: Direction, + data: &[u8], + offset: u64, + ); + + fn on_flow_close(&mut self, flow_key: &FlowKey, reason: CloseReason); +} + +pub trait StreamAnalyzer: StreamHandler { + fn name(&self) -> &'static str; + fn summarize(&self) -> AnalysisSummary; + fn findings(&self) -> Vec; +} +``` + +- [ ] **Step 4: Create flow.rs with FlowKey, FlowDirection, TcpFlow, FlowState** + +Create `src/reassembly/flow.rs`: + +```rust +use std::collections::BTreeMap; +use std::net::IpAddr; + +use crate::reassembly::handler::Direction; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct FlowKey { + pub lower_ip: IpAddr, + pub lower_port: u16, + pub upper_ip: IpAddr, + pub upper_port: u16, +} + +impl FlowKey { + pub fn new(ip_a: IpAddr, port_a: u16, ip_b: IpAddr, port_b: u16) -> Self { + if (ip_a, port_a) <= (ip_b, port_b) { + FlowKey { + lower_ip: ip_a, + lower_port: port_a, + upper_ip: ip_b, + upper_port: port_b, + } + } else { + FlowKey { + lower_ip: ip_b, + lower_port: port_b, + upper_ip: ip_a, + upper_port: port_a, + } + } + } +} + +impl std::fmt::Display for FlowKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}:{} → {}:{}", + self.lower_ip, self.lower_port, self.upper_ip, self.upper_port + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlowState { + New, + SynSent, + Established, + Closing, + Closed, + TimedOut, +} + +#[derive(Debug)] +pub struct FlowDirection { + pub isn: Option, + pub base_offset: u64, + pub segments: BTreeMap>, + pub reassembled_bytes: usize, + pub overlap_count: u32, + pub small_segment_count: u32, + pub fin_seen: bool, + pub rst_seen: bool, + pub depth_exceeded: bool, +} + +impl FlowDirection { + pub fn new() -> Self { + FlowDirection { + isn: None, + base_offset: 0, + segments: BTreeMap::new(), + reassembled_bytes: 0, + overlap_count: 0, + small_segment_count: 0, + fin_seen: false, + rst_seen: false, + depth_exceeded: false, + } + } + + pub fn set_isn(&mut self, isn: u32) { + if self.isn.is_none() { + self.isn = Some(isn); + self.base_offset = 1; // ISN+1 is first data byte + } + } + + pub fn infer_isn(&mut self, first_seq: u32) { + if self.isn.is_none() { + self.isn = Some(first_seq.wrapping_sub(1)); + self.base_offset = 1; + } + } + + pub fn memory_used(&self) -> usize { + self.segments.values().map(|v| v.len()).sum() + } +} + +#[derive(Debug)] +pub struct TcpFlow { + pub key: FlowKey, + pub client_to_server: FlowDirection, + pub server_to_client: FlowDirection, + pub state: FlowState, + pub partial: bool, + pub first_seen: u32, + pub last_seen: u32, + initiator_ip: Option, + initiator_port: Option, + fin_count: u8, +} + +impl TcpFlow { + pub fn new(key: FlowKey, timestamp: u32) -> Self { + TcpFlow { + key, + client_to_server: FlowDirection::new(), + server_to_client: FlowDirection::new(), + state: FlowState::New, + partial: false, + first_seen: timestamp, + last_seen: timestamp, + initiator_ip: None, + initiator_port: None, + fin_count: 0, + } + } + + pub fn set_initiator(&mut self, ip: IpAddr, port: u16) { + if self.initiator_ip.is_none() { + self.initiator_ip = Some(ip); + self.initiator_port = Some(port); + } + } + + pub fn direction(&self, src_ip: IpAddr, src_port: u16) -> Direction { + if self.initiator_ip == Some(src_ip) && self.initiator_port == Some(src_port) { + Direction::ClientToServer + } else { + Direction::ServerToClient + } + } + + pub fn get_direction_mut(&mut self, dir: Direction) -> &mut FlowDirection { + match dir { + Direction::ClientToServer => &mut self.client_to_server, + Direction::ServerToClient => &mut self.server_to_client, + } + } + + pub fn on_syn(&mut self) { + if self.state == FlowState::New { + self.state = FlowState::SynSent; + } + } + + pub fn on_syn_ack(&mut self) { + if self.state == FlowState::SynSent || self.state == FlowState::New { + self.state = FlowState::Established; + } + } + + pub fn on_data_without_syn(&mut self) { + if self.state == FlowState::New { + self.state = FlowState::Established; + self.partial = true; + } + } + + pub fn on_fin(&mut self) { + self.fin_count += 1; + if self.fin_count >= 2 { + self.state = FlowState::Closed; + } else if self.state == FlowState::Established || self.state == FlowState::SynSent { + self.state = FlowState::Closing; + } + } + + pub fn on_rst(&mut self) { + self.state = FlowState::Closed; + } + + pub fn memory_used(&self) -> usize { + self.client_to_server.memory_used() + self.server_to_client.memory_used() + } +} +``` + +- [ ] **Step 5: Create reassembly/mod.rs stub** + +Create `src/reassembly/mod.rs`: + +```rust +pub mod flow; +pub mod handler; +``` + +- [ ] **Step 6: Add reassembly module to lib.rs** + +Update `src/lib.rs`: + +```rust +pub mod analyzer; +pub mod cli; +pub mod decoder; +pub mod findings; +pub mod reader; +pub mod reassembly; +pub mod reporter; +pub mod summary; +``` + +- [ ] **Step 7: Run tests** + +Run: `cargo test --test reassembly_flow_tests` +Expected: 7 tests PASS. + +- [ ] **Step 8: Commit** + +```bash +git add src/reassembly/ src/lib.rs tests/reassembly_flow_tests.rs +git commit -m "feat: add FlowKey, TcpFlow, FlowDirection, StreamHandler types" +``` + +--- + +### Task 3: Segment Insertion and Contiguous Flush (segment.rs) + +**Files:** +- Create: `src/reassembly/segment.rs` +- Modify: `src/reassembly/mod.rs` (add `pub mod segment;`) +- Create: `tests/reassembly_segment_tests.rs` + +- [ ] **Step 1: Write failing tests for segment operations** + +Create `tests/reassembly_segment_tests.rs`: + +```rust +use wirerust::reassembly::flow::FlowDirection; +use wirerust::reassembly::segment::{flush_contiguous, insert_segment, InsertResult}; + +#[test] +fn test_insert_single_segment() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + let result = insert_segment(&mut dir, 1001, b"hello", 10_485_760); + assert_eq!(result, InsertResult::Inserted); + assert_eq!(dir.segments.len(), 1); + assert_eq!(dir.segments.get(&1), Some(&b"hello".to_vec())); +} + +#[test] +fn test_flush_contiguous_single() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + insert_segment(&mut dir, 1001, b"hello", 10_485_760); + + let flushed = flush_contiguous(&mut dir); + assert_eq!(flushed.len(), 1); + assert_eq!(flushed[0].0, 1); // offset + assert_eq!(flushed[0].1, b"hello"); + assert_eq!(dir.base_offset, 6); // 1 + 5 + assert_eq!(dir.reassembled_bytes, 5); + assert!(dir.segments.is_empty()); +} + +#[test] +fn test_flush_contiguous_ordered() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + insert_segment(&mut dir, 1001, b"aaa", 10_485_760); + insert_segment(&mut dir, 1004, b"bbb", 10_485_760); + + let flushed = flush_contiguous(&mut dir); + assert_eq!(flushed.len(), 2); + assert_eq!(flushed[0].1, b"aaa"); + assert_eq!(flushed[1].1, b"bbb"); + assert_eq!(dir.base_offset, 7); // 1 + 3 + 3 + assert!(dir.segments.is_empty()); +} + +#[test] +fn test_out_of_order_buffering() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + // Insert segment 2 first (out of order) + insert_segment(&mut dir, 1004, b"bbb", 10_485_760); + let flushed = flush_contiguous(&mut dir); + assert!(flushed.is_empty()); // Can't flush — gap at offset 1 + + // Now insert segment 1 + insert_segment(&mut dir, 1001, b"aaa", 10_485_760); + let flushed = flush_contiguous(&mut dir); + assert_eq!(flushed.len(), 2); // Both flush now + assert_eq!(flushed[0].1, b"aaa"); + assert_eq!(flushed[1].1, b"bbb"); + assert_eq!(dir.base_offset, 7); +} + +#[test] +fn test_retransmission_dedup() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + insert_segment(&mut dir, 1001, b"hello", 10_485_760); + let result = insert_segment(&mut dir, 1001, b"hello", 10_485_760); + assert_eq!(result, InsertResult::Duplicate); + assert_eq!(dir.segments.len(), 1); // No duplicate stored +} + +#[test] +fn test_overlap_first_wins() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + // Insert "AAABBB" at offset 1 + insert_segment(&mut dir, 1001, b"AAABBB", 10_485_760); + + // Overlapping insert: "XXXCC" at offset 4 (overlaps with "BBB" at 4-6) + let result = insert_segment(&mut dir, 1004, b"XXXCC", 10_485_760); + assert_eq!(result, InsertResult::PartialOverlap); + assert_eq!(dir.overlap_count, 1); + + // Flush and verify: first 6 bytes from original, then "CC" from new + let flushed = flush_contiguous(&mut dir); + let all_bytes: Vec = flushed.iter().flat_map(|(_, data)| data.iter().copied()).collect(); + assert_eq!(&all_bytes, b"AAABBBCC"); +} + +#[test] +fn test_overlap_conflicting_data_detected() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + insert_segment(&mut dir, 1001, b"AAAA", 10_485_760); + + // Same range, different data + let result = insert_segment(&mut dir, 1001, b"BBBB", 10_485_760); + assert_eq!(result, InsertResult::ConflictingOverlap); + assert_eq!(dir.overlap_count, 1); + + // Original data preserved (first-wins) + let flushed = flush_contiguous(&mut dir); + assert_eq!(flushed[0].1, b"AAAA"); +} + +#[test] +fn test_sequence_wraparound() { + let mut dir = FlowDirection::new(); + // ISN near wraparound + dir.set_isn(0xFFFF_FFF0); + + // First data byte at ISN+1 = 0xFFFF_FFF1, offset = 1 + insert_segment(&mut dir, 0xFFFF_FFF1, b"before", 10_485_760); + // Next segment wraps: seq = 0xFFFF_FFF1 + 6 = 0xFFFF_FFF7, offset = 7 + insert_segment(&mut dir, 0xFFFF_FFF7, b"wrap", 10_485_760); + // Another after wrap: seq = 0xFFFF_FFFB, offset = 11 + insert_segment(&mut dir, 0xFFFF_FFFB, b"around", 10_485_760); + + let flushed = flush_contiguous(&mut dir); + let all_bytes: Vec = flushed.iter().flat_map(|(_, data)| data.iter().copied()).collect(); + assert_eq!(&all_bytes, b"beforewraparound"); +} + +#[test] +fn test_small_segment_tracking() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + // Insert small segments + for i in 0..5u32 { + let seq = 1001 + i; + insert_segment(&mut dir, seq, &[b'a'], 10_485_760); + } + + assert_eq!(dir.small_segment_count, 5); +} + +#[test] +fn test_depth_limit_truncation() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + let max_depth: usize = 100; // small for testing + let data = vec![b'A'; 80]; + insert_segment(&mut dir, 1001, &data, max_depth); + flush_contiguous(&mut dir); + assert_eq!(dir.reassembled_bytes, 80); + assert!(!dir.depth_exceeded); + + // This should be truncated to 20 bytes + let data2 = vec![b'B'; 50]; + let result = insert_segment(&mut dir, 1081, &data2, max_depth); + assert_eq!(result, InsertResult::Truncated); + assert!(dir.depth_exceeded); + + let flushed = flush_contiguous(&mut dir); + assert_eq!(flushed[0].1.len(), 20); // truncated from 50 to 20 + assert_eq!(dir.reassembled_bytes, 100); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --test reassembly_segment_tests` +Expected: FAIL — module `segment` not found. + +- [ ] **Step 3: Implement segment.rs** + +Create `src/reassembly/segment.rs`: + +```rust +use crate::reassembly::flow::FlowDirection; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InsertResult { + Inserted, + Duplicate, + PartialOverlap, + ConflictingOverlap, + Truncated, + DepthExceeded, +} + +/// Compute the ISN-relative offset for a sequence number. +fn seq_offset(seq: u32, isn: u32) -> u64 { + seq.wrapping_sub(isn) as u64 +} + +/// Insert a segment into the flow direction's out-of-order buffer. +/// Applies first-wins overlap policy and tracks anomaly counters. +pub fn insert_segment( + dir: &mut FlowDirection, + seq: u32, + data: &[u8], + max_depth: usize, +) -> InsertResult { + if data.is_empty() { + return InsertResult::Inserted; + } + + let isn = match dir.isn { + Some(isn) => isn, + None => return InsertResult::Inserted, // no ISN yet, skip + }; + + // Track small segments + if data.len() < 8 { + dir.small_segment_count += 1; + } else { + dir.small_segment_count = 0; // reset on normal-sized segment + } + + // Check depth limit + let remaining_depth = max_depth.saturating_sub(dir.reassembled_bytes); + if remaining_depth == 0 { + if !dir.depth_exceeded { + dir.depth_exceeded = true; + } + return InsertResult::DepthExceeded; + } + + let offset = seq_offset(seq, isn); + let mut segment_data = data.to_vec(); + + // Truncate if exceeding depth + let buffered: usize = dir.segments.values().map(|v| v.len()).sum(); + let total_after = dir.reassembled_bytes + buffered + segment_data.len(); + let truncated = if total_after > max_depth { + let allowed = max_depth.saturating_sub(dir.reassembled_bytes + buffered); + if allowed == 0 { + dir.depth_exceeded = true; + return InsertResult::DepthExceeded; + } + segment_data.truncate(allowed); + dir.depth_exceeded = true; + true + } else { + false + }; + + let new_start = offset; + let new_end = offset + segment_data.len() as u64; + + // Check for overlaps with existing segments + let mut has_overlap = false; + let mut has_conflict = false; + let mut trimmed_ranges: Vec<(u64, u64)> = Vec::new(); + + // Collect existing segment ranges that overlap + for (&existing_offset, existing_data) in dir.segments.iter() { + let existing_end = existing_offset + existing_data.len() as u64; + + if new_start < existing_end && new_end > existing_offset { + // Overlap detected + has_overlap = true; + + // Check if overlapping region has different data (conflict) + let overlap_start = new_start.max(existing_offset); + let overlap_end = new_end.min(existing_end); + + for pos in overlap_start..overlap_end { + let new_idx = (pos - new_start) as usize; + let existing_idx = (pos - existing_offset) as usize; + if new_idx < segment_data.len() + && existing_idx < existing_data.len() + && segment_data[new_idx] != existing_data[existing_idx] + { + has_conflict = true; + break; + } + } + + trimmed_ranges.push((existing_offset, existing_end)); + } + } + + if has_overlap { + dir.overlap_count += 1; + + // Check if fully covered (duplicate/retransmission) + let fully_covered = trimmed_ranges.iter().any(|&(es, ee)| es <= new_start && ee >= new_end); + if fully_covered { + return if has_conflict { + InsertResult::ConflictingOverlap + } else { + InsertResult::Duplicate + }; + } + + // First-wins: trim new segment to only cover gaps + // Build list of gap regions within [new_start, new_end) + let mut gaps: Vec<(u64, u64)> = Vec::new(); + let mut cursor = new_start; + + // Sort existing overlapping ranges + let mut sorted_ranges = trimmed_ranges.clone(); + sorted_ranges.sort_by_key(|&(start, _)| start); + + for &(es, ee) in &sorted_ranges { + if cursor < es { + gaps.push((cursor, es.min(new_end))); + } + cursor = cursor.max(ee); + } + if cursor < new_end { + gaps.push((cursor, new_end)); + } + + // Insert only gap portions + for (gap_start, gap_end) in gaps { + let start_idx = (gap_start - new_start) as usize; + let end_idx = (gap_end - new_start) as usize; + if start_idx < segment_data.len() && end_idx <= segment_data.len() { + let gap_data = segment_data[start_idx..end_idx].to_vec(); + if !gap_data.is_empty() { + dir.segments.insert(gap_start, gap_data); + } + } + } + + return if has_conflict { + InsertResult::ConflictingOverlap + } else if truncated { + InsertResult::Truncated + } else { + InsertResult::PartialOverlap + }; + } + + // No overlap — insert normally + dir.segments.insert(offset, segment_data); + + if truncated { + InsertResult::Truncated + } else { + InsertResult::Inserted + } +} + +/// Flush contiguous segments starting from base_offset. +/// Returns Vec of (offset, data) pairs that were flushed. +pub fn flush_contiguous(dir: &mut FlowDirection) -> Vec<(u64, Vec)> { + let mut flushed = Vec::new(); + + loop { + if let Some(data) = dir.segments.remove(&dir.base_offset) { + let offset = dir.base_offset; + dir.base_offset += data.len() as u64; + dir.reassembled_bytes += data.len(); + flushed.push((offset, data)); + } else { + break; + } + } + + flushed +} +``` + +- [ ] **Step 4: Add segment module to reassembly/mod.rs** + +Update `src/reassembly/mod.rs`: + +```rust +pub mod flow; +pub mod handler; +pub mod segment; +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test --test reassembly_segment_tests` +Expected: 10 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/reassembly/segment.rs src/reassembly/mod.rs tests/reassembly_segment_tests.rs +git commit -m "feat: add segment insertion with first-wins overlap and contiguous flush" +``` + +--- + +### Task 4: TcpReassembler Engine (mod.rs) + +**Files:** +- Modify: `src/reassembly/mod.rs` +- Create: `tests/reassembly_engine_tests.rs` + +- [ ] **Step 1: Write failing tests for the engine** + +Create `tests/reassembly_engine_tests.rs`: + +```rust +use std::net::{IpAddr, Ipv4Addr}; + +use wirerust::decoder::{ParsedPacket, Protocol, TransportInfo}; +use wirerust::findings::Finding; +use wirerust::reassembly::flow::FlowKey; +use wirerust::reassembly::handler::{CloseReason, Direction, StreamHandler}; +use wirerust::reassembly::mod_public::{ReassemblyConfig, ReassemblyStats, TcpReassembler}; + +/// Test handler that records all callbacks. +struct RecordingHandler { + data_events: Vec<(FlowKey, Direction, Vec, u64)>, + close_events: Vec<(FlowKey, CloseReason)>, +} + +impl RecordingHandler { + fn new() -> Self { + RecordingHandler { + data_events: Vec::new(), + close_events: Vec::new(), + } + } + + fn all_data(&self) -> Vec { + self.data_events + .iter() + .flat_map(|(_, _, data, _)| data.iter().copied()) + .collect() + } +} + +impl StreamHandler for RecordingHandler { + fn on_data( + &mut self, + flow_key: &FlowKey, + direction: Direction, + data: &[u8], + offset: u64, + ) { + self.data_events + .push((flow_key.clone(), direction, data.to_vec(), offset)); + } + + fn on_flow_close(&mut self, flow_key: &FlowKey, reason: CloseReason) { + self.close_events.push((flow_key.clone(), reason)); + } +} + +fn make_tcp_packet( + src_ip: [u8; 4], + src_port: u16, + dst_ip: [u8; 4], + dst_port: u16, + seq: u32, + payload: &[u8], + syn: bool, + fin: bool, + rst: bool, +) -> ParsedPacket { + ParsedPacket { + src_ip: IpAddr::V4(Ipv4Addr::from(src_ip)), + dst_ip: IpAddr::V4(Ipv4Addr::from(dst_ip)), + protocol: Protocol::Tcp, + transport: TransportInfo::Tcp { + src_port, + dst_port, + seq_number: seq, + syn, + ack: false, + fin, + rst, + }, + payload: payload.to_vec(), + packet_len: 54 + payload.len(), + } +} + +#[test] +fn test_three_packet_stream_ordered() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // SYN + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + reassembler.process_packet(&syn, 1, &mut handler); + + // Data packets + let p1 = make_tcp_packet(client, 12345, server, 80, 1001, b"aaa", false, false, false); + reassembler.process_packet(&p1, 2, &mut handler); + + let p2 = make_tcp_packet(client, 12345, server, 80, 1004, b"bbb", false, false, false); + reassembler.process_packet(&p2, 3, &mut handler); + + let p3 = make_tcp_packet(client, 12345, server, 80, 1007, b"ccc", false, false, false); + reassembler.process_packet(&p3, 4, &mut handler); + + assert_eq!(handler.all_data(), b"aaabbbccc"); + assert_eq!(handler.data_events.len(), 3); +} + +#[test] +fn test_out_of_order_delivery() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // SYN + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + reassembler.process_packet(&syn, 1, &mut handler); + + // Send packets [1, 3, 2] + let p1 = make_tcp_packet(client, 12345, server, 80, 1001, b"aaa", false, false, false); + reassembler.process_packet(&p1, 2, &mut handler); + + let p3 = make_tcp_packet(client, 12345, server, 80, 1007, b"ccc", false, false, false); + reassembler.process_packet(&p3, 3, &mut handler); + assert_eq!(handler.data_events.len(), 1); // only p1 flushed + + let p2 = make_tcp_packet(client, 12345, server, 80, 1004, b"bbb", false, false, false); + reassembler.process_packet(&p2, 4, &mut handler); + + // Now all three should be flushed + assert_eq!(handler.all_data(), b"aaabbbccc"); +} + +#[test] +fn test_mid_stream_no_syn() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // Data without SYN + let p1 = make_tcp_packet(client, 12345, server, 80, 5000, b"hello", false, false, false); + reassembler.process_packet(&p1, 1, &mut handler); + + assert_eq!(handler.all_data(), b"hello"); + + let stats = reassembler.stats(); + assert_eq!(stats.flows_total, 1); + assert_eq!(stats.flows_partial, 1); +} + +#[test] +fn test_rst_closes_flow() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + reassembler.process_packet(&syn, 1, &mut handler); + + let data = make_tcp_packet(client, 12345, server, 80, 1001, b"data", false, false, false); + reassembler.process_packet(&data, 2, &mut handler); + + let rst = make_tcp_packet(server, 80, client, 12345, 2000, &[], false, false, true); + reassembler.process_packet(&rst, 3, &mut handler); + + assert_eq!(handler.close_events.len(), 1); + assert_eq!(handler.close_events[0].1, CloseReason::Rst); +} + +#[test] +fn test_finalize_flushes_remaining() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + reassembler.process_packet(&syn, 1, &mut handler); + + let data = make_tcp_packet(client, 12345, server, 80, 1001, b"leftover", false, false, false); + reassembler.process_packet(&data, 2, &mut handler); + + // Finalize — should close all flows + reassembler.finalize(&mut handler); + + assert_eq!(handler.close_events.len(), 1); + assert_eq!(handler.close_events[0].1, CloseReason::Timeout); +} + +#[test] +fn test_flow_timeout_expiration() { + let config = ReassemblyConfig { + flow_timeout_secs: 10, + ..ReassemblyConfig::default() + }; + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + reassembler.process_packet(&syn, 100, &mut handler); + + // Expire at time 200 (100 seconds later, > 10s timeout) + reassembler.expire_flows(200, &mut handler); + + assert_eq!(handler.close_events.len(), 1); + assert_eq!(handler.close_events[0].1, CloseReason::Timeout); + + let stats = reassembler.stats(); + assert_eq!(stats.flows_expired, 1); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --test reassembly_engine_tests` +Expected: FAIL — `mod_public` not found (we need to expose TcpReassembler from mod.rs). + +- [ ] **Step 3: Implement TcpReassembler in mod.rs** + +Replace `src/reassembly/mod.rs` with: + +```rust +pub mod flow; +pub mod handler; +pub mod segment; + +use std::collections::HashMap; + +use crate::decoder::{ParsedPacket, Protocol, TransportInfo}; +use crate::findings::{Confidence, Finding, ThreatCategory, Verdict}; +use crate::reassembly::flow::{FlowKey, FlowState, TcpFlow}; +use crate::reassembly::handler::{CloseReason, Direction, StreamHandler}; +use crate::reassembly::segment::{flush_contiguous, insert_segment, InsertResult}; + +#[derive(Debug, Clone)] +pub struct ReassemblyConfig { + pub max_depth_per_direction: usize, + pub global_memcap: usize, + pub flow_timeout_secs: u32, +} + +impl Default for ReassemblyConfig { + fn default() -> Self { + ReassemblyConfig { + max_depth_per_direction: 10_485_760, // 10MB + global_memcap: 1_073_741_824, // 1GB + flow_timeout_secs: 300, // 5 min + } + } +} + +#[derive(Debug, Default)] +pub struct ReassemblyStats { + pub flows_total: u64, + pub flows_partial: u64, + pub flows_expired: u64, + pub flows_evicted: u64, + pub packets_processed: u64, + pub packets_skipped: u64, + pub depth_exceeded_count: u64, + pub memcap_exceeded: bool, +} + +pub struct TcpReassembler { + flows: HashMap, + config: ReassemblyConfig, + total_memory_used: usize, + stats: ReassemblyStats, + findings: Vec, +} + +impl TcpReassembler { + pub fn new(config: ReassemblyConfig) -> Self { + TcpReassembler { + flows: HashMap::new(), + config, + total_memory_used: 0, + stats: ReassemblyStats::default(), + findings: Vec::new(), + } + } + + pub fn process_packet( + &mut self, + packet: &ParsedPacket, + timestamp: u32, + handler: &mut dyn StreamHandler, + ) { + // Only process TCP packets + let (src_port, dst_port, seq_number, syn, fin, rst) = match &packet.transport { + TransportInfo::Tcp { + src_port, + dst_port, + seq_number, + syn, + fin, + rst, + .. + } => (*src_port, *dst_port, *seq_number, *syn, *fin, *rst), + _ => { + self.stats.packets_skipped += 1; + return; + } + }; + + self.stats.packets_processed += 1; + + let key = FlowKey::new(packet.src_ip, src_port, packet.dst_ip, dst_port); + + // Get or create flow + let is_new_flow = !self.flows.contains_key(&key); + let flow = self.flows.entry(key.clone()).or_insert_with(|| { + let mut f = TcpFlow::new(key.clone(), timestamp); + self.stats.flows_total += 1; + f + }); + flow.last_seen = timestamp; + + // Determine direction and handle flags + if syn && !flow.client_to_server.fin_seen { + flow.set_initiator(packet.src_ip, src_port); + if rst { + // SYN+RST is weird, ignore + } else if flow.state == FlowState::SynSent { + // SYN+ACK (server response) + flow.on_syn_ack(); + flow.server_to_client.set_isn(seq_number); + } else { + // SYN (client initiating) + flow.on_syn(); + flow.client_to_server.set_isn(seq_number); + } + } + + if rst { + flow.on_rst(); + handler.on_flow_close(&flow.key, CloseReason::Rst); + return; + } + + if fin { + let dir = flow.direction(packet.src_ip, src_port); + flow.get_direction_mut(dir).fin_seen = true; + flow.on_fin(); + } + + // Process payload + if !packet.payload.is_empty() { + if flow.state == FlowState::New { + // Mid-stream pickup + flow.set_initiator(packet.src_ip, src_port); + flow.on_data_without_syn(); + self.stats.flows_partial += 1; + } + + let dir = flow.direction(packet.src_ip, src_port); + let flow_dir = flow.get_direction_mut(dir); + + // Infer ISN if not set (mid-stream) + if flow_dir.isn.is_none() { + flow_dir.infer_isn(seq_number); + } + + // Check memcap before inserting + if self.total_memory_used + packet.payload.len() > self.config.global_memcap { + self.evict_flows(timestamp, handler); + self.stats.memcap_exceeded = true; + eprintln!( + "Warning: reassembly memory cap reached, evicting flows. \ + Re-run with --reassembly-memcap to increase." + ); + } + + let result = insert_segment( + flow_dir, + seq_number, + &packet.payload, + self.config.max_depth_per_direction, + ); + + // Generate findings for anomalies + match result { + InsertResult::ConflictingOverlap => { + self.findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Likely, + confidence: Confidence::High, + summary: format!( + "Conflicting data in overlapping TCP segments on flow {}", + flow.key + ), + evidence: vec!["Possible insertion/evasion attack".into()], + mitre_technique: Some("T1036".into()), + source_ip: Some(packet.src_ip), + timestamp: None, + }); + } + InsertResult::Truncated => { + self.stats.depth_exceeded_count += 1; + self.findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Inconclusive, + confidence: Confidence::Low, + summary: format!( + "Flow {} exceeded reassembly depth ({}MB), stream truncated", + flow.key, + self.config.max_depth_per_direction / 1_048_576, + ), + evidence: vec![], + mitre_technique: None, + source_ip: None, + timestamp: None, + }); + } + InsertResult::DepthExceeded => { + // Already counted + } + _ => {} + } + + // Check overlap count threshold + if flow_dir.overlap_count == 51 { + self.findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Likely, + confidence: Confidence::Medium, + summary: format!( + "Excessive TCP segment overlaps on flow {} ({} overlaps)", + flow.key, flow_dir.overlap_count + ), + evidence: vec!["Possible evasion attempt".into()], + mitre_technique: Some("T1036".into()), + source_ip: Some(packet.src_ip), + timestamp: None, + }); + } + + // Check small segment flood + if flow_dir.small_segment_count == 2049 { + self.findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Inconclusive, + confidence: Confidence::Medium, + summary: format!( + "Excessive small TCP segments on flow {} ({} segments <8 bytes)", + flow.key, flow_dir.small_segment_count + ), + evidence: vec!["Possible IDS evasion".into()], + mitre_technique: None, + source_ip: Some(packet.src_ip), + timestamp: None, + }); + } + + // Flush contiguous data + let flushed = flush_contiguous(flow_dir); + let dir_enum = dir; + for (offset, data) in &flushed { + self.total_memory_used = self.total_memory_used.saturating_sub(data.len()); + handler.on_data(&flow.key, dir_enum, data, *offset); + } + + // Track memory added by non-flushed segments + self.total_memory_used = self + .flows + .values() + .map(|f| f.memory_used()) + .sum(); + } + } + + pub fn expire_flows(&mut self, current_time: u32, handler: &mut dyn StreamHandler) { + let timeout = self.config.flow_timeout_secs; + let expired_keys: Vec = self + .flows + .iter() + .filter(|(_, flow)| { + flow.state != FlowState::Closed + && current_time.saturating_sub(flow.last_seen) > timeout + }) + .map(|(key, _)| key.clone()) + .collect(); + + for key in expired_keys { + if let Some(mut flow) = self.flows.remove(&key) { + flow.state = FlowState::TimedOut; + self.total_memory_used = self.total_memory_used.saturating_sub(flow.memory_used()); + handler.on_flow_close(&flow.key, CloseReason::Timeout); + self.stats.flows_expired += 1; + } + } + } + + pub fn finalize(&mut self, handler: &mut dyn StreamHandler) { + let all_keys: Vec = self.flows.keys().cloned().collect(); + for key in all_keys { + if let Some(flow) = self.flows.remove(&key) { + self.total_memory_used = self.total_memory_used.saturating_sub(flow.memory_used()); + handler.on_flow_close(&flow.key, CloseReason::Timeout); + } + } + } + + pub fn stats(&self) -> &ReassemblyStats { + &self.stats + } + + pub fn findings(&self) -> &[Finding] { + &self.findings + } + + fn evict_flows(&mut self, current_time: u32, handler: &mut dyn StreamHandler) { + // First evict non-established flows + let non_established: Vec = self + .flows + .iter() + .filter(|(_, f)| { + f.state != FlowState::Established && f.state != FlowState::Closing + }) + .map(|(k, _)| k.clone()) + .collect(); + + for key in non_established { + if let Some(flow) = self.flows.remove(&key) { + self.total_memory_used = self.total_memory_used.saturating_sub(flow.memory_used()); + handler.on_flow_close(&flow.key, CloseReason::MemoryPressure); + self.stats.flows_evicted += 1; + } + if self.total_memory_used < self.config.global_memcap { + return; + } + } + + // Then evict LRU established flows + let mut by_last_seen: Vec<(FlowKey, u32)> = self + .flows + .iter() + .map(|(k, f)| (k.clone(), f.last_seen)) + .collect(); + by_last_seen.sort_by_key(|(_, ts)| *ts); + + for (key, _) in by_last_seen { + if self.total_memory_used < self.config.global_memcap { + return; + } + if let Some(flow) = self.flows.remove(&key) { + self.total_memory_used = self.total_memory_used.saturating_sub(flow.memory_used()); + handler.on_flow_close(&flow.key, CloseReason::MemoryPressure); + self.stats.flows_evicted += 1; + } + } + } +} +``` + +- [ ] **Step 4: Fix test imports** + +The test uses `mod_public` which was a placeholder. The actual types are at `wirerust::reassembly::{ReassemblyConfig, ReassemblyStats, TcpReassembler}`. Update the import in `tests/reassembly_engine_tests.rs`: + +Replace: +```rust +use wirerust::reassembly::mod_public::{ReassemblyConfig, ReassemblyStats, TcpReassembler}; +``` +With: +```rust +use wirerust::reassembly::{ReassemblyConfig, ReassemblyStats, TcpReassembler}; +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test --test reassembly_engine_tests` +Expected: 6 tests PASS. + +Run: `cargo test` +Expected: All tests pass (existing + new). + +- [ ] **Step 6: Commit** + +```bash +git add src/reassembly/mod.rs tests/reassembly_engine_tests.rs +git commit -m "feat: add TcpReassembler engine with memcap, depth limits, and anomaly detection" +``` + +--- + +### Task 5: CLI Flags and main.rs Integration + +**Files:** +- Modify: `src/cli.rs` +- Modify: `src/main.rs` +- Modify: `tests/cli_tests.rs` + +- [ ] **Step 1: Add reassembly CLI flags** + +In `src/cli.rs`, add to the `Cli` struct: + +```rust +/// Force TCP stream reassembly on +#[arg(long, global = true)] +pub reassemble: bool, + +/// Force TCP stream reassembly off (quick scan) +#[arg(long, global = true)] +pub no_reassemble: bool, + +/// Per-direction stream reassembly limit in MB (default: 10) +#[arg(long, global = true, default_value_t = 10)] +pub reassembly_depth: usize, + +/// Global reassembly memory cap in MB (default: 1024) +#[arg(long, global = true, default_value_t = 1024)] +pub reassembly_memcap: usize, +``` + +- [ ] **Step 2: Add CLI test for reassembly flags** + +Add to `tests/cli_tests.rs`: + +```rust +#[test] +fn test_reassembly_flags() { + let cli = Cli::parse_from([ + "wirerust", + "analyze", + "test.pcap", + "--reassemble", + "--reassembly-depth", + "20", + "--reassembly-memcap", + "2048", + ]); + assert!(cli.reassemble); + assert_eq!(cli.reassembly_depth, 20); + assert_eq!(cli.reassembly_memcap, 2048); +} + +#[test] +fn test_no_reassemble_flag() { + let cli = Cli::parse_from(["wirerust", "analyze", "test.pcap", "--no-reassemble"]); + assert!(cli.no_reassemble); +} +``` + +- [ ] **Step 3: Wire reassembler into main.rs** + +Update `src/main.rs` — add imports and modify `run_analyze`: + +Add to imports: +```rust +use wirerust::reassembly::{ReassemblyConfig, TcpReassembler}; +use wirerust::reassembly::handler::StreamHandler; +``` + +Update `run_analyze` to create and use the reassembler: + +```rust +fn run_analyze( + targets: &[std::path::PathBuf], + enable_dns: bool, + use_color: bool, + cli: &Cli, +) -> Result<()> { + let mut summary = Summary::new(); + let mut dns_analyzer = DnsAnalyzer::new(); + let mut all_findings = Vec::new(); + + // Determine if reassembly is needed + let needs_reassembly = cli.reassemble; // Will expand when HTTP/TLS analyzers added + let skip_reassembly = cli.no_reassemble; + + let mut reassembler = if needs_reassembly && !skip_reassembly { + let config = ReassemblyConfig { + max_depth_per_direction: cli.reassembly_depth * 1_048_576, + global_memcap: cli.reassembly_memcap * 1_048_576, + ..ReassemblyConfig::default() + }; + Some(TcpReassembler::new(config)) + } else { + None + }; + + // Placeholder handler for now — will be replaced by actual stream analyzers + struct NullHandler; + impl StreamHandler for NullHandler { + fn on_data(&mut self, _: &wirerust::reassembly::flow::FlowKey, _: wirerust::reassembly::handler::Direction, _: &[u8], _: u64) {} + fn on_flow_close(&mut self, _: &wirerust::reassembly::flow::FlowKey, _: wirerust::reassembly::handler::CloseReason) {} + } + let mut stream_handler = NullHandler; + + for target in targets { + let pcap_files = resolve_targets(target)?; + for path in &pcap_files { + let source = PcapSource::from_file(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + + let pb = ProgressBar::new(source.packets.len() as u64); + pb.set_style(ProgressStyle::with_template( + "[{elapsed_precise}] {bar:40} {pos}/{len} packets", + )?); + + for raw in &source.packets { + if let Ok(parsed) = decode_packet(&raw.data) { + summary.ingest(&parsed); + + if enable_dns && dns_analyzer.can_decode(&parsed) { + let findings = dns_analyzer.analyze(&parsed); + all_findings.extend(findings); + } + + if let Some(ref mut reasm) = reassembler { + reasm.process_packet(&parsed, raw.timestamp_secs, &mut stream_handler); + } + } + pb.inc(1); + } + pb.finish_and_clear(); + } + } + + // Finalize reassembler + if let Some(ref mut reasm) = reassembler { + reasm.finalize(&mut stream_handler); + all_findings.extend(reasm.findings().to_vec()); + } + + let analyzer_summaries = if enable_dns { + vec![dns_analyzer.summarize()] + } else { + vec![] + }; + + let output = match cli.output_format { + Some(OutputFormat::Json) => { + let reporter = JsonReporter; + reporter.render(&summary, &all_findings, &analyzer_summaries) + } + _ => { + let reporter = TerminalReporter { use_color }; + reporter.render(&summary, &all_findings, &analyzer_summaries) + } + }; + + println!("{output}"); + Ok(()) +} +``` + +- [ ] **Step 4: Run all tests** + +Run: `cargo test` +Expected: All tests pass. + +Run: `cargo clippy --all-targets -- -D warnings` +Expected: No errors. + +Run: `cargo run -- --help` +Expected: Shows `--reassemble`, `--no-reassemble`, `--reassembly-depth`, `--reassembly-memcap` flags. + +- [ ] **Step 5: Commit** + +```bash +git add src/cli.rs src/main.rs tests/cli_tests.rs +git commit -m "feat: wire TCP reassembler into CLI and analyze pipeline" +``` + +--- + +### Task 6: Final Validation and Push + +- [ ] **Step 1: Run full test suite** + +Run: `cargo test` +Expected: All tests pass. + +Run: `cargo clippy --all-targets -- -D warnings` +Expected: No errors. + +Run: `cargo fmt --all --check` +Expected: No formatting issues. + +- [ ] **Step 2: Push branch** + +```bash +git push -u origin feature/tcp-reassembly +``` + +- [ ] **Step 3: Create PR** + +```bash +gh pr create --repo Zious11/wirerust --base develop --title "feat: add TCP stream reassembly engine" --body "$(cat <<'EOF' +## Summary +- Forensic-grade TCP stream reassembly module (`src/reassembly/`) +- FlowKey canonicalization, ISN-relative u64 offsets, BTreeMap segment storage +- First-wins overlap policy with anomaly detection (conflicting data, excessive overlaps, small segment floods) +- Configurable depth limit (10MB/direction) and global memcap (1GB) with LRU eviction +- Mid-stream pickup (missing SYN) with partial flow flagging +- Incremental stream delivery via StreamHandler callbacks +- CLI flags: `--reassemble`, `--no-reassemble`, `--reassembly-depth`, `--reassembly-memcap` + +Closes: n/a (infrastructure for #1, #2) + +## Test plan +- [ ] `cargo test` — all tests pass +- [ ] `cargo clippy -- -D warnings` — clean +- [ ] `cargo fmt --check` — clean +- [ ] Segment tests: ordered, out-of-order, overlap, retransmit, wraparound, depth truncation +- [ ] Engine tests: three-packet stream, OOO delivery, mid-stream, RST, finalize, timeout +- [ ] Flow tests: canonicalization, state transitions, direction detection +EOF +)" +``` From abc6cf2ea70e7943a0c940054a7e6db0d013d42a Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:01:24 -0500 Subject: [PATCH 04/18] fix: correct etherparse API - use tcp.header().sequence_number() --- docs/superpowers/plans/2026-04-06-tcp-reassembly.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-04-06-tcp-reassembly.md b/docs/superpowers/plans/2026-04-06-tcp-reassembly.md index 3fdff5e..4115840 100644 --- a/docs/superpowers/plans/2026-04-06-tcp-reassembly.md +++ b/docs/superpowers/plans/2026-04-06-tcp-reassembly.md @@ -75,7 +75,7 @@ Some(etherparse::TransportSlice::Tcp(tcp)) => ( TransportInfo::Tcp { src_port: tcp.source_port(), dst_port: tcp.destination_port(), - seq_number: tcp.sequence_number(), + seq_number: tcp.header().sequence_number(), syn: tcp.syn(), ack: tcp.ack(), fin: tcp.fin(), From 44c6b5ef12b426de174544b580d46fa8415a0ae0 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:03:28 -0500 Subject: [PATCH 05/18] feat: add seq_number to TransportInfo::Tcp for reassembly Add seq_number: u32 field to TransportInfo::Tcp variant and populate it from tcp.to_header().sequence_number during packet decoding. Update test fixtures in analyzer_tests.rs and summary_tests.rs to include the new field. --- src/decoder.rs | 2 ++ tests/analyzer_tests.rs | 1 + tests/summary_tests.rs | 1 + 3 files changed, 4 insertions(+) diff --git a/src/decoder.rs b/src/decoder.rs index b675ddb..2f0ac2c 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -17,6 +17,7 @@ pub enum TransportInfo { Tcp { src_port: u16, dst_port: u16, + seq_number: u32, syn: bool, ack: bool, fin: bool, @@ -95,6 +96,7 @@ pub fn decode_packet(data: &[u8]) -> Result { TransportInfo::Tcp { src_port: tcp.source_port(), dst_port: tcp.destination_port(), + seq_number: tcp.to_header().sequence_number, syn: tcp.syn(), ack: tcp.ack(), fin: tcp.fin(), diff --git a/tests/analyzer_tests.rs b/tests/analyzer_tests.rs index 3512c47..77d47b9 100644 --- a/tests/analyzer_tests.rs +++ b/tests/analyzer_tests.rs @@ -26,6 +26,7 @@ fn make_non_dns_packet() -> ParsedPacket { transport: TransportInfo::Tcp { src_port: 12345, dst_port: 80, + seq_number: 1000, syn: true, ack: false, fin: false, diff --git a/tests/summary_tests.rs b/tests/summary_tests.rs index c938310..d8c8f4f 100644 --- a/tests/summary_tests.rs +++ b/tests/summary_tests.rs @@ -11,6 +11,7 @@ fn make_parsed(src: [u8; 4], dst: [u8; 4], src_port: u16, dst_port: u16) -> Pars transport: TransportInfo::Tcp { src_port, dst_port, + seq_number: 1000, syn: false, ack: false, fin: false, From 09270ea13a1d3726ebd419bcf1e6fd2a3d7814c6 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:06:41 -0500 Subject: [PATCH 06/18] feat: add FlowKey, TcpFlow, FlowDirection, StreamHandler types --- src/lib.rs | 1 + src/reassembly/flow.rs | 192 +++++++++++++++++++++++++++++++++ src/reassembly/handler.rs | 35 ++++++ src/reassembly/mod.rs | 2 + tests/reassembly_flow_tests.rs | 100 +++++++++++++++++ 5 files changed, 330 insertions(+) create mode 100644 src/reassembly/flow.rs create mode 100644 src/reassembly/handler.rs create mode 100644 src/reassembly/mod.rs create mode 100644 tests/reassembly_flow_tests.rs diff --git a/src/lib.rs b/src/lib.rs index 3b23055..9dac35d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,5 +3,6 @@ pub mod cli; pub mod decoder; pub mod findings; pub mod reader; +pub mod reassembly; pub mod reporter; pub mod summary; diff --git a/src/reassembly/flow.rs b/src/reassembly/flow.rs new file mode 100644 index 0000000..2c0a17f --- /dev/null +++ b/src/reassembly/flow.rs @@ -0,0 +1,192 @@ +use std::collections::BTreeMap; +use std::net::IpAddr; + +use crate::reassembly::handler::Direction; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct FlowKey { + pub lower_ip: IpAddr, + pub lower_port: u16, + pub upper_ip: IpAddr, + pub upper_port: u16, +} + +impl FlowKey { + pub fn new(ip_a: IpAddr, port_a: u16, ip_b: IpAddr, port_b: u16) -> Self { + // Canonicalize independently: lower_ip = min(ip_a, ip_b), lower_port = min(port_a, port_b), + // upper_ip = max(ip_a, ip_b), upper_port = max(port_a, port_b). + // This ensures the same FlowKey regardless of which direction the packet arrives from. + let (lower_ip, upper_ip) = if ip_a <= ip_b { + (ip_a, ip_b) + } else { + (ip_b, ip_a) + }; + let (lower_port, upper_port) = if port_a <= port_b { + (port_a, port_b) + } else { + (port_b, port_a) + }; + FlowKey { + lower_ip, + lower_port, + upper_ip, + upper_port, + } + } +} + +impl std::fmt::Display for FlowKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}:{} → {}:{}", + self.lower_ip, self.lower_port, self.upper_ip, self.upper_port + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlowState { + New, + SynSent, + Established, + Closing, + Closed, + TimedOut, +} + +#[derive(Debug)] +pub struct FlowDirection { + pub isn: Option, + pub base_offset: u64, + pub segments: BTreeMap>, + pub reassembled_bytes: usize, + pub overlap_count: u32, + pub small_segment_count: u32, + pub fin_seen: bool, + pub rst_seen: bool, + pub depth_exceeded: bool, +} + +impl FlowDirection { + pub fn new() -> Self { + FlowDirection { + isn: None, + base_offset: 0, + segments: BTreeMap::new(), + reassembled_bytes: 0, + overlap_count: 0, + small_segment_count: 0, + fin_seen: false, + rst_seen: false, + depth_exceeded: false, + } + } + + pub fn set_isn(&mut self, isn: u32) { + if self.isn.is_none() { + self.isn = Some(isn); + self.base_offset = 1; // ISN+1 is first data byte + } + } + + pub fn infer_isn(&mut self, first_seq: u32) { + if self.isn.is_none() { + self.isn = Some(first_seq.wrapping_sub(1)); + self.base_offset = 1; + } + } + + pub fn memory_used(&self) -> usize { + self.segments.values().map(|v| v.len()).sum() + } +} + +#[derive(Debug)] +pub struct TcpFlow { + pub key: FlowKey, + pub client_to_server: FlowDirection, + pub server_to_client: FlowDirection, + pub state: FlowState, + pub partial: bool, + pub first_seen: u32, + pub last_seen: u32, + initiator_ip: Option, + initiator_port: Option, + fin_count: u8, +} + +impl TcpFlow { + pub fn new(key: FlowKey, timestamp: u32) -> Self { + TcpFlow { + key, + client_to_server: FlowDirection::new(), + server_to_client: FlowDirection::new(), + state: FlowState::New, + partial: false, + first_seen: timestamp, + last_seen: timestamp, + initiator_ip: None, + initiator_port: None, + fin_count: 0, + } + } + + pub fn set_initiator(&mut self, ip: IpAddr, port: u16) { + if self.initiator_ip.is_none() { + self.initiator_ip = Some(ip); + self.initiator_port = Some(port); + } + } + + pub fn direction(&self, src_ip: IpAddr, src_port: u16) -> Direction { + if self.initiator_ip == Some(src_ip) && self.initiator_port == Some(src_port) { + Direction::ClientToServer + } else { + Direction::ServerToClient + } + } + + pub fn get_direction_mut(&mut self, dir: Direction) -> &mut FlowDirection { + match dir { + Direction::ClientToServer => &mut self.client_to_server, + Direction::ServerToClient => &mut self.server_to_client, + } + } + + pub fn on_syn(&mut self) { + if self.state == FlowState::New { + self.state = FlowState::SynSent; + } + } + + pub fn on_syn_ack(&mut self) { + if self.state == FlowState::SynSent || self.state == FlowState::New { + self.state = FlowState::Established; + } + } + + pub fn on_data_without_syn(&mut self) { + if self.state == FlowState::New { + self.state = FlowState::Established; + self.partial = true; + } + } + + pub fn on_fin(&mut self) { + self.fin_count += 1; + if self.fin_count >= 2 { + self.state = FlowState::Closed; + } else if self.state == FlowState::Established || self.state == FlowState::SynSent { + self.state = FlowState::Closing; + } + } + + pub fn on_rst(&mut self) { + self.state = FlowState::Closed; + } + + pub fn memory_used(&self) -> usize { + self.client_to_server.memory_used() + self.server_to_client.memory_used() + } +} diff --git a/src/reassembly/handler.rs b/src/reassembly/handler.rs new file mode 100644 index 0000000..061feba --- /dev/null +++ b/src/reassembly/handler.rs @@ -0,0 +1,35 @@ +use crate::analyzer::AnalysisSummary; +use crate::findings::Finding; +use crate::reassembly::flow::FlowKey; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + ClientToServer, + ServerToClient, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloseReason { + Fin, + Rst, + Timeout, + MemoryPressure, +} + +pub trait StreamHandler { + fn on_data( + &mut self, + flow_key: &FlowKey, + direction: Direction, + data: &[u8], + offset: u64, + ); + + fn on_flow_close(&mut self, flow_key: &FlowKey, reason: CloseReason); +} + +pub trait StreamAnalyzer: StreamHandler { + fn name(&self) -> &'static str; + fn summarize(&self) -> AnalysisSummary; + fn findings(&self) -> Vec; +} diff --git a/src/reassembly/mod.rs b/src/reassembly/mod.rs new file mode 100644 index 0000000..c0e61ae --- /dev/null +++ b/src/reassembly/mod.rs @@ -0,0 +1,2 @@ +pub mod flow; +pub mod handler; diff --git a/tests/reassembly_flow_tests.rs b/tests/reassembly_flow_tests.rs new file mode 100644 index 0000000..c34b65f --- /dev/null +++ b/tests/reassembly_flow_tests.rs @@ -0,0 +1,100 @@ +use std::net::{IpAddr, Ipv4Addr}; + +use wirerust::reassembly::flow::{FlowDirection, FlowKey, FlowState, TcpFlow}; +use wirerust::reassembly::handler::Direction; + +#[test] +fn test_flow_key_canonicalization() { + let ip_a = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let ip_b = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)); + + let key_ab = FlowKey::new(ip_a, 12345, ip_b, 80); + let key_ba = FlowKey::new(ip_b, 80, ip_a, 12345); + + assert_eq!(key_ab, key_ba); + assert_eq!(key_ab.lower_ip, IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))); + assert_eq!(key_ab.lower_port, 80); + assert_eq!(key_ab.upper_ip, IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2))); + assert_eq!(key_ab.upper_port, 12345); +} + +#[test] +fn test_flow_key_same_ip_different_ports() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + + let key1 = FlowKey::new(ip, 80, ip, 12345); + let key2 = FlowKey::new(ip, 12345, ip, 80); + + assert_eq!(key1, key2); + assert_eq!(key1.lower_port, 80); + assert_eq!(key1.upper_port, 12345); +} + +#[test] +fn test_flow_direction_determines_client_server() { + let ip_client = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let ip_server = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)); + + let mut flow = TcpFlow::new(FlowKey::new(ip_client, 12345, ip_server, 80), 1000); + flow.set_initiator(ip_client, 12345); + + assert_eq!(flow.direction(ip_client, 12345), Direction::ClientToServer); + assert_eq!(flow.direction(ip_server, 80), Direction::ServerToClient); +} + +#[test] +fn test_flow_state_transitions() { + let ip_a = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let ip_b = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)); + + let mut flow = TcpFlow::new(FlowKey::new(ip_a, 12345, ip_b, 80), 1000); + assert_eq!(flow.state, FlowState::New); + + flow.on_syn(); + assert_eq!(flow.state, FlowState::SynSent); + + flow.on_syn_ack(); + assert_eq!(flow.state, FlowState::Established); + + flow.on_fin(); + assert_eq!(flow.state, FlowState::Closing); + + flow.on_fin(); + assert_eq!(flow.state, FlowState::Closed); +} + +#[test] +fn test_flow_rst_from_any_state() { + let ip_a = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let ip_b = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)); + + let mut flow = TcpFlow::new(FlowKey::new(ip_a, 12345, ip_b, 80), 1000); + flow.on_syn(); + assert_eq!(flow.state, FlowState::SynSent); + + flow.on_rst(); + assert_eq!(flow.state, FlowState::Closed); +} + +#[test] +fn test_mid_stream_pickup() { + let ip_a = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let ip_b = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)); + + let mut flow = TcpFlow::new(FlowKey::new(ip_a, 12345, ip_b, 80), 1000); + flow.on_data_without_syn(); + assert_eq!(flow.state, FlowState::Established); + assert!(flow.partial); +} + +#[test] +fn test_flow_direction_new() { + let dir = FlowDirection::new(); + assert_eq!(dir.isn, None); + assert_eq!(dir.base_offset, 0); + assert!(dir.segments.is_empty()); + assert_eq!(dir.reassembled_bytes, 0); + assert!(!dir.fin_seen); + assert!(!dir.rst_seen); + assert!(!dir.depth_exceeded); +} From 06bf9b22a99cf969e2ff0174fa6d8ad18c5034ed Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:08:00 -0500 Subject: [PATCH 07/18] fix: FlowKey canonicalization must use tuple ordering, not independent Sorting IPs and ports independently would merge different connections (e.g., A:80->B:443 and A:443->B:80 would produce the same key). Correct approach: compare (ip, port) tuples together. --- src/reassembly/flow.rs | 32 +++++++++++++++----------------- tests/reassembly_flow_tests.rs | 5 +++-- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/reassembly/flow.rs b/src/reassembly/flow.rs index 2c0a17f..ec1e391 100644 --- a/src/reassembly/flow.rs +++ b/src/reassembly/flow.rs @@ -13,24 +13,22 @@ pub struct FlowKey { impl FlowKey { pub fn new(ip_a: IpAddr, port_a: u16, ip_b: IpAddr, port_b: u16) -> Self { - // Canonicalize independently: lower_ip = min(ip_a, ip_b), lower_port = min(port_a, port_b), - // upper_ip = max(ip_a, ip_b), upper_port = max(port_a, port_b). - // This ensures the same FlowKey regardless of which direction the packet arrives from. - let (lower_ip, upper_ip) = if ip_a <= ip_b { - (ip_a, ip_b) + // Canonicalize by (ip, port) tuple comparison — keeps IP+port paired together. + // This is critical: sorting independently would merge different connections. + if (ip_a, port_a) <= (ip_b, port_b) { + FlowKey { + lower_ip: ip_a, + lower_port: port_a, + upper_ip: ip_b, + upper_port: port_b, + } } else { - (ip_b, ip_a) - }; - let (lower_port, upper_port) = if port_a <= port_b { - (port_a, port_b) - } else { - (port_b, port_a) - }; - FlowKey { - lower_ip, - lower_port, - upper_ip, - upper_port, + FlowKey { + lower_ip: ip_b, + lower_port: port_b, + upper_ip: ip_a, + upper_port: port_a, + } } } } diff --git a/tests/reassembly_flow_tests.rs b/tests/reassembly_flow_tests.rs index c34b65f..34530f8 100644 --- a/tests/reassembly_flow_tests.rs +++ b/tests/reassembly_flow_tests.rs @@ -12,10 +12,11 @@ fn test_flow_key_canonicalization() { let key_ba = FlowKey::new(ip_b, 80, ip_a, 12345); assert_eq!(key_ab, key_ba); + // Tuple ordering: (10.0.0.1, 12345) < (10.0.0.2, 80) since IPs differ assert_eq!(key_ab.lower_ip, IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))); - assert_eq!(key_ab.lower_port, 80); + assert_eq!(key_ab.lower_port, 12345); assert_eq!(key_ab.upper_ip, IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2))); - assert_eq!(key_ab.upper_port, 12345); + assert_eq!(key_ab.upper_port, 80); } #[test] From 65c62bda7fa17cb0e2f405ed45e0e52e99c6abbb Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:11:56 -0500 Subject: [PATCH 08/18] feat: add segment insertion with first-wins overlap and contiguous flush Implements TCP segment buffering (insert_segment) and ordered delivery (flush_contiguous) with first-wins overlap policy, conflict detection, sequence wraparound support, depth limiting with truncation, and anomaly counters for small segments and overlaps. --- src/reassembly/mod.rs | 1 + src/reassembly/segment.rs | 184 ++++++++++++++++++++++++++++++ tests/reassembly_segment_tests.rs | 166 +++++++++++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 src/reassembly/segment.rs create mode 100644 tests/reassembly_segment_tests.rs diff --git a/src/reassembly/mod.rs b/src/reassembly/mod.rs index c0e61ae..aabf37f 100644 --- a/src/reassembly/mod.rs +++ b/src/reassembly/mod.rs @@ -1,2 +1,3 @@ pub mod flow; pub mod handler; +pub mod segment; diff --git a/src/reassembly/segment.rs b/src/reassembly/segment.rs new file mode 100644 index 0000000..5ae5c62 --- /dev/null +++ b/src/reassembly/segment.rs @@ -0,0 +1,184 @@ +use crate::reassembly::flow::FlowDirection; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InsertResult { + Inserted, + Duplicate, + PartialOverlap, + ConflictingOverlap, + Truncated, + DepthExceeded, +} + +/// Compute the ISN-relative offset for a sequence number. +fn seq_offset(seq: u32, isn: u32) -> u64 { + seq.wrapping_sub(isn) as u64 +} + +/// Insert a segment into the flow direction's out-of-order buffer. +/// Applies first-wins overlap policy and tracks anomaly counters. +pub fn insert_segment( + dir: &mut FlowDirection, + seq: u32, + data: &[u8], + max_depth: usize, +) -> InsertResult { + if data.is_empty() { + return InsertResult::Inserted; + } + + let isn = match dir.isn { + Some(isn) => isn, + None => return InsertResult::Inserted, + }; + + // Track small segments + if data.len() < 8 { + dir.small_segment_count += 1; + } else { + dir.small_segment_count = 0; + } + + // Check depth limit + let remaining_depth = max_depth.saturating_sub(dir.reassembled_bytes); + if remaining_depth == 0 { + if !dir.depth_exceeded { + dir.depth_exceeded = true; + } + return InsertResult::DepthExceeded; + } + + let offset = seq_offset(seq, isn); + let mut segment_data = data.to_vec(); + + // Truncate if exceeding depth + let buffered: usize = dir.segments.values().map(|v| v.len()).sum(); + let total_after = dir.reassembled_bytes + buffered + segment_data.len(); + let truncated = if total_after > max_depth { + let allowed = max_depth.saturating_sub(dir.reassembled_bytes + buffered); + if allowed == 0 { + dir.depth_exceeded = true; + return InsertResult::DepthExceeded; + } + segment_data.truncate(allowed); + dir.depth_exceeded = true; + true + } else { + false + }; + + let new_start = offset; + let new_end = offset + segment_data.len() as u64; + + // Check for overlaps with existing segments + let mut has_overlap = false; + let mut has_conflict = false; + let mut trimmed_ranges: Vec<(u64, u64)> = Vec::new(); + + for (&existing_offset, existing_data) in dir.segments.iter() { + let existing_end = existing_offset + existing_data.len() as u64; + + if new_start < existing_end && new_end > existing_offset { + has_overlap = true; + + let overlap_start = new_start.max(existing_offset); + let overlap_end = new_end.min(existing_end); + + for pos in overlap_start..overlap_end { + let new_idx = (pos - new_start) as usize; + let existing_idx = (pos - existing_offset) as usize; + if new_idx < segment_data.len() + && existing_idx < existing_data.len() + && segment_data[new_idx] != existing_data[existing_idx] + { + has_conflict = true; + break; + } + } + + trimmed_ranges.push((existing_offset, existing_end)); + } + } + + if has_overlap { + dir.overlap_count += 1; + + let fully_covered = trimmed_ranges + .iter() + .any(|&(es, ee)| es <= new_start && ee >= new_end); + if fully_covered { + return if has_conflict { + InsertResult::ConflictingOverlap + } else { + InsertResult::Duplicate + }; + } + + // First-wins: insert only gap portions + let mut gaps: Vec<(u64, u64)> = Vec::new(); + let mut cursor = new_start; + + let mut sorted_ranges = trimmed_ranges.clone(); + sorted_ranges.sort_by_key(|&(start, _)| start); + + for &(es, ee) in &sorted_ranges { + if cursor < es { + gaps.push((cursor, es.min(new_end))); + } + cursor = cursor.max(ee); + } + if cursor < new_end { + gaps.push((cursor, new_end)); + } + + let had_gap = !gaps.is_empty(); + + for (gap_start, gap_end) in gaps { + let start_idx = (gap_start - new_start) as usize; + let end_idx = (gap_end - new_start) as usize; + if start_idx < segment_data.len() && end_idx <= segment_data.len() { + let gap_data = segment_data[start_idx..end_idx].to_vec(); + if !gap_data.is_empty() { + dir.segments.insert(gap_start, gap_data); + } + } + } + + // Only report ConflictingOverlap when fully covered (no gap was inserted) + return if !had_gap && has_conflict { + InsertResult::ConflictingOverlap + } else if truncated { + InsertResult::Truncated + } else { + InsertResult::PartialOverlap + }; + } + + // No overlap — insert normally + dir.segments.insert(offset, segment_data); + + if truncated { + InsertResult::Truncated + } else { + InsertResult::Inserted + } +} + +/// Flush contiguous segments starting from base_offset. +/// Returns Vec of (offset, data) pairs that were flushed. +pub fn flush_contiguous(dir: &mut FlowDirection) -> Vec<(u64, Vec)> { + let mut flushed = Vec::new(); + + loop { + if let Some(data) = dir.segments.remove(&dir.base_offset) { + let offset = dir.base_offset; + dir.base_offset += data.len() as u64; + dir.reassembled_bytes += data.len(); + flushed.push((offset, data)); + } else { + break; + } + } + + flushed +} diff --git a/tests/reassembly_segment_tests.rs b/tests/reassembly_segment_tests.rs new file mode 100644 index 0000000..d27d656 --- /dev/null +++ b/tests/reassembly_segment_tests.rs @@ -0,0 +1,166 @@ +use wirerust::reassembly::flow::FlowDirection; +use wirerust::reassembly::segment::{flush_contiguous, insert_segment, InsertResult}; + +#[test] +fn test_insert_single_segment() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + let result = insert_segment(&mut dir, 1001, b"hello", 10_485_760); + assert_eq!(result, InsertResult::Inserted); + assert_eq!(dir.segments.len(), 1); + assert_eq!(dir.segments.get(&1), Some(&b"hello".to_vec())); +} + +#[test] +fn test_flush_contiguous_single() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + insert_segment(&mut dir, 1001, b"hello", 10_485_760); + + let flushed = flush_contiguous(&mut dir); + assert_eq!(flushed.len(), 1); + assert_eq!(flushed[0].0, 1); // offset + assert_eq!(flushed[0].1, b"hello"); + assert_eq!(dir.base_offset, 6); // 1 + 5 + assert_eq!(dir.reassembled_bytes, 5); + assert!(dir.segments.is_empty()); +} + +#[test] +fn test_flush_contiguous_ordered() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + insert_segment(&mut dir, 1001, b"aaa", 10_485_760); + insert_segment(&mut dir, 1004, b"bbb", 10_485_760); + + let flushed = flush_contiguous(&mut dir); + assert_eq!(flushed.len(), 2); + assert_eq!(flushed[0].1, b"aaa"); + assert_eq!(flushed[1].1, b"bbb"); + assert_eq!(dir.base_offset, 7); // 1 + 3 + 3 + assert!(dir.segments.is_empty()); +} + +#[test] +fn test_out_of_order_buffering() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + // Insert segment 2 first (out of order) + insert_segment(&mut dir, 1004, b"bbb", 10_485_760); + let flushed = flush_contiguous(&mut dir); + assert!(flushed.is_empty()); // Can't flush — gap at offset 1 + + // Now insert segment 1 + insert_segment(&mut dir, 1001, b"aaa", 10_485_760); + let flushed = flush_contiguous(&mut dir); + assert_eq!(flushed.len(), 2); // Both flush now + assert_eq!(flushed[0].1, b"aaa"); + assert_eq!(flushed[1].1, b"bbb"); + assert_eq!(dir.base_offset, 7); +} + +#[test] +fn test_retransmission_dedup() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + insert_segment(&mut dir, 1001, b"hello", 10_485_760); + let result = insert_segment(&mut dir, 1001, b"hello", 10_485_760); + assert_eq!(result, InsertResult::Duplicate); + assert_eq!(dir.segments.len(), 1); // No duplicate stored +} + +#[test] +fn test_overlap_first_wins() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + // Insert "AAABBB" at offset 1 + insert_segment(&mut dir, 1001, b"AAABBB", 10_485_760); + + // Overlapping insert: "XXXCC" at offset 4 (overlaps with "BBB" at 4-6) + let result = insert_segment(&mut dir, 1004, b"XXXCC", 10_485_760); + assert_eq!(result, InsertResult::PartialOverlap); + assert_eq!(dir.overlap_count, 1); + + // Flush and verify: first 6 bytes from original, then "CC" from new + let flushed = flush_contiguous(&mut dir); + let all_bytes: Vec = flushed.iter().flat_map(|(_, data)| data.iter().copied()).collect(); + assert_eq!(&all_bytes, b"AAABBBCC"); +} + +#[test] +fn test_overlap_conflicting_data_detected() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + insert_segment(&mut dir, 1001, b"AAAA", 10_485_760); + + // Same range, different data + let result = insert_segment(&mut dir, 1001, b"BBBB", 10_485_760); + assert_eq!(result, InsertResult::ConflictingOverlap); + assert_eq!(dir.overlap_count, 1); + + // Original data preserved (first-wins) + let flushed = flush_contiguous(&mut dir); + assert_eq!(flushed[0].1, b"AAAA"); +} + +#[test] +fn test_sequence_wraparound() { + let mut dir = FlowDirection::new(); + // ISN near wraparound + dir.set_isn(0xFFFF_FFF0); + + // First data byte at ISN+1 = 0xFFFF_FFF1, offset = 1 + insert_segment(&mut dir, 0xFFFF_FFF1, b"before", 10_485_760); + // Next segment wraps: seq = 0xFFFF_FFF1 + 6 = 0xFFFF_FFF7, offset = 7 + insert_segment(&mut dir, 0xFFFF_FFF7, b"wrap", 10_485_760); + // Another after wrap: seq = 0xFFFF_FFFB, offset = 11 + insert_segment(&mut dir, 0xFFFF_FFFB, b"around", 10_485_760); + + let flushed = flush_contiguous(&mut dir); + let all_bytes: Vec = flushed.iter().flat_map(|(_, data)| data.iter().copied()).collect(); + assert_eq!(&all_bytes, b"beforewraparound"); +} + +#[test] +fn test_small_segment_tracking() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + // Insert small segments + for i in 0..5u32 { + let seq = 1001 + i; + insert_segment(&mut dir, seq, &[b'a'], 10_485_760); + } + + assert_eq!(dir.small_segment_count, 5); +} + +#[test] +fn test_depth_limit_truncation() { + let mut dir = FlowDirection::new(); + dir.set_isn(1000); + + let max_depth: usize = 100; // small for testing + let data = vec![b'A'; 80]; + insert_segment(&mut dir, 1001, &data, max_depth); + flush_contiguous(&mut dir); + assert_eq!(dir.reassembled_bytes, 80); + assert!(!dir.depth_exceeded); + + // This should be truncated to 20 bytes + let data2 = vec![b'B'; 50]; + let result = insert_segment(&mut dir, 1081, &data2, max_depth); + assert_eq!(result, InsertResult::Truncated); + assert!(dir.depth_exceeded); + + let flushed = flush_contiguous(&mut dir); + assert_eq!(flushed[0].1.len(), 20); // truncated from 50 to 20 + assert_eq!(dir.reassembled_bytes, 100); +} From 8b177b8300ac31b0537b16cb2b2ec77c8a8848f8 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:18:54 -0500 Subject: [PATCH 09/18] feat: add TcpReassembler engine with memcap, depth limits, and anomaly detection --- src/reassembly/mod.rs | 360 +++++++++++++++++++++++++++++++ tests/reassembly_engine_tests.rs | 217 +++++++++++++++++++ 2 files changed, 577 insertions(+) create mode 100644 tests/reassembly_engine_tests.rs diff --git a/src/reassembly/mod.rs b/src/reassembly/mod.rs index aabf37f..628e44a 100644 --- a/src/reassembly/mod.rs +++ b/src/reassembly/mod.rs @@ -1,3 +1,363 @@ pub mod flow; pub mod handler; pub mod segment; + +use std::collections::HashMap; + +use crate::decoder::{ParsedPacket, Protocol, TransportInfo}; +use crate::findings::{Confidence, Finding, ThreatCategory, Verdict}; +use crate::reassembly::flow::{FlowKey, FlowState, TcpFlow}; +use crate::reassembly::handler::{CloseReason, StreamHandler}; +use crate::reassembly::segment::{flush_contiguous, insert_segment, InsertResult}; + +/// Configuration for the TCP reassembly engine. +#[derive(Debug, Clone)] +pub struct ReassemblyConfig { + /// Maximum bytes to reassemble per-direction before stopping (depth limit). + pub max_depth: usize, + /// Maximum total memory across all flows before eviction kicks in. + pub memcap: usize, + /// Seconds of inactivity before a flow is considered timed out. + pub flow_timeout_secs: u32, +} + +impl Default for ReassemblyConfig { + fn default() -> Self { + ReassemblyConfig { + max_depth: 10 * 1024 * 1024, // 10 MB per direction + memcap: 1024 * 1024 * 1024, // 1 GB total + flow_timeout_secs: 300, // 5 minutes + } + } +} + +/// Counters exposed by the reassembly engine. +#[derive(Debug, Clone, Default)] +pub struct ReassemblyStats { + pub packets_processed: u64, + pub packets_tcp: u64, + pub packets_skipped_non_tcp: u64, + pub flows_total: u64, + pub flows_partial: u64, + pub flows_expired: u64, + pub flows_rst: u64, + pub flows_fin: u64, + pub segments_inserted: u64, + pub segments_duplicates: u64, + pub segments_overlaps: u64, + pub bytes_reassembled: u64, + pub evictions: u64, +} + +/// The main TCP reassembly engine. +pub struct TcpReassembler { + config: ReassemblyConfig, + flows: HashMap, + stats: ReassemblyStats, + findings: Vec, + total_memory: usize, +} + +impl TcpReassembler { + pub fn new(config: ReassemblyConfig) -> Self { + TcpReassembler { + config, + flows: HashMap::new(), + stats: ReassemblyStats::default(), + findings: Vec::new(), + total_memory: 0, + } + } + + /// Process a single parsed packet through the reassembly engine. + pub fn process_packet( + &mut self, + packet: &ParsedPacket, + timestamp: u32, + handler: &mut dyn StreamHandler, + ) { + self.stats.packets_processed += 1; + + // 1. Skip non-TCP packets + if packet.protocol != Protocol::Tcp { + self.stats.packets_skipped_non_tcp += 1; + return; + } + + // 2. Extract TCP fields + let (src_port, dst_port, seq, syn, ack, fin, rst) = match &packet.transport { + TransportInfo::Tcp { + src_port, + dst_port, + seq_number, + syn, + ack, + fin, + rst, + } => (*src_port, *dst_port, *seq_number, *syn, *ack, *fin, *rst), + _ => return, + }; + + self.stats.packets_tcp += 1; + + // 3. Build the flow key + let key = FlowKey::new(packet.src_ip, src_port, packet.dst_ip, dst_port); + + // 4. Get or create flow + if !self.flows.contains_key(&key) { + let flow = TcpFlow::new(key.clone(), timestamp); + self.flows.insert(key.clone(), flow); + self.stats.flows_total += 1; + } + + // Work with the flow + let flow = self.flows.get_mut(&key).unwrap(); + flow.last_seen = timestamp; + + // 5. Handle SYN (without ACK) -- client initiating + if syn && !ack { + flow.set_initiator(packet.src_ip, src_port); + let dir = flow.direction(packet.src_ip, src_port); + flow.get_direction_mut(dir).set_isn(seq); + flow.on_syn(); + } + + // 6. Handle SYN+ACK -- server responding + if syn && ack { + // The responder is sending SYN+ACK, so the initiator is the *destination* + flow.set_initiator(packet.dst_ip, dst_port); + let dir = flow.direction(packet.src_ip, src_port); + flow.get_direction_mut(dir).set_isn(seq); + flow.on_syn_ack(); + } + + // 7. Handle RST + if rst { + flow.on_rst(); + self.stats.flows_rst += 1; + let key_clone = key.clone(); + handler.on_flow_close(&key_clone, CloseReason::Rst); + return; + } + + // 8. Handle FIN + if fin { + let dir = flow.direction(packet.src_ip, src_port); + flow.get_direction_mut(dir).fin_seen = true; + flow.on_fin(); + if flow.state == FlowState::Closed { + self.stats.flows_fin += 1; + handler.on_flow_close(&key, CloseReason::Fin); + } + } + + // 9. Handle payload + let payload = &packet.payload; + if !payload.is_empty() { + // If no SYN was seen (mid-stream join), infer state + if flow.state == FlowState::New { + flow.on_data_without_syn(); + flow.set_initiator(packet.src_ip, src_port); + let dir = flow.direction(packet.src_ip, src_port); + flow.get_direction_mut(dir).infer_isn(seq); + self.stats.flows_partial += 1; + } + + let dir = flow.direction(packet.src_ip, src_port); + + // Ensure ISN is set for this direction even on established flows + // (e.g., server direction when only SYN was seen, not SYN+ACK) + if flow.get_direction_mut(dir).isn.is_none() { + flow.get_direction_mut(dir).infer_isn(seq); + } + + let flow_dir = flow.get_direction_mut(dir); + let result = insert_segment(flow_dir, seq, payload, self.config.max_depth); + + match result { + InsertResult::Inserted => self.stats.segments_inserted += 1, + InsertResult::Duplicate => self.stats.segments_duplicates += 1, + InsertResult::PartialOverlap => { + self.stats.segments_overlaps += 1; + self.stats.segments_inserted += 1; + } + InsertResult::ConflictingOverlap => { + self.stats.segments_overlaps += 1; + self.generate_conflicting_overlap_finding(&key, packet.src_ip); + } + InsertResult::Truncated => { + self.stats.segments_inserted += 1; + self.generate_truncated_finding(&key, packet.src_ip); + } + InsertResult::DepthExceeded => { + // Already tracked in the direction + } + } + + // Check anomaly thresholds on the direction + let flow = self.flows.get_mut(&key).unwrap(); + let flow_dir = flow.get_direction_mut(dir); + if flow_dir.overlap_count == 51 { + self.findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Inconclusive, + confidence: Confidence::Medium, + summary: format!( + "Excessive segment overlaps ({}) on flow {}", + flow_dir.overlap_count, key + ), + evidence: vec![format!("overlap_count = {}", flow_dir.overlap_count)], + mitre_technique: None, + source_ip: Some(packet.src_ip), + timestamp: None, + }); + } + if flow_dir.small_segment_count == 2049 { + self.findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Inconclusive, + confidence: Confidence::Low, + summary: format!( + "Excessive small segments ({}) on flow {}", + flow_dir.small_segment_count, key + ), + evidence: vec![format!( + "small_segment_count = {}", + flow_dir.small_segment_count + )], + mitre_technique: None, + source_ip: Some(packet.src_ip), + timestamp: None, + }); + } + + // Flush contiguous data + let flow = self.flows.get_mut(&key).unwrap(); + let flow_dir = flow.get_direction_mut(dir); + let flushed = flush_contiguous(flow_dir); + + for (offset, data) in &flushed { + self.stats.bytes_reassembled += data.len() as u64; + handler.on_data(&key, dir, data, *offset); + } + } + + // 10. Update total memory tracking + self.update_memory(); + + // 11. Evict flows if memcap exceeded + if self.total_memory > self.config.memcap { + self.evict_flows(handler); + } + } + + /// Expire flows that have been idle longer than the configured timeout. + pub fn expire_flows(&mut self, current_time: u32, handler: &mut dyn StreamHandler) { + let timeout = self.config.flow_timeout_secs; + let expired_keys: Vec = self + .flows + .iter() + .filter(|(_, flow)| { + flow.state != FlowState::Closed + && current_time.wrapping_sub(flow.last_seen) > timeout + }) + .map(|(key, _)| key.clone()) + .collect(); + + for key in expired_keys { + self.flows.remove(&key); + self.stats.flows_expired += 1; + handler.on_flow_close(&key, CloseReason::Timeout); + } + + self.update_memory(); + } + + /// Close all remaining flows (called at end of capture). + pub fn finalize(&mut self, handler: &mut dyn StreamHandler) { + let all_keys: Vec = self.flows.keys().cloned().collect(); + for key in all_keys { + self.flows.remove(&key); + handler.on_flow_close(&key, CloseReason::Timeout); + } + self.update_memory(); + } + + /// Return a reference to current stats. + pub fn stats(&self) -> &ReassemblyStats { + &self.stats + } + + /// Return any anomaly findings generated during reassembly. + pub fn findings(&self) -> &[Finding] { + &self.findings + } + + // --- Private helpers --- + + fn update_memory(&mut self) { + self.total_memory = self.flows.values().map(|f| f.memory_used()).sum(); + } + + /// Evict flows when memcap is exceeded. + /// Strategy: evict non-established flows first (sorted by LRU), + /// then established flows by LRU. + fn evict_flows(&mut self, handler: &mut dyn StreamHandler) { + while self.total_memory > self.config.memcap && !self.flows.is_empty() { + // Collect candidate keys with their (is_established, last_seen) for sorting + let mut candidates: Vec<(FlowKey, bool, u32)> = self + .flows + .iter() + .map(|(key, flow)| { + let is_established = flow.state == FlowState::Established; + (key.clone(), is_established, flow.last_seen) + }) + .collect(); + + // Sort: non-established first, then by oldest last_seen + candidates.sort_by(|a, b| { + a.1.cmp(&b.1) // false (non-established) < true (established) + .then(a.2.cmp(&b.2)) // older first + }); + + if let Some((key, _, _)) = candidates.first() { + let key = key.clone(); + self.flows.remove(&key); + self.stats.evictions += 1; + handler.on_flow_close(&key, CloseReason::MemoryPressure); + self.update_memory(); + } else { + break; + } + } + } + + fn generate_conflicting_overlap_finding(&mut self, key: &FlowKey, src_ip: std::net::IpAddr) { + self.findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Likely, + confidence: Confidence::High, + summary: format!("Conflicting TCP segment overlap on flow {}", key), + evidence: vec!["Retransmitted segment contains different data".to_string()], + mitre_technique: Some("T1036".to_string()), + source_ip: Some(src_ip), + timestamp: None, + }); + } + + fn generate_truncated_finding(&mut self, key: &FlowKey, src_ip: std::net::IpAddr) { + self.findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Inconclusive, + confidence: Confidence::Low, + summary: format!("Stream depth exceeded on flow {}", key), + evidence: vec![format!( + "Max depth {} bytes reached", + self.config.max_depth + )], + mitre_technique: None, + source_ip: Some(src_ip), + timestamp: None, + }); + } +} diff --git a/tests/reassembly_engine_tests.rs b/tests/reassembly_engine_tests.rs new file mode 100644 index 0000000..4cda705 --- /dev/null +++ b/tests/reassembly_engine_tests.rs @@ -0,0 +1,217 @@ +use std::net::{IpAddr, Ipv4Addr}; + +use wirerust::decoder::{ParsedPacket, Protocol, TransportInfo}; +use wirerust::reassembly::flow::FlowKey; +use wirerust::reassembly::handler::{CloseReason, Direction, StreamHandler}; +use wirerust::reassembly::{ReassemblyConfig, TcpReassembler}; + +/// Test handler that records all callbacks. +struct RecordingHandler { + data_events: Vec<(FlowKey, Direction, Vec, u64)>, + close_events: Vec<(FlowKey, CloseReason)>, +} + +impl RecordingHandler { + fn new() -> Self { + RecordingHandler { + data_events: Vec::new(), + close_events: Vec::new(), + } + } + + fn all_data(&self) -> Vec { + self.data_events + .iter() + .flat_map(|(_, _, data, _)| data.iter().copied()) + .collect() + } +} + +impl StreamHandler for RecordingHandler { + fn on_data( + &mut self, + flow_key: &FlowKey, + direction: Direction, + data: &[u8], + offset: u64, + ) { + self.data_events + .push((flow_key.clone(), direction, data.to_vec(), offset)); + } + + fn on_flow_close(&mut self, flow_key: &FlowKey, reason: CloseReason) { + self.close_events.push((flow_key.clone(), reason)); + } +} + +fn make_tcp_packet( + src_ip: [u8; 4], + src_port: u16, + dst_ip: [u8; 4], + dst_port: u16, + seq: u32, + payload: &[u8], + syn: bool, + fin: bool, + rst: bool, +) -> ParsedPacket { + ParsedPacket { + src_ip: IpAddr::V4(Ipv4Addr::from(src_ip)), + dst_ip: IpAddr::V4(Ipv4Addr::from(dst_ip)), + protocol: Protocol::Tcp, + transport: TransportInfo::Tcp { + src_port, + dst_port, + seq_number: seq, + syn, + ack: false, + fin, + rst, + }, + payload: payload.to_vec(), + packet_len: 54 + payload.len(), + } +} + +#[test] +fn test_three_packet_stream_ordered() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // SYN + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + reassembler.process_packet(&syn, 1, &mut handler); + + // Data packets + let p1 = make_tcp_packet(client, 12345, server, 80, 1001, b"aaa", false, false, false); + reassembler.process_packet(&p1, 2, &mut handler); + + let p2 = make_tcp_packet(client, 12345, server, 80, 1004, b"bbb", false, false, false); + reassembler.process_packet(&p2, 3, &mut handler); + + let p3 = make_tcp_packet(client, 12345, server, 80, 1007, b"ccc", false, false, false); + reassembler.process_packet(&p3, 4, &mut handler); + + assert_eq!(handler.all_data(), b"aaabbbccc"); + assert_eq!(handler.data_events.len(), 3); +} + +#[test] +fn test_out_of_order_delivery() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // SYN + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + reassembler.process_packet(&syn, 1, &mut handler); + + // Send packets [1, 3, 2] + let p1 = make_tcp_packet(client, 12345, server, 80, 1001, b"aaa", false, false, false); + reassembler.process_packet(&p1, 2, &mut handler); + + let p3 = make_tcp_packet(client, 12345, server, 80, 1007, b"ccc", false, false, false); + reassembler.process_packet(&p3, 3, &mut handler); + assert_eq!(handler.data_events.len(), 1); // only p1 flushed + + let p2 = make_tcp_packet(client, 12345, server, 80, 1004, b"bbb", false, false, false); + reassembler.process_packet(&p2, 4, &mut handler); + + // Now all three should be flushed + assert_eq!(handler.all_data(), b"aaabbbccc"); +} + +#[test] +fn test_mid_stream_no_syn() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // Data without SYN + let p1 = make_tcp_packet(client, 12345, server, 80, 5000, b"hello", false, false, false); + reassembler.process_packet(&p1, 1, &mut handler); + + assert_eq!(handler.all_data(), b"hello"); + + let stats = reassembler.stats(); + assert_eq!(stats.flows_total, 1); + assert_eq!(stats.flows_partial, 1); +} + +#[test] +fn test_rst_closes_flow() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + reassembler.process_packet(&syn, 1, &mut handler); + + let data = make_tcp_packet(client, 12345, server, 80, 1001, b"data", false, false, false); + reassembler.process_packet(&data, 2, &mut handler); + + let rst = make_tcp_packet(server, 80, client, 12345, 2000, &[], false, false, true); + reassembler.process_packet(&rst, 3, &mut handler); + + assert_eq!(handler.close_events.len(), 1); + assert_eq!(handler.close_events[0].1, CloseReason::Rst); +} + +#[test] +fn test_finalize_flushes_remaining() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + reassembler.process_packet(&syn, 1, &mut handler); + + let data = make_tcp_packet(client, 12345, server, 80, 1001, b"leftover", false, false, false); + reassembler.process_packet(&data, 2, &mut handler); + + reassembler.finalize(&mut handler); + + assert_eq!(handler.close_events.len(), 1); + assert_eq!(handler.close_events[0].1, CloseReason::Timeout); +} + +#[test] +fn test_flow_timeout_expiration() { + let config = ReassemblyConfig { + flow_timeout_secs: 10, + ..ReassemblyConfig::default() + }; + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + reassembler.process_packet(&syn, 100, &mut handler); + + // Expire at time 200 (100 seconds later, > 10s timeout) + reassembler.expire_flows(200, &mut handler); + + assert_eq!(handler.close_events.len(), 1); + assert_eq!(handler.close_events[0].1, CloseReason::Timeout); + + let stats = reassembler.stats(); + assert_eq!(stats.flows_expired, 1); +} From 79fcdd415a1adde3d25b53d16cda3ab2aff34d38 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:23:04 -0500 Subject: [PATCH 10/18] fix: resolve clippy warnings (Default, while-let, byte str, too-many-args) --- src/reassembly/flow.rs | 6 ++++++ src/reassembly/segment.rs | 14 +++++--------- tests/reassembly_engine_tests.rs | 1 + tests/reassembly_segment_tests.rs | 2 +- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/reassembly/flow.rs b/src/reassembly/flow.rs index ec1e391..79a8f8b 100644 --- a/src/reassembly/flow.rs +++ b/src/reassembly/flow.rs @@ -66,6 +66,12 @@ pub struct FlowDirection { pub depth_exceeded: bool, } +impl Default for FlowDirection { + fn default() -> Self { + Self::new() + } +} + impl FlowDirection { pub fn new() -> Self { FlowDirection { diff --git a/src/reassembly/segment.rs b/src/reassembly/segment.rs index 5ae5c62..fc0ba70 100644 --- a/src/reassembly/segment.rs +++ b/src/reassembly/segment.rs @@ -169,15 +169,11 @@ pub fn insert_segment( pub fn flush_contiguous(dir: &mut FlowDirection) -> Vec<(u64, Vec)> { let mut flushed = Vec::new(); - loop { - if let Some(data) = dir.segments.remove(&dir.base_offset) { - let offset = dir.base_offset; - dir.base_offset += data.len() as u64; - dir.reassembled_bytes += data.len(); - flushed.push((offset, data)); - } else { - break; - } + while let Some(data) = dir.segments.remove(&dir.base_offset) { + let offset = dir.base_offset; + dir.base_offset += data.len() as u64; + dir.reassembled_bytes += data.len(); + flushed.push((offset, data)); } flushed diff --git a/tests/reassembly_engine_tests.rs b/tests/reassembly_engine_tests.rs index 4cda705..c249aee 100644 --- a/tests/reassembly_engine_tests.rs +++ b/tests/reassembly_engine_tests.rs @@ -44,6 +44,7 @@ impl StreamHandler for RecordingHandler { } } +#[allow(clippy::too_many_arguments)] fn make_tcp_packet( src_ip: [u8; 4], src_port: u16, diff --git a/tests/reassembly_segment_tests.rs b/tests/reassembly_segment_tests.rs index d27d656..83209af 100644 --- a/tests/reassembly_segment_tests.rs +++ b/tests/reassembly_segment_tests.rs @@ -136,7 +136,7 @@ fn test_small_segment_tracking() { // Insert small segments for i in 0..5u32 { let seq = 1001 + i; - insert_segment(&mut dir, seq, &[b'a'], 10_485_760); + insert_segment(&mut dir, seq, b"a", 10_485_760); } assert_eq!(dir.small_segment_count, 5); From dbfc58fef22777d6c0d01e05a9f1ed9ff8d22081 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:25:53 -0500 Subject: [PATCH 11/18] feat: wire TCP reassembler into CLI and analyze pipeline Add --reassemble, --no-reassemble, --reassembly-depth, and --reassembly-memcap global CLI flags; integrate TcpReassembler with a NullHandler placeholder into the run_analyze pipeline including finalize and findings collection. --- src/cli.rs | 16 ++++++++++++++++ src/main.rs | 33 +++++++++++++++++++++++++++++++++ tests/cli_tests.rs | 23 +++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index 187bea3..913f994 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -35,6 +35,22 @@ pub struct Cli { #[arg(long, global = true)] pub csv: Option>, + /// Force TCP stream reassembly on + #[arg(long, global = true)] + pub reassemble: bool, + + /// Force TCP stream reassembly off (quick scan) + #[arg(long, global = true)] + pub no_reassemble: bool, + + /// Per-direction stream reassembly limit in MB (default: 10) + #[arg(long, global = true, default_value_t = 10)] + pub reassembly_depth: usize, + + /// Global reassembly memory cap in MB (default: 1024) + #[arg(long, global = true, default_value_t = 1024)] + pub reassembly_memcap: usize, + #[command(subcommand)] pub command: Commands, } diff --git a/src/main.rs b/src/main.rs index 8629d8e..ade9f86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,9 @@ use wirerust::analyzer::dns::DnsAnalyzer; use wirerust::cli::{Cli, Commands, OutputFormat}; use wirerust::decoder::decode_packet; use wirerust::reader::PcapSource; +use wirerust::reassembly::flow::FlowKey; +use wirerust::reassembly::handler::{CloseReason, Direction, StreamHandler}; +use wirerust::reassembly::{ReassemblyConfig, TcpReassembler}; use wirerust::reporter::Reporter; use wirerust::reporter::json::JsonReporter; use wirerust::reporter::terminal::TerminalReporter; @@ -43,6 +46,28 @@ fn run_analyze( let mut dns_analyzer = DnsAnalyzer::new(); let mut all_findings = Vec::new(); + // Determine if reassembly is needed + let needs_reassembly = cli.reassemble; // Will expand when HTTP/TLS analyzers added + let skip_reassembly = cli.no_reassemble; + + let mut reassembler = if needs_reassembly && !skip_reassembly { + let config = ReassemblyConfig { + max_depth: cli.reassembly_depth * 1_048_576, + memcap: cli.reassembly_memcap * 1_048_576, + ..ReassemblyConfig::default() + }; + Some(TcpReassembler::new(config)) + } else { + None + }; + + struct NullHandler; + impl StreamHandler for NullHandler { + fn on_data(&mut self, _: &FlowKey, _: Direction, _: &[u8], _: u64) {} + fn on_flow_close(&mut self, _: &FlowKey, _: CloseReason) {} + } + let mut stream_handler = NullHandler; + for target in targets { let pcap_files = resolve_targets(target)?; for path in &pcap_files { @@ -61,6 +86,9 @@ fn run_analyze( let findings = dns_analyzer.analyze(&parsed); all_findings.extend(findings); } + if let Some(ref mut reasm) = reassembler { + reasm.process_packet(&parsed, raw.timestamp_secs, &mut stream_handler); + } } pb.inc(1); } @@ -68,6 +96,11 @@ fn run_analyze( } } + if let Some(ref mut reasm) = reassembler { + reasm.finalize(&mut stream_handler); + all_findings.extend(reasm.findings().to_vec()); + } + let analyzer_summaries = if enable_dns { vec![dns_analyzer.summarize()] } else { diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index f5b0c50..4f5973a 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -45,6 +45,29 @@ fn test_summary_subcommand() { assert_eq!(cli.output_format, Some(OutputFormat::Json)); } +#[test] +fn test_reassembly_flags() { + let cli = Cli::parse_from([ + "wirerust", + "analyze", + "test.pcap", + "--reassemble", + "--reassembly-depth", + "20", + "--reassembly-memcap", + "2048", + ]); + assert!(cli.reassemble); + assert_eq!(cli.reassembly_depth, 20); + assert_eq!(cli.reassembly_memcap, 2048); +} + +#[test] +fn test_no_reassemble_flag() { + let cli = Cli::parse_from(["wirerust", "analyze", "test.pcap", "--no-reassemble"]); + assert!(cli.no_reassemble); +} + #[test] fn test_no_color_flag() { let cli = Cli::parse_from(["wirerust", "--no-color", "analyze", "test.pcap"]); From 2afeb73cb82d92a2a81f0908dee37b923db6ef52 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:26:46 -0500 Subject: [PATCH 12/18] style: apply rustfmt to all reassembly code and tests --- src/reassembly/handler.rs | 8 +------- src/reassembly/mod.rs | 13 +++++-------- tests/reassembly_engine_tests.rs | 28 ++++++++++++++++++---------- tests/reassembly_segment_tests.rs | 12 +++++++++--- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/reassembly/handler.rs b/src/reassembly/handler.rs index 061feba..2b0d445 100644 --- a/src/reassembly/handler.rs +++ b/src/reassembly/handler.rs @@ -17,13 +17,7 @@ pub enum CloseReason { } pub trait StreamHandler { - fn on_data( - &mut self, - flow_key: &FlowKey, - direction: Direction, - data: &[u8], - offset: u64, - ); + fn on_data(&mut self, flow_key: &FlowKey, direction: Direction, data: &[u8], offset: u64); fn on_flow_close(&mut self, flow_key: &FlowKey, reason: CloseReason); } diff --git a/src/reassembly/mod.rs b/src/reassembly/mod.rs index 628e44a..17e5644 100644 --- a/src/reassembly/mod.rs +++ b/src/reassembly/mod.rs @@ -8,7 +8,7 @@ use crate::decoder::{ParsedPacket, Protocol, TransportInfo}; use crate::findings::{Confidence, Finding, ThreatCategory, Verdict}; use crate::reassembly::flow::{FlowKey, FlowState, TcpFlow}; use crate::reassembly::handler::{CloseReason, StreamHandler}; -use crate::reassembly::segment::{flush_contiguous, insert_segment, InsertResult}; +use crate::reassembly::segment::{InsertResult, flush_contiguous, insert_segment}; /// Configuration for the TCP reassembly engine. #[derive(Debug, Clone)] @@ -24,9 +24,9 @@ pub struct ReassemblyConfig { impl Default for ReassemblyConfig { fn default() -> Self { ReassemblyConfig { - max_depth: 10 * 1024 * 1024, // 10 MB per direction - memcap: 1024 * 1024 * 1024, // 1 GB total - flow_timeout_secs: 300, // 5 minutes + max_depth: 10 * 1024 * 1024, // 10 MB per direction + memcap: 1024 * 1024 * 1024, // 1 GB total + flow_timeout_secs: 300, // 5 minutes } } } @@ -351,10 +351,7 @@ impl TcpReassembler { verdict: Verdict::Inconclusive, confidence: Confidence::Low, summary: format!("Stream depth exceeded on flow {}", key), - evidence: vec![format!( - "Max depth {} bytes reached", - self.config.max_depth - )], + evidence: vec![format!("Max depth {} bytes reached", self.config.max_depth)], mitre_technique: None, source_ip: Some(src_ip), timestamp: None, diff --git a/tests/reassembly_engine_tests.rs b/tests/reassembly_engine_tests.rs index c249aee..010ee33 100644 --- a/tests/reassembly_engine_tests.rs +++ b/tests/reassembly_engine_tests.rs @@ -28,13 +28,7 @@ impl RecordingHandler { } impl StreamHandler for RecordingHandler { - fn on_data( - &mut self, - flow_key: &FlowKey, - direction: Direction, - data: &[u8], - offset: u64, - ) { + fn on_data(&mut self, flow_key: &FlowKey, direction: Direction, data: &[u8], offset: u64) { self.data_events .push((flow_key.clone(), direction, data.to_vec(), offset)); } @@ -139,7 +133,9 @@ fn test_mid_stream_no_syn() { let server = [10, 0, 0, 2]; // Data without SYN - let p1 = make_tcp_packet(client, 12345, server, 80, 5000, b"hello", false, false, false); + let p1 = make_tcp_packet( + client, 12345, server, 80, 5000, b"hello", false, false, false, + ); reassembler.process_packet(&p1, 1, &mut handler); assert_eq!(handler.all_data(), b"hello"); @@ -161,7 +157,9 @@ fn test_rst_closes_flow() { let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); reassembler.process_packet(&syn, 1, &mut handler); - let data = make_tcp_packet(client, 12345, server, 80, 1001, b"data", false, false, false); + let data = make_tcp_packet( + client, 12345, server, 80, 1001, b"data", false, false, false, + ); reassembler.process_packet(&data, 2, &mut handler); let rst = make_tcp_packet(server, 80, client, 12345, 2000, &[], false, false, true); @@ -183,7 +181,17 @@ fn test_finalize_flushes_remaining() { let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); reassembler.process_packet(&syn, 1, &mut handler); - let data = make_tcp_packet(client, 12345, server, 80, 1001, b"leftover", false, false, false); + let data = make_tcp_packet( + client, + 12345, + server, + 80, + 1001, + b"leftover", + false, + false, + false, + ); reassembler.process_packet(&data, 2, &mut handler); reassembler.finalize(&mut handler); diff --git a/tests/reassembly_segment_tests.rs b/tests/reassembly_segment_tests.rs index 83209af..846dcd3 100644 --- a/tests/reassembly_segment_tests.rs +++ b/tests/reassembly_segment_tests.rs @@ -1,5 +1,5 @@ use wirerust::reassembly::flow::FlowDirection; -use wirerust::reassembly::segment::{flush_contiguous, insert_segment, InsertResult}; +use wirerust::reassembly::segment::{InsertResult, flush_contiguous, insert_segment}; #[test] fn test_insert_single_segment() { @@ -89,7 +89,10 @@ fn test_overlap_first_wins() { // Flush and verify: first 6 bytes from original, then "CC" from new let flushed = flush_contiguous(&mut dir); - let all_bytes: Vec = flushed.iter().flat_map(|(_, data)| data.iter().copied()).collect(); + let all_bytes: Vec = flushed + .iter() + .flat_map(|(_, data)| data.iter().copied()) + .collect(); assert_eq!(&all_bytes, b"AAABBBCC"); } @@ -124,7 +127,10 @@ fn test_sequence_wraparound() { insert_segment(&mut dir, 0xFFFF_FFFB, b"around", 10_485_760); let flushed = flush_contiguous(&mut dir); - let all_bytes: Vec = flushed.iter().flat_map(|(_, data)| data.iter().copied()).collect(); + let all_bytes: Vec = flushed + .iter() + .flat_map(|(_, data)| data.iter().copied()) + .collect(); assert_eq!(&all_bytes, b"beforewraparound"); } From fa204d88580e5c1dfa6b272c137ac6ff3e74820c Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:36:32 -0500 Subject: [PATCH 13/18] =?UTF-8?q?fix:=20critical=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20flow=20leak=20and=20overlap=20CPU=20exhaustion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: RST/FIN closed flows now removed from HashMap immediately. Previously they accumulated forever (expire_flows skipped Closed flows). New connections reusing the same 5-tuple would silently merge with the dead flow, corrupting reassembly. C2: Overlap conflict detection now uses slice comparison instead of byte-by-byte loop. Prevents O(N²·S) CPU exhaustion from crafted overlapping segments. Also fixes I6: expire_flows now uses checked subtraction instead of wrapping_sub, preventing premature expiration on non-monotonic pcap timestamps. Closed flows are also now eligible for expiration as a safety net. --- src/reassembly/mod.rs | 27 +++++++++++++++++++++------ src/reassembly/segment.rs | 22 ++++++++++++---------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/src/reassembly/mod.rs b/src/reassembly/mod.rs index 17e5644..776dcb3 100644 --- a/src/reassembly/mod.rs +++ b/src/reassembly/mod.rs @@ -131,12 +131,15 @@ impl TcpReassembler { flow.on_syn_ack(); } - // 7. Handle RST + // 7. Handle RST — close and remove flow immediately if rst { flow.on_rst(); self.stats.flows_rst += 1; + // Drop the mutable borrow before removing let key_clone = key.clone(); handler.on_flow_close(&key_clone, CloseReason::Rst); + self.flows.remove(&key_clone); + self.update_memory(); return; } @@ -147,7 +150,10 @@ impl TcpReassembler { flow.on_fin(); if flow.state == FlowState::Closed { self.stats.flows_fin += 1; - handler.on_flow_close(&key, CloseReason::Fin); + let key_clone = key.clone(); + handler.on_flow_close(&key_clone, CloseReason::Fin); + // Remove fully closed flow — but continue processing payload below + // if this FIN packet carries data (payload handled after this block) } } @@ -242,10 +248,19 @@ impl TcpReassembler { } } - // 10. Update total memory tracking + // 10. Remove FIN-closed flows after processing their final payload + if self + .flows + .get(&key) + .is_some_and(|f| f.state == FlowState::Closed) + { + self.flows.remove(&key); + } + + // 11. Update total memory tracking self.update_memory(); - // 11. Evict flows if memcap exceeded + // 12. Evict flows if memcap exceeded if self.total_memory > self.config.memcap { self.evict_flows(handler); } @@ -258,8 +273,8 @@ impl TcpReassembler { .flows .iter() .filter(|(_, flow)| { - flow.state != FlowState::Closed - && current_time.wrapping_sub(flow.last_seen) > timeout + flow.state == FlowState::Closed + || (current_time > flow.last_seen && (current_time - flow.last_seen) > timeout) }) .map(|(key, _)| key.clone()) .collect(); diff --git a/src/reassembly/segment.rs b/src/reassembly/segment.rs index fc0ba70..08d6f70 100644 --- a/src/reassembly/segment.rs +++ b/src/reassembly/segment.rs @@ -84,16 +84,18 @@ pub fn insert_segment( let overlap_start = new_start.max(existing_offset); let overlap_end = new_end.min(existing_end); - for pos in overlap_start..overlap_end { - let new_idx = (pos - new_start) as usize; - let existing_idx = (pos - existing_offset) as usize; - if new_idx < segment_data.len() - && existing_idx < existing_data.len() - && segment_data[new_idx] != existing_data[existing_idx] - { - has_conflict = true; - break; - } + // Use slice comparison (SIMD-optimized) instead of byte-by-byte + let new_slice_start = (overlap_start - new_start) as usize; + let new_slice_end = (overlap_end - new_start) as usize; + let existing_slice_start = (overlap_start - existing_offset) as usize; + let existing_slice_end = (overlap_end - existing_offset) as usize; + + if new_slice_end <= segment_data.len() + && existing_slice_end <= existing_data.len() + && segment_data[new_slice_start..new_slice_end] + != existing_data[existing_slice_start..existing_slice_end] + { + has_conflict = true; } trimmed_ranges.push((existing_offset, existing_end)); From adbc30c5aa1df5e7bbe73d5e98f556442136cfa3 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:41:41 -0500 Subject: [PATCH 14/18] fix: address Important review findings (I1-I5, I7) I1: small_segment_count now cumulative (not reset on normal segments) I2: Anomaly thresholds use > with fired flags (not exact ==). Named constants OVERLAP_ALERT_THRESHOLD (50) and SMALL_SEGMENT_ALERT_THRESHOLD (2048). I3: Add max_flows (100K) to ReassemblyConfig. Eviction triggers when flow count exceeds limit. I4: Add max_segments_per_direction (10K) to prevent BTreeMap overhead explosion from sparse insertions. I5: Eviction and finalize now flush contiguous data before removing flows. Salvageable data delivered to handler. Eviction loop also fixed from O(n^2) to O(n log n). I7: insert_segment returns DepthExceeded (not fake Inserted) when ISN is None. Added debug_assert. --- src/reassembly/flow.rs | 4 + src/reassembly/mod.rs | 121 ++++++++++++++++++++---------- src/reassembly/segment.rs | 15 +++- tests/reassembly_segment_tests.rs | 36 ++++----- 4 files changed, 116 insertions(+), 60 deletions(-) diff --git a/src/reassembly/flow.rs b/src/reassembly/flow.rs index 79a8f8b..242cff8 100644 --- a/src/reassembly/flow.rs +++ b/src/reassembly/flow.rs @@ -60,7 +60,9 @@ pub struct FlowDirection { pub segments: BTreeMap>, pub reassembled_bytes: usize, pub overlap_count: u32, + pub overlap_alert_fired: bool, pub small_segment_count: u32, + pub small_segment_alert_fired: bool, pub fin_seen: bool, pub rst_seen: bool, pub depth_exceeded: bool, @@ -80,7 +82,9 @@ impl FlowDirection { segments: BTreeMap::new(), reassembled_bytes: 0, overlap_count: 0, + overlap_alert_fired: false, small_segment_count: 0, + small_segment_alert_fired: false, fin_seen: false, rst_seen: false, depth_exceeded: false, diff --git a/src/reassembly/mod.rs b/src/reassembly/mod.rs index 776dcb3..e258827 100644 --- a/src/reassembly/mod.rs +++ b/src/reassembly/mod.rs @@ -10,6 +10,9 @@ use crate::reassembly::flow::{FlowKey, FlowState, TcpFlow}; use crate::reassembly::handler::{CloseReason, StreamHandler}; use crate::reassembly::segment::{InsertResult, flush_contiguous, insert_segment}; +const OVERLAP_ALERT_THRESHOLD: u32 = 50; +const SMALL_SEGMENT_ALERT_THRESHOLD: u32 = 2048; + /// Configuration for the TCP reassembly engine. #[derive(Debug, Clone)] pub struct ReassemblyConfig { @@ -19,14 +22,20 @@ pub struct ReassemblyConfig { pub memcap: usize, /// Seconds of inactivity before a flow is considered timed out. pub flow_timeout_secs: u32, + /// Maximum number of concurrent flows tracked. Prevents flow table flooding. + pub max_flows: usize, + /// Maximum segments per flow direction. Prevents BTreeMap overhead explosion. + pub max_segments_per_direction: usize, } impl Default for ReassemblyConfig { fn default() -> Self { ReassemblyConfig { - max_depth: 10 * 1024 * 1024, // 10 MB per direction - memcap: 1024 * 1024 * 1024, // 1 GB total - flow_timeout_secs: 300, // 5 minutes + max_depth: 10 * 1024 * 1024, // 10 MB per direction + memcap: 1024 * 1024 * 1024, // 1 GB total + flow_timeout_secs: 300, // 5 minutes + max_flows: 100_000, // 100K concurrent flows + max_segments_per_direction: 10_000, // 10K segments per direction } } } @@ -105,6 +114,14 @@ impl TcpReassembler { // 4. Get or create flow if !self.flows.contains_key(&key) { + // Enforce max_flows limit + if self.flows.len() >= self.config.max_flows { + self.evict_flows(handler); + if self.flows.len() >= self.config.max_flows { + // Still at capacity after eviction — drop this packet + return; + } + } let flow = TcpFlow::new(key.clone(), timestamp); self.flows.insert(key.clone(), flow); self.stats.flows_total += 1; @@ -178,7 +195,13 @@ impl TcpReassembler { } let flow_dir = flow.get_direction_mut(dir); - let result = insert_segment(flow_dir, seq, payload, self.config.max_depth); + let result = insert_segment( + flow_dir, + seq, + payload, + self.config.max_depth, + self.config.max_segments_per_direction, + ); match result { InsertResult::Inserted => self.stats.segments_inserted += 1, @@ -203,34 +226,35 @@ impl TcpReassembler { // Check anomaly thresholds on the direction let flow = self.flows.get_mut(&key).unwrap(); let flow_dir = flow.get_direction_mut(dir); - if flow_dir.overlap_count == 51 { + if flow_dir.overlap_count > OVERLAP_ALERT_THRESHOLD && !flow_dir.overlap_alert_fired { + flow_dir.overlap_alert_fired = true; self.findings.push(Finding { category: ThreatCategory::Anomaly, - verdict: Verdict::Inconclusive, + verdict: Verdict::Likely, confidence: Confidence::Medium, summary: format!( "Excessive segment overlaps ({}) on flow {}", flow_dir.overlap_count, key ), - evidence: vec![format!("overlap_count = {}", flow_dir.overlap_count)], - mitre_technique: None, + evidence: vec!["Possible evasion attempt".into()], + mitre_technique: Some("T1036".into()), source_ip: Some(packet.src_ip), timestamp: None, }); } - if flow_dir.small_segment_count == 2049 { + if flow_dir.small_segment_count > SMALL_SEGMENT_ALERT_THRESHOLD + && !flow_dir.small_segment_alert_fired + { + flow_dir.small_segment_alert_fired = true; self.findings.push(Finding { category: ThreatCategory::Anomaly, verdict: Verdict::Inconclusive, - confidence: Confidence::Low, + confidence: Confidence::Medium, summary: format!( "Excessive small segments ({}) on flow {}", flow_dir.small_segment_count, key ), - evidence: vec![format!( - "small_segment_count = {}", - flow_dir.small_segment_count - )], + evidence: vec!["Possible IDS evasion".into()], mitre_technique: None, source_ip: Some(packet.src_ip), timestamp: None, @@ -290,8 +314,19 @@ impl TcpReassembler { /// Close all remaining flows (called at end of capture). pub fn finalize(&mut self, handler: &mut dyn StreamHandler) { + use crate::reassembly::handler::Direction; let all_keys: Vec = self.flows.keys().cloned().collect(); for key in all_keys { + // Flush any remaining contiguous data before closing + if let Some(flow) = self.flows.get_mut(&key) { + for dir in [Direction::ClientToServer, Direction::ServerToClient] { + let flow_dir = flow.get_direction_mut(dir); + let flushed = flush_contiguous(flow_dir); + for (offset, data) in &flushed { + handler.on_data(&key, dir, data, *offset); + } + } + } self.flows.remove(&key); handler.on_flow_close(&key, CloseReason::Timeout); } @@ -318,32 +353,42 @@ impl TcpReassembler { /// Strategy: evict non-established flows first (sorted by LRU), /// then established flows by LRU. fn evict_flows(&mut self, handler: &mut dyn StreamHandler) { - while self.total_memory > self.config.memcap && !self.flows.is_empty() { - // Collect candidate keys with their (is_established, last_seen) for sorting - let mut candidates: Vec<(FlowKey, bool, u32)> = self - .flows - .iter() - .map(|(key, flow)| { - let is_established = flow.state == FlowState::Established; - (key.clone(), is_established, flow.last_seen) - }) - .collect(); - - // Sort: non-established first, then by oldest last_seen - candidates.sort_by(|a, b| { - a.1.cmp(&b.1) // false (non-established) < true (established) - .then(a.2.cmp(&b.2)) // older first - }); - - if let Some((key, _, _)) = candidates.first() { - let key = key.clone(); - self.flows.remove(&key); - self.stats.evictions += 1; - handler.on_flow_close(&key, CloseReason::MemoryPressure); - self.update_memory(); - } else { + // Sort once, then evict from the sorted list until under memcap + let mut candidates: Vec<(FlowKey, bool, u32)> = self + .flows + .iter() + .map(|(key, flow)| { + let is_established = flow.state == FlowState::Established; + (key.clone(), is_established, flow.last_seen) + }) + .collect(); + + // Sort: non-established first, then by oldest last_seen + candidates.sort_by(|a, b| { + a.1.cmp(&b.1) // false (non-established) < true (established) + .then(a.2.cmp(&b.2)) // older first + }); + + for (key, _, _) in &candidates { + if self.total_memory <= self.config.memcap && self.flows.len() <= self.config.max_flows + { break; } + // Flush salvageable contiguous data before evicting + if let Some(flow) = self.flows.get_mut(key) { + use crate::reassembly::handler::Direction; + for dir in [Direction::ClientToServer, Direction::ServerToClient] { + let flow_dir = flow.get_direction_mut(dir); + let flushed = flush_contiguous(flow_dir); + for (offset, data) in &flushed { + handler.on_data(key, dir, data, *offset); + } + } + } + self.flows.remove(key); + self.stats.evictions += 1; + handler.on_flow_close(key, CloseReason::MemoryPressure); + self.update_memory(); } } diff --git a/src/reassembly/segment.rs b/src/reassembly/segment.rs index 08d6f70..d74fead 100644 --- a/src/reassembly/segment.rs +++ b/src/reassembly/segment.rs @@ -22,6 +22,7 @@ pub fn insert_segment( seq: u32, data: &[u8], max_depth: usize, + max_segments: usize, ) -> InsertResult { if data.is_empty() { return InsertResult::Inserted; @@ -29,14 +30,20 @@ pub fn insert_segment( let isn = match dir.isn { Some(isn) => isn, - None => return InsertResult::Inserted, + None => { + debug_assert!(false, "insert_segment called with no ISN set"); + return InsertResult::DepthExceeded; + } }; - // Track small segments + // Enforce max segments per direction to prevent BTreeMap overhead explosion + if dir.segments.len() >= max_segments { + return InsertResult::DepthExceeded; + } + + // Track small segments (cumulative, not consecutive) if data.len() < 8 { dir.small_segment_count += 1; - } else { - dir.small_segment_count = 0; } // Check depth limit diff --git a/tests/reassembly_segment_tests.rs b/tests/reassembly_segment_tests.rs index 846dcd3..1ab0d12 100644 --- a/tests/reassembly_segment_tests.rs +++ b/tests/reassembly_segment_tests.rs @@ -6,7 +6,7 @@ fn test_insert_single_segment() { let mut dir = FlowDirection::new(); dir.set_isn(1000); - let result = insert_segment(&mut dir, 1001, b"hello", 10_485_760); + let result = insert_segment(&mut dir, 1001, b"hello", 10_485_760, 10_000); assert_eq!(result, InsertResult::Inserted); assert_eq!(dir.segments.len(), 1); assert_eq!(dir.segments.get(&1), Some(&b"hello".to_vec())); @@ -17,7 +17,7 @@ fn test_flush_contiguous_single() { let mut dir = FlowDirection::new(); dir.set_isn(1000); - insert_segment(&mut dir, 1001, b"hello", 10_485_760); + insert_segment(&mut dir, 1001, b"hello", 10_485_760, 10_000); let flushed = flush_contiguous(&mut dir); assert_eq!(flushed.len(), 1); @@ -33,8 +33,8 @@ fn test_flush_contiguous_ordered() { let mut dir = FlowDirection::new(); dir.set_isn(1000); - insert_segment(&mut dir, 1001, b"aaa", 10_485_760); - insert_segment(&mut dir, 1004, b"bbb", 10_485_760); + insert_segment(&mut dir, 1001, b"aaa", 10_485_760, 10_000); + insert_segment(&mut dir, 1004, b"bbb", 10_485_760, 10_000); let flushed = flush_contiguous(&mut dir); assert_eq!(flushed.len(), 2); @@ -50,12 +50,12 @@ fn test_out_of_order_buffering() { dir.set_isn(1000); // Insert segment 2 first (out of order) - insert_segment(&mut dir, 1004, b"bbb", 10_485_760); + insert_segment(&mut dir, 1004, b"bbb", 10_485_760, 10_000); let flushed = flush_contiguous(&mut dir); assert!(flushed.is_empty()); // Can't flush — gap at offset 1 // Now insert segment 1 - insert_segment(&mut dir, 1001, b"aaa", 10_485_760); + insert_segment(&mut dir, 1001, b"aaa", 10_485_760, 10_000); let flushed = flush_contiguous(&mut dir); assert_eq!(flushed.len(), 2); // Both flush now assert_eq!(flushed[0].1, b"aaa"); @@ -68,8 +68,8 @@ fn test_retransmission_dedup() { let mut dir = FlowDirection::new(); dir.set_isn(1000); - insert_segment(&mut dir, 1001, b"hello", 10_485_760); - let result = insert_segment(&mut dir, 1001, b"hello", 10_485_760); + insert_segment(&mut dir, 1001, b"hello", 10_485_760, 10_000); + let result = insert_segment(&mut dir, 1001, b"hello", 10_485_760, 10_000); assert_eq!(result, InsertResult::Duplicate); assert_eq!(dir.segments.len(), 1); // No duplicate stored } @@ -80,10 +80,10 @@ fn test_overlap_first_wins() { dir.set_isn(1000); // Insert "AAABBB" at offset 1 - insert_segment(&mut dir, 1001, b"AAABBB", 10_485_760); + insert_segment(&mut dir, 1001, b"AAABBB", 10_485_760, 10_000); // Overlapping insert: "XXXCC" at offset 4 (overlaps with "BBB" at 4-6) - let result = insert_segment(&mut dir, 1004, b"XXXCC", 10_485_760); + let result = insert_segment(&mut dir, 1004, b"XXXCC", 10_485_760, 10_000); assert_eq!(result, InsertResult::PartialOverlap); assert_eq!(dir.overlap_count, 1); @@ -101,10 +101,10 @@ fn test_overlap_conflicting_data_detected() { let mut dir = FlowDirection::new(); dir.set_isn(1000); - insert_segment(&mut dir, 1001, b"AAAA", 10_485_760); + insert_segment(&mut dir, 1001, b"AAAA", 10_485_760, 10_000); // Same range, different data - let result = insert_segment(&mut dir, 1001, b"BBBB", 10_485_760); + let result = insert_segment(&mut dir, 1001, b"BBBB", 10_485_760, 10_000); assert_eq!(result, InsertResult::ConflictingOverlap); assert_eq!(dir.overlap_count, 1); @@ -120,11 +120,11 @@ fn test_sequence_wraparound() { dir.set_isn(0xFFFF_FFF0); // First data byte at ISN+1 = 0xFFFF_FFF1, offset = 1 - insert_segment(&mut dir, 0xFFFF_FFF1, b"before", 10_485_760); + insert_segment(&mut dir, 0xFFFF_FFF1, b"before", 10_485_760, 10_000); // Next segment wraps: seq = 0xFFFF_FFF1 + 6 = 0xFFFF_FFF7, offset = 7 - insert_segment(&mut dir, 0xFFFF_FFF7, b"wrap", 10_485_760); + insert_segment(&mut dir, 0xFFFF_FFF7, b"wrap", 10_485_760, 10_000); // Another after wrap: seq = 0xFFFF_FFFB, offset = 11 - insert_segment(&mut dir, 0xFFFF_FFFB, b"around", 10_485_760); + insert_segment(&mut dir, 0xFFFF_FFFB, b"around", 10_485_760, 10_000); let flushed = flush_contiguous(&mut dir); let all_bytes: Vec = flushed @@ -142,7 +142,7 @@ fn test_small_segment_tracking() { // Insert small segments for i in 0..5u32 { let seq = 1001 + i; - insert_segment(&mut dir, seq, b"a", 10_485_760); + insert_segment(&mut dir, seq, b"a", 10_485_760, 10_000); } assert_eq!(dir.small_segment_count, 5); @@ -155,14 +155,14 @@ fn test_depth_limit_truncation() { let max_depth: usize = 100; // small for testing let data = vec![b'A'; 80]; - insert_segment(&mut dir, 1001, &data, max_depth); + insert_segment(&mut dir, 1001, &data, max_depth, 10_000); flush_contiguous(&mut dir); assert_eq!(dir.reassembled_bytes, 80); assert!(!dir.depth_exceeded); // This should be truncated to 20 bytes let data2 = vec![b'B'; 50]; - let result = insert_segment(&mut dir, 1081, &data2, max_depth); + let result = insert_segment(&mut dir, 1081, &data2, max_depth, 10_000); assert_eq!(result, InsertResult::Truncated); assert!(dir.depth_exceeded); From 821634e0f9461c45c6d5805114fef85d4dcb1f5f Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:45:06 -0500 Subject: [PATCH 15/18] fix: FIN close ordering and expire_flows flush consistency - FIN on_flow_close now fires AFTER payload processing (not before), so stream handlers receive all data before the close notification - expire_flows now flushes contiguous data before removing flows, consistent with evict_flows and finalize --- src/reassembly/mod.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/reassembly/mod.rs b/src/reassembly/mod.rs index e258827..a38eecf 100644 --- a/src/reassembly/mod.rs +++ b/src/reassembly/mod.rs @@ -165,13 +165,8 @@ impl TcpReassembler { let dir = flow.direction(packet.src_ip, src_port); flow.get_direction_mut(dir).fin_seen = true; flow.on_fin(); - if flow.state == FlowState::Closed { - self.stats.flows_fin += 1; - let key_clone = key.clone(); - handler.on_flow_close(&key_clone, CloseReason::Fin); - // Remove fully closed flow — but continue processing payload below - // if this FIN packet carries data (payload handled after this block) - } + // Note: if state is now Closed (both FINs seen), the flow will be + // removed after payload processing below (step 10). } // 9. Handle payload @@ -278,6 +273,8 @@ impl TcpReassembler { .get(&key) .is_some_and(|f| f.state == FlowState::Closed) { + self.stats.flows_fin += 1; + handler.on_flow_close(&key, CloseReason::Fin); self.flows.remove(&key); } @@ -304,6 +301,17 @@ impl TcpReassembler { .collect(); for key in expired_keys { + // Flush salvageable data before removing + if let Some(flow) = self.flows.get_mut(&key) { + use crate::reassembly::handler::Direction; + for dir in [Direction::ClientToServer, Direction::ServerToClient] { + let flow_dir = flow.get_direction_mut(dir); + let flushed = flush_contiguous(flow_dir); + for (offset, data) in &flushed { + handler.on_data(&key, dir, data, *offset); + } + } + } self.flows.remove(&key); self.stats.flows_expired += 1; handler.on_flow_close(&key, CloseReason::Timeout); From 4912e038f22a69d7f6609a67202e58a7c38deeb3 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:46:34 -0500 Subject: [PATCH 16/18] =?UTF-8?q?fix:=20re-review=20findings=20=E2=80=94?= =?UTF-8?q?=20flush=20consistency=20and=20security=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RST path now flushes buffered contiguous data before removal (consistent with evict/finalize/expire paths) - FIN-closed removal now flushes both directions before removal (opposite direction's buffered data was silently lost) - Gap insertion in overlap handling now checks max_segments limit per insert (prevents bypass via single large overlapping segment) - fin_count uses saturating_add to prevent u8 overflow panic --- src/reassembly/flow.rs | 2 +- src/reassembly/mod.rs | 27 +++++++++++++++++++++++++-- src/reassembly/segment.rs | 4 ++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/reassembly/flow.rs b/src/reassembly/flow.rs index 242cff8..08b768f 100644 --- a/src/reassembly/flow.rs +++ b/src/reassembly/flow.rs @@ -182,7 +182,7 @@ impl TcpFlow { } pub fn on_fin(&mut self) { - self.fin_count += 1; + self.fin_count = self.fin_count.saturating_add(1); if self.fin_count >= 2 { self.state = FlowState::Closed; } else if self.state == FlowState::Established || self.state == FlowState::SynSent { diff --git a/src/reassembly/mod.rs b/src/reassembly/mod.rs index a38eecf..34e7a6c 100644 --- a/src/reassembly/mod.rs +++ b/src/reassembly/mod.rs @@ -148,12 +148,23 @@ impl TcpReassembler { flow.on_syn_ack(); } - // 7. Handle RST — close and remove flow immediately + // 7. Handle RST — flush salvageable data, close, and remove if rst { flow.on_rst(); self.stats.flows_rst += 1; - // Drop the mutable borrow before removing let key_clone = key.clone(); + // Flush buffered contiguous data before removing + if let Some(flow) = self.flows.get_mut(&key_clone) { + use crate::reassembly::handler::Direction; + for dir in [Direction::ClientToServer, Direction::ServerToClient] { + let flow_dir = flow.get_direction_mut(dir); + let flushed = flush_contiguous(flow_dir); + for (offset, data) in &flushed { + self.stats.bytes_reassembled += data.len() as u64; + handler.on_data(&key_clone, dir, data, *offset); + } + } + } handler.on_flow_close(&key_clone, CloseReason::Rst); self.flows.remove(&key_clone); self.update_memory(); @@ -273,6 +284,18 @@ impl TcpReassembler { .get(&key) .is_some_and(|f| f.state == FlowState::Closed) { + // Flush remaining data in both directions before removal + if let Some(flow) = self.flows.get_mut(&key) { + use crate::reassembly::handler::Direction; + for dir in [Direction::ClientToServer, Direction::ServerToClient] { + let flow_dir = flow.get_direction_mut(dir); + let flushed = flush_contiguous(flow_dir); + for (offset, data) in &flushed { + self.stats.bytes_reassembled += data.len() as u64; + handler.on_data(&key, dir, data, *offset); + } + } + } self.stats.flows_fin += 1; handler.on_flow_close(&key, CloseReason::Fin); self.flows.remove(&key); diff --git a/src/reassembly/segment.rs b/src/reassembly/segment.rs index d74fead..409edcf 100644 --- a/src/reassembly/segment.rs +++ b/src/reassembly/segment.rs @@ -143,6 +143,10 @@ pub fn insert_segment( let had_gap = !gaps.is_empty(); for (gap_start, gap_end) in gaps { + // Enforce max_segments inside gap insertion loop + if dir.segments.len() >= max_segments { + break; + } let start_idx = (gap_start - new_start) as usize; let end_idx = (gap_end - new_start) as usize; if start_idx < segment_data.len() && end_idx <= segment_data.len() { From 0f9140dd1da7cc6829edd7d4143d3e31e9765ed2 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 10:57:48 -0500 Subject: [PATCH 17/18] chore: quick-win review suggestions and tech debt S4: Cap findings vector at 10K to prevent unbounded growth S6: Validate ReassemblyConfig (assert non-zero values) S10: Add conflicts_with for --reassemble/--no-reassemble TD1: Remove dead FlowState::TimedOut variant TD3: Collapse initiator_ip/initiator_port into Option<(IpAddr, u16)> --- src/cli.rs | 2 +- src/reassembly/flow.rs | 14 +++++--------- src/reassembly/mod.rs | 20 +++++++++++++++++++- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 913f994..44a2dd1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -36,7 +36,7 @@ pub struct Cli { pub csv: Option>, /// Force TCP stream reassembly on - #[arg(long, global = true)] + #[arg(long, global = true, conflicts_with = "no_reassemble")] pub reassemble: bool, /// Force TCP stream reassembly off (quick scan) diff --git a/src/reassembly/flow.rs b/src/reassembly/flow.rs index 08b768f..8bc3512 100644 --- a/src/reassembly/flow.rs +++ b/src/reassembly/flow.rs @@ -50,7 +50,6 @@ pub enum FlowState { Established, Closing, Closed, - TimedOut, } #[derive(Debug)] @@ -119,8 +118,7 @@ pub struct TcpFlow { pub partial: bool, pub first_seen: u32, pub last_seen: u32, - initiator_ip: Option, - initiator_port: Option, + initiator: Option<(IpAddr, u16)>, fin_count: u8, } @@ -134,21 +132,19 @@ impl TcpFlow { partial: false, first_seen: timestamp, last_seen: timestamp, - initiator_ip: None, - initiator_port: None, + initiator: None, fin_count: 0, } } pub fn set_initiator(&mut self, ip: IpAddr, port: u16) { - if self.initiator_ip.is_none() { - self.initiator_ip = Some(ip); - self.initiator_port = Some(port); + if self.initiator.is_none() { + self.initiator = Some((ip, port)); } } pub fn direction(&self, src_ip: IpAddr, src_port: u16) -> Direction { - if self.initiator_ip == Some(src_ip) && self.initiator_port == Some(src_port) { + if self.initiator == Some((src_ip, src_port)) { Direction::ClientToServer } else { Direction::ServerToClient diff --git a/src/reassembly/mod.rs b/src/reassembly/mod.rs index 34e7a6c..5927078 100644 --- a/src/reassembly/mod.rs +++ b/src/reassembly/mod.rs @@ -12,6 +12,7 @@ use crate::reassembly::segment::{InsertResult, flush_contiguous, insert_segment} const OVERLAP_ALERT_THRESHOLD: u32 = 50; const SMALL_SEGMENT_ALERT_THRESHOLD: u32 = 2048; +const MAX_FINDINGS: usize = 10_000; /// Configuration for the TCP reassembly engine. #[derive(Debug, Clone)] @@ -69,6 +70,13 @@ pub struct TcpReassembler { impl TcpReassembler { pub fn new(config: ReassemblyConfig) -> Self { + assert!(config.max_depth > 0, "max_depth must be > 0"); + assert!(config.memcap > 0, "memcap must be > 0"); + assert!(config.max_flows > 0, "max_flows must be > 0"); + assert!( + config.max_segments_per_direction > 0, + "max_segments_per_direction must be > 0" + ); TcpReassembler { config, flows: HashMap::new(), @@ -232,7 +240,10 @@ impl TcpReassembler { // Check anomaly thresholds on the direction let flow = self.flows.get_mut(&key).unwrap(); let flow_dir = flow.get_direction_mut(dir); - if flow_dir.overlap_count > OVERLAP_ALERT_THRESHOLD && !flow_dir.overlap_alert_fired { + if flow_dir.overlap_count > OVERLAP_ALERT_THRESHOLD + && !flow_dir.overlap_alert_fired + && self.findings.len() < MAX_FINDINGS + { flow_dir.overlap_alert_fired = true; self.findings.push(Finding { category: ThreatCategory::Anomaly, @@ -250,6 +261,7 @@ impl TcpReassembler { } if flow_dir.small_segment_count > SMALL_SEGMENT_ALERT_THRESHOLD && !flow_dir.small_segment_alert_fired + && self.findings.len() < MAX_FINDINGS { flow_dir.small_segment_alert_fired = true; self.findings.push(Finding { @@ -424,6 +436,9 @@ impl TcpReassembler { } fn generate_conflicting_overlap_finding(&mut self, key: &FlowKey, src_ip: std::net::IpAddr) { + if self.findings.len() >= MAX_FINDINGS { + return; + } self.findings.push(Finding { category: ThreatCategory::Anomaly, verdict: Verdict::Likely, @@ -437,6 +452,9 @@ impl TcpReassembler { } fn generate_truncated_finding(&mut self, key: &FlowKey, src_ip: std::net::IpAddr) { + if self.findings.len() >= MAX_FINDINGS { + return; + } self.findings.push(Finding { category: ThreatCategory::Anomaly, verdict: Verdict::Inconclusive, From d827b48767edb3cb4eabb2a57bf283554227c1a6 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 11:03:22 -0500 Subject: [PATCH 18/18] test: add real pcap fixtures for smoke testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tls.pcap: Wireshark TLS capture (58 packets, Ethernet, link type 1) — WORKS - http.pcap: Wireshark HTTP capture (1 packet) — WORKS - segmented.pcap: Wireshark segmented TCP (Raw IP, link type 101) — FAILS (decoder only handles Ethernet) - http-ooo.pcap: Wireshark HTTP OOO (link type 113) — FAILS (same reason) Findings: wirerust only supports Ethernet encapsulation (link type 1). Raw IP (101), Linux cooked (113), and pcapng format are not supported. These are reader/decoder issues, not reassembly issues. --- tests/fixtures/http-ooo.pcap | Bin 0 -> 1209 bytes tests/fixtures/http.pcap | Bin 0 -> 247 bytes tests/fixtures/segmented.pcap | Bin 0 -> 33144 bytes tests/fixtures/tls.pcap | Bin 0 -> 25057 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/fixtures/http-ooo.pcap create mode 100644 tests/fixtures/http.pcap create mode 100644 tests/fixtures/segmented.pcap create mode 100644 tests/fixtures/tls.pcap diff --git a/tests/fixtures/http-ooo.pcap b/tests/fixtures/http-ooo.pcap new file mode 100644 index 0000000000000000000000000000000000000000..be0b5d2f8804729023f06fb5225a8b596310b383 GIT binary patch literal 1209 zcmbW0O>5Lp6oyX*t>f6KuDZU+?Dzk{-rbs&Zr1eIU46QD@aixfV zp!m^^b}fP%cOsRk-T4pdzEC%Ui+WCaZkcA$4h_6X?#J`qbI!f@*SAlbCAa|UIXndT zts3~f&uY`J25@7<|5yRgP^I++=-{wR(>vD$l>7L7U<=a{w{5#)T834vEW|SNWTxNs zWRQg|(Wq9c2%`n6#zOZRyYJxOp!-XjOYS>c09He4H4UzSGnoGeS zyaC8#zl0q_Y~!W^%P)8vrw1Y^ZsV-l#`19PyT|T}?A|!VeMY%oM$BMg^$f1wcAgFVWkw8@rJVTA~~FERY$u|*7#LX3>$S!49A*eu!BEy-`#^*#K;qYjwMh&N9QQj_E)RRtlbZ zDW%0FMU@Jn1u2OosS3{dc_l^pIlNpR`Ncr#^31%H{PN;bu%upYW^z$}aef-mqWoN5 eE}+`H)Z~)P{5&fK@6^-+UB{fvvQ%C!UM>K`Z%p3+ literal 0 HcmV?d00001 diff --git a/tests/fixtures/segmented.pcap b/tests/fixtures/segmented.pcap new file mode 100644 index 0000000000000000000000000000000000000000..f86e1038bb4aadb5b909b887ad2cfe5615b4fc26 GIT binary patch literal 33144 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/tls.pcap b/tests/fixtures/tls.pcap new file mode 100644 index 0000000000000000000000000000000000000000..a1c6bd4fb436af7e98d0e6085fb00c22d4628efc GIT binary patch literal 25057 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 NbklyD)*VyY{{w~yo%{d* literal 0 HcmV?d00001