Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ Inspired by [pcapper](https://github.com/SackOfHacks/pcapper) — reimagined for
## Features

- **One-pass triage** — hosts, services, protocols, and threat signals from pcap files
- **Protocol analysis** — DNS and HTTP traffic analysis with extensible analyzer framework
- **Protocol analysis** — DNS, HTTP, and TLS traffic analysis with extensible analyzer framework
- **HTTP forensics** — stream-level HTTP/1.x parsing with detection for path traversal, web shells, unusual methods, and anomalies
- **TCP stream reassembly** — forensic-grade reassembly engine with first-wins overlap policy, configurable depth/memory limits
- **TLS forensics** — ClientHello/ServerHello parsing, SNI extraction, JA3/JA3S fingerprinting, weak cipher and deprecated SSL 2.0/3.0 detection
- **TCP stream reassembly** — forensic-grade reassembly engine with first-wins overlap policy, configurable depth/memory/window limits
- **Multi-link-type support** — Ethernet, Raw IP, IPv4, IPv6, and Linux Cooked (SLL) pcap formats
- **Threat detection** — finding system with verdict/confidence scoring and MITRE ATT&CK mapping
- **Multiple outputs** — colored terminal, JSON export
Expand Down Expand Up @@ -85,7 +86,7 @@ Options:
--threats Run threat detection
--dns Analyze DNS traffic
--http Analyze HTTP traffic (auto-enables reassembly)
--tls Analyze TLS handshakes (coming soon)
--tls Analyze TLS handshakes (SNI, JA3/JA3S, weak ciphers, deprecated SSL)
--beacon Detect C2 beaconing patterns (coming soon)
-a, --all Run all analyzers
-f, --filter BPF filter expression
Expand All @@ -98,7 +99,7 @@ PCAP file → Reader → Decoder → Analyzers → Reporter
↓ ↓ ↓
DataLink ParsedPacket Findings
Reassembly Engine → StreamAnalyzers (HTTP)
Reassembly Engine → StreamDispatcher → StreamAnalyzers (HTTP, TLS)
Summary
```
Expand All @@ -108,6 +109,7 @@ PCAP file → Reader → Decoder → Analyzers → Reporter
| Reader | `pcap-file` | Parse pcap files (5 link types) |
| Decoder | `etherparse` | Zero-copy packet parsing |
| HTTP Parser | `httparse` | HTTP/1.x request/response parsing |
| TLS Parser | `tls-parser` | TLS handshake parsing, JA3/JA3S |
| Reassembly | (built-in) | TCP stream reassembly engine |
| CLI | `clap` | Argument parsing |
| Output | `owo-colors`, `serde_json` | Terminal + JSON |
Expand Down Expand Up @@ -144,7 +146,6 @@ impl ProtocolAnalyzer for MyAnalyzer {

See [open issues](https://github.com/Zious11/wirerust/issues) for planned features:

- TLS analyzer (JA3/JA4 fingerprinting)
- C2 beaconing detection
- CSV and SQLite export
- MITRE ATT&CK mapping
Expand Down
2 changes: 1 addition & 1 deletion src/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ fn classify(data: &[u8], flow_key: &FlowKey) -> DispatchTarget {
return DispatchTarget::Http;
}
// Port fallback for short data
let ports = [flow_key.lower_port, flow_key.upper_port];
let ports = [flow_key.lower_port(), flow_key.upper_port()];
if ports.contains(&443) || ports.contains(&8443) {
return DispatchTarget::Tls;
}
Expand Down
24 changes: 20 additions & 4 deletions src/reassembly/flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,29 @@ 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,
lower_ip: IpAddr,
lower_port: u16,
upper_ip: IpAddr,
upper_port: u16,
}

impl FlowKey {
pub fn lower_ip(&self) -> IpAddr {
self.lower_ip
}

pub fn lower_port(&self) -> u16 {
self.lower_port
}

pub fn upper_ip(&self) -> IpAddr {
self.upper_ip
}

pub fn upper_port(&self) -> u16 {
self.upper_port
}

pub fn new(ip_a: IpAddr, port_a: u16, ip_b: IpAddr, port_b: u16) -> Self {
// Canonicalize by (ip, port) tuple comparison — keeps IP+port paired together.
// This is critical: sorting independently would merge different connections.
Expand Down
147 changes: 42 additions & 105 deletions src/reassembly/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{InsertResult, flush_contiguous, insert_segment};
use crate::reassembly::segment::InsertResult;

const OVERLAP_ALERT_THRESHOLD: u32 = 50;
const SMALL_SEGMENT_ALERT_THRESHOLD: u32 = 2048;
Expand All @@ -27,6 +27,9 @@ pub struct ReassemblyConfig {
pub max_flows: usize,
/// Maximum segments per flow direction. Prevents BTreeMap overhead explosion.
pub max_segments_per_direction: usize,
/// Maximum distance (bytes) ahead of base_offset to accept a segment.
/// Segments beyond this are dropped. Default 1MB matches Suricata/Zeek/Snort.
pub max_receive_window: usize,
}

impl Default for ReassemblyConfig {
Expand All @@ -37,6 +40,7 @@ impl Default for ReassemblyConfig {
flow_timeout_secs: 300, // 5 minutes
max_flows: 100_000, // 100K concurrent flows
max_segments_per_direction: 10_000, // 10K segments per direction
max_receive_window: 1_048_576, // 1 MB forward window
}
}
}
Expand All @@ -55,6 +59,7 @@ pub struct ReassemblyStats {
pub segments_inserted: u64,
pub segments_duplicates: u64,
pub segments_overlaps: u64,
pub segments_out_of_window: u64,
pub bytes_reassembled: u64,
pub evictions: u64,
}
Expand All @@ -77,6 +82,10 @@ impl TcpReassembler {
config.max_segments_per_direction > 0,
"max_segments_per_direction must be > 0"
);
assert!(
config.max_receive_window > 0,
"max_receive_window must be > 0"
);
TcpReassembler {
config,
flows: HashMap::new(),
Expand Down Expand Up @@ -160,29 +169,7 @@ impl TcpReassembler {
if rst {
flow.on_rst();
self.stats.flows_rst += 1;
let key_clone = key.clone();
// Capture memory before flushing: total_memory still holds this flow's
// full contribution. Subtracting flow_mem after removal zeros it out.
let flow_mem = self
.flows
.get(&key_clone)
.expect("flow must exist before RST removal")
.memory_used();
// 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.total_memory -= flow_mem;
self.close_flow(&key, CloseReason::Rst, handler);
return;
}

Expand Down Expand Up @@ -217,12 +204,12 @@ impl TcpReassembler {

let flow_dir = flow.get_direction_mut(dir);
let before_insert = flow_dir.buffered_bytes;
let result = insert_segment(
flow_dir,
let result = flow_dir.insert_segment(
seq,
payload,
self.config.max_depth,
self.config.max_segments_per_direction,
self.config.max_receive_window,
);
debug_assert!(
flow_dir.buffered_bytes >= before_insert,
Expand Down Expand Up @@ -250,6 +237,9 @@ impl TcpReassembler {
InsertResult::DepthExceeded => {
// Already tracked in the direction
}
InsertResult::OutOfWindow => {
self.stats.segments_out_of_window += 1;
}
}

// Check anomaly thresholds on the direction
Expand Down Expand Up @@ -298,7 +288,7 @@ impl TcpReassembler {
let flow = self.flows.get_mut(&key).unwrap();
let flow_dir = flow.get_direction_mut(dir);
let before_flush = flow_dir.buffered_bytes;
let flushed = flush_contiguous(flow_dir);
let flushed = flow_dir.flush_contiguous();
self.total_memory -= before_flush - flow_dir.buffered_bytes;

for (offset, data) in &flushed {
Expand All @@ -313,28 +303,8 @@ impl TcpReassembler {
.get(&key)
.is_some_and(|f| f.state == FlowState::Closed)
{
// Capture memory before flushing (see RST handler comment for rationale)
let flow_mem = self
.flows
.get(&key)
.expect("flow must exist before FIN removal")
.memory_used();
// 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);
self.total_memory -= flow_mem;
self.close_flow(&key, CloseReason::Fin, handler);
}

// 12. Evict flows if memcap exceeded
Expand All @@ -357,52 +327,16 @@ impl TcpReassembler {
.collect();

for key in expired_keys {
let flow_mem = self
.flows
.get(&key)
.expect("expired flow must exist")
.memory_used();
// 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.total_memory -= flow_mem;
self.stats.flows_expired += 1;
handler.on_flow_close(&key, CloseReason::Timeout);
self.close_flow(&key, CloseReason::Timeout, handler);
}
}

/// 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<FlowKey> = self.flows.keys().cloned().collect();
for key in all_keys {
let flow_mem = self
.flows
.get(&key)
.expect("finalize flow must exist")
.memory_used();
// 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);
self.total_memory -= flow_mem;
handler.on_flow_close(&key, CloseReason::Timeout);
self.close_flow(&key, CloseReason::Timeout, handler);
}
}

Expand All @@ -423,6 +357,27 @@ impl TcpReassembler {

// --- Private helpers ---

/// Flush remaining contiguous data in both directions, remove the flow,
/// update memory accounting, and notify the handler.
fn close_flow(&mut self, key: &FlowKey, reason: CloseReason, handler: &mut dyn StreamHandler) {
use crate::reassembly::handler::Direction;
let Some(mut flow) = self.flows.remove(key) else {
debug_assert!(false, "close_flow called for non-existent key: {}", key);
return;
};
let flow_mem = flow.memory_used();
for dir in [Direction::ClientToServer, Direction::ServerToClient] {
let flow_dir = flow.get_direction_mut(dir);
let flushed = flow_dir.flush_contiguous();
for (offset, data) in &flushed {
self.stats.bytes_reassembled += data.len() as u64;
handler.on_data(key, dir, data, *offset);
}
}
self.total_memory -= flow_mem;
handler.on_flow_close(key, reason);
}

/// Evict flows when memcap is exceeded.
/// Strategy: evict non-established flows first (sorted by LRU),
/// then established flows by LRU.
Expand All @@ -448,26 +403,8 @@ impl TcpReassembler {
{
break;
}
let flow_mem = self
.flows
.get(key)
.expect("eviction candidate must exist")
.memory_used();
// 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.total_memory -= flow_mem;
self.stats.evictions += 1;
handler.on_flow_close(key, CloseReason::MemoryPressure);
self.close_flow(key, CloseReason::MemoryPressure, handler);
}
}

Expand Down
Loading