From a04b745af6bb4c9a16e149109bac0cda93120b9b Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 00:41:31 -0500 Subject: [PATCH 01/14] =?UTF-8?q?docs:=20ADR=200001=20=E2=80=94=20content-?= =?UTF-8?q?first=20stream=20protocol=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the routing pattern for multiple stream analyzers (HTTP, TLS, future SSH/SMB). Content-based detection is primary (TLS record header, HTTP method keywords), port hints as fallback. Validated against Zeek DPD, Suricata, and Wireshark approaches. --- .../adr/0001-content-first-stream-dispatch.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/adr/0001-content-first-stream-dispatch.md diff --git a/docs/adr/0001-content-first-stream-dispatch.md b/docs/adr/0001-content-first-stream-dispatch.md new file mode 100644 index 0000000..a0e01e3 --- /dev/null +++ b/docs/adr/0001-content-first-stream-dispatch.md @@ -0,0 +1,107 @@ +# ADR 0001: Content-First Stream Protocol Dispatch + +**Status:** Accepted +**Date:** 2026-04-07 +**Context:** Issue #2 (TLS ClientHello analyzer) requires routing reassembled TCP streams to multiple protocol analyzers. + +## Problem + +The TCP reassembly engine (`TcpReassembler::process_packet`) accepts a single `&mut dyn StreamHandler`. Currently only the HTTP analyzer uses it. Adding a TLS analyzer (and eventually SSH, SMB, etc.) requires a mechanism to route each flow's reassembled data to the correct analyzer. + +## Decision + +Implement a **content-first StreamDispatcher** that classifies flows by inspecting the first bytes of stream data, with port-based fallback when content is ambiguous. + +### Classification Logic + +On the first `on_data` call for a flow: + +1. **TLS**: `data.len() >= 5 && data[0] == 0x16 && data[1] == 0x03` (TLS record header: content type Handshake + SSL/TLS 3.x version family) +2. **HTTP**: First bytes match an HTTP method (`GET `, `POST `, `PUT `, `DELETE `, `HEAD `, `OPTIONS `, `PATCH `, `CONNECT `, `TRACE `) or response (`HTTP/`) +3. **Fallback**: If data is too short (< 5 bytes) or matches neither signature, use port hints: 443/8443 → TLS, 80/8080 → HTTP +4. **None**: If no match, data is dropped (not forwarded to any analyzer) + +The classification decision is cached per flow in a `HashMap`. + +### StreamDispatcher Struct + +```rust +pub struct StreamDispatcher { + routes: HashMap, + http: Option, + tls: Option, +} + +enum DispatchTarget { + Http, + Tls, + None, +} + +impl StreamHandler for StreamDispatcher { + fn on_data(&mut self, flow_key: &FlowKey, direction: Direction, data: &[u8], offset: u64) { + let target = self.routes.entry(flow_key.clone()).or_insert_with(|| { + classify(data, flow_key) + }); + match target { + DispatchTarget::Http => { /* forward to self.http */ } + DispatchTarget::Tls => { /* forward to self.tls */ } + DispatchTarget::None => {} + } + } + fn on_flow_close(&mut self, flow_key: &FlowKey, reason: CloseReason) { + // forward to cached analyzer, remove route entry + } +} +``` + +## Alternatives Considered + +### Port-Based Only + +Route by well-known port: 443 → TLS, 80 → HTTP. + +- **Pro:** Simplest implementation, zero content inspection overhead. +- **Con:** Misses TLS on non-standard ports (8443, 4443). Fails completely when protocols masquerade on other ports (TLS on port 80, HTTP on port 443). Zeek, Suricata, and Wireshark all moved beyond pure port-based detection for this reason. +- **Rejected:** Insufficient for real-world PCAP forensics. + +### Broadcast to All Analyzers + +Send all reassembled data to all enabled analyzers. Each self-filters. + +- **Pro:** No routing logic needed. +- **Con:** Every analyzer buffers all traffic. HTTP already buffers up to 64KB per flow direction — with N analyzers this multiplies memory usage. Suricata, Zeek, and Wireshark do not use this approach. +- **Rejected:** Unacceptable memory overhead. + +### Port-First Hybrid + +Check port first (fast path), content detection only for unknown ports. + +- **Pro:** Slightly faster for common case. +- **Con:** Misroutes masquerading traffic. If TLS runs on port 80, port check sends it to HTTP. Content detection must override port hints to handle this, making port-first ordering harmful rather than helpful. +- **Rejected:** Content-first is both more correct and equally simple. + +## Rationale + +- **Matches industry standard.** Zeek's Dynamic Protocol Detection, Suricata's protocol detection engine, and Wireshark's dissector table all use content-based detection as the primary mechanism with ports as hints/fallback. This was validated via Perplexity queries against current documentation. +- **Handles masquerading.** TLS on port 80, HTTP on port 443, and protocols on arbitrary ports are all correctly classified. +- **Minimal overhead.** Classification requires reading 5 bytes on the first data delivery per flow — negligible compared to reassembly and parsing costs. +- **TLS signature is unambiguous for TCP.** The 5-byte TLS record header (`0x16 0x03 0xNN` + 2-byte length) does not collide with any common TCP application protocol. All text-based protocols (HTTP, FTP, SMTP, SIP) start with ASCII bytes. Binary protocol collisions are practically negligible. +- **Extensible.** Adding SSH (first bytes `SSH-`), SMB (first bytes `\x00\x00`+NetBIOS), or other analyzers requires only adding a classification branch and an `Option` field. + +## Consequences + +- **New struct:** `StreamDispatcher` in `src/reassembly/handler.rs` or `src/dispatcher.rs`. +- **main.rs changes:** Replace direct `HttpAnalyzer` handler with `StreamDispatcher` wrapping both `Option` and `Option`. +- **Per-flow routing map:** Small memory overhead (~64 bytes per flow for the HashMap entry). Cleaned up on `on_flow_close`. +- **Future analyzers** register content signatures in the dispatcher rather than changing the reassembly engine. +- **Edge case:** If the first `on_data` delivery has < 5 bytes (extremely rare — requires pathological TCP segmentation), the dispatcher falls back to port hints. This matches Zeek's approach with its `dpd_buffer_size` parameter, though Zeek buffers up to 1024 bytes. For wirerust v1, single-delivery classification with port fallback is sufficient. + +## Validation + +This decision was validated through Perplexity queries on 2026-04-07: +- Zeek DPD architecture: content signatures primary, ports as hints +- Suricata protocol detection: content-based with app-layer auto-detection +- Wireshark dissector routing: content-based with "Decode As" port override +- TLS record header collision risk: negligible for TCP protocols +- Small initial segment handling: standard buffering, port fallback From 099a55a4ba0d57f4b3bcb61eeea0bacaae32ab3b Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 08:13:09 -0500 Subject: [PATCH 02/14] docs: add TLS ClientHello + ServerHello analyzer design spec (#2) Covers: StreamDispatcher (ADR 0001), TlsAnalyzer with JA3/JA3S fingerprinting, weak cipher detection, SNI extraction, TLS record buffering. Validated via Perplexity: tls-parser API, JA3 format, GREASE filtering, TLS 1.3 version handling, detection scope. --- .../2026-04-07-tls-clienthello-design.md | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-07-tls-clienthello-design.md diff --git a/docs/superpowers/specs/2026-04-07-tls-clienthello-design.md b/docs/superpowers/specs/2026-04-07-tls-clienthello-design.md new file mode 100644 index 0000000..0306840 --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-tls-clienthello-design.md @@ -0,0 +1,266 @@ +# TLS ClientHello + ServerHello Analyzer Design + +**Issue:** #2 — Add TLS ClientHello analyzer +**Scope:** `src/analyzer/tls.rs`, `src/dispatcher.rs`, changes to `src/main.rs`, new tests in `tests/tls_analyzer_tests.rs`. +**Dependencies:** `tls-parser` 0.12, `md-5` 0.11 +**Related:** ADR 0001 (content-first stream dispatch) + +## Problem + +wirerust has no TLS analysis capability. The `--tls` CLI flag exists but is unused. TLS handshake metadata (SNI, cipher suites, JA3 fingerprints) is critical for network forensics and threat detection, yet the current architecture only supports a single `StreamHandler` (HTTP), making it impossible to add stream-level analyzers without a routing mechanism. + +## Approach + +### 1. StreamDispatcher (ADR 0001) + +A content-first dispatcher that routes reassembled TCP stream data to the correct protocol analyzer. Implements `StreamHandler` and wraps `Option` + `Option`. + +**Classification on first `on_data` per flow:** + +1. If `data.len() >= 5 && data[0] == 0x16 && data[1] == 0x03` → TLS (record header: Handshake + SSL/TLS 3.x) +2. If first bytes match HTTP method (`GET `, `POST `, `PUT `, `DELETE `, `HEAD `, `OPTIONS `, `PATCH `, `CONNECT `, `TRACE `) or `HTTP/` → HTTP +3. If data too short (< 5 bytes) → fall back to port hints: 443/8443 → TLS, 80/8080 → HTTP +4. No match → `None` (data dropped) + +Decision cached per flow in `HashMap`. See ADR 0001 for full rationale. + +### 2. TlsAnalyzer + +Implements `StreamAnalyzer` (same trait as `HttpAnalyzer`). Parses ClientHello and ServerHello from reassembled TCP streams. + +**Per-flow state:** + +```rust +struct TlsFlowState { + buf: Vec, // Accumulates stream bytes for TLS record extraction + client_hello_seen: bool, + server_hello_seen: bool, +} +``` + +**TLS record extraction loop:** + +``` +on_data(flow_key, direction, data, offset): + 1. If both client_hello_seen and server_hello_seen for this flow → return early + 2. Append data to flow's buf (capped at 64KB) + 3. Loop: + a. buf.len() < 5 → break (need TLS record header) + b. content_type = buf[0] + c. record_len = u16::from_be_bytes(buf[3..5]) as usize + d. buf.len() < 5 + record_len → break (incomplete record) + e. If content_type != 0x16 (not handshake) → drain 5+record_len, continue + f. parse_tls_plaintext(&buf[..5+record_len]) + → Ok: iterate TlsMessage list: + - Handshake(ClientHello(ch)) → process_client_hello(ch, flow_key) + - Handshake(ServerHello(sh)) → process_server_hello(sh, flow_key) + - Other → ignore + → Err(Incomplete) → increment parse_errors, drain record, break + → Err(other) → increment parse_errors, clear buf, break + g. Drain 5+record_len from buf + 4. Stop buffering after both CH + SH seen for this flow +``` + +### 3. ClientHello Processing + +Extract and aggregate: +- **SNI**: From `TlsExtension::SNI` — hostname string. Counted in `HashMap`. +- **Cipher suites**: `ch.ciphers` as `Vec`. Counted by selected cipher (from ServerHello). +- **TLS version**: `ch.version.0` as u16. Counted in `HashMap`. +- **Extensions**: Parsed via `parse_tls_extensions(ch.ext)`. + +**JA3 computation:** + +``` +JA3_string = "{version},{ciphers},{extensions},{elliptic_curves},{ec_point_formats}" + +Where: + version = ch.version.0 as decimal string (e.g., "771") + ciphers = ch.ciphers, filtered GREASE, dash-separated decimals + extensions = extension type IDs in wire order, filtered GREASE, dash-separated + elliptic_curves = from TlsExtension::EllipticCurves, filtered GREASE, dash-separated + ec_point_formats = from TlsExtension::EcPointFormats, dash-separated + +GREASE filter: (id & 0x0F0F) == 0x0A0A (for u16 values) + TlsExtension::Grease(..) variant (for extensions) + +JA3 = hex(MD5(JA3_string)) +``` + +**Empty fields:** When EllipticCurves or EcPointFormats extensions are absent, those fields are empty strings (not omitted). This produces trailing commas, e.g., `771,ciphers,extensions,,`. The trailing commas are part of the string and contribute to the MD5 hash. This is the official JA3 specification. + +**TLS 1.3 version:** Use `ch.version.0` which gives the `legacy_version` field value (0x0303 = 771 for TLS 1.3). Do NOT use the `supported_versions` extension value (0x0304 = 772). This matches Wireshark's implementation and the principle of capturing literal packet contents. The `tls-parser` crate's `TlsClientHelloContents.version` already provides the legacy_version value. + +JA3 hashes counted in `HashMap`. + +**Weak cipher finding:** + +Scan `ch.ciphers` for NULL, anonymous, or export cipher suites. If found: + +- **Category:** `ThreatCategory::Anomaly` +- **Verdict:** `Verdict::Likely` +- **Confidence:** `Confidence::High` +- **MITRE:** `None` (no clean mapping for weak cipher negotiation) +- **Summary:** `"ClientHello offers weak cipher suites (NULL/anonymous/export)"` +- **Evidence:** List of weak cipher IDs found + +**Weak cipher identification:** + +A cipher suite is weak if any of: +- Name contains `NULL` (no encryption) +- Name contains `anon` (anonymous key exchange, no authentication) +- Name contains `EXPORT` (deliberately weakened) +- `TlsCipherSuiteID` matches known weak IDs: any suite with `WITH_NULL`, `_anon_`, or `EXPORT` in the IANA registry name + +The `tls-parser` crate provides cipher suite lookup via `TlsCipherSuite::from_id(id)` which returns an `Option<&TlsCipherSuite>` with a `name` field. For unknown cipher IDs (returns `None`), skip the weak check — only flag ciphers we can positively identify as weak. + +### 4. ServerHello Processing + +Extract: +- **Selected cipher**: `sh.cipher` (single `TlsCipherSuiteID`) +- **TLS version**: `sh.version.0` +- **Extensions**: Parsed via `parse_tls_extensions(sh.ext)` + +**JA3S computation:** + +``` +JA3S_string = "{version},{cipher},{extensions}" + +Where: + version = sh.version.0 as decimal string + cipher = sh.cipher.0 as decimal string (single value) + extensions = extension type IDs, dash-separated decimals + +JA3S = hex(MD5(JA3S_string)) +``` + +**TLS 1.3 version:** Same as JA3 — use `sh.version.0` (legacy_version = 771), not supported_versions extension. + +JA3S hashes counted in `HashMap`. + +**Weak cipher selection finding:** + +If `sh.cipher` is RC4, NULL, anonymous, or export: + +- **Category:** `ThreatCategory::Anomaly` +- **Verdict:** `Verdict::Likely` +- **Confidence:** `Confidence::Medium` +- **MITRE:** `None` +- **Summary:** `"ServerHello selected weak cipher suite ({name})"` +- **Evidence:** `"Selected cipher: {name} (0x{id:04x})"` + +Medium confidence (not High) because the server may be misconfigured rather than under attack. + +### 5. Aggregate State + +```rust +pub struct TlsAnalyzer { + flows: HashMap, + sni_counts: HashMap, + ja3_counts: HashMap, + ja3s_counts: HashMap, + version_counts: HashMap, + cipher_counts: HashMap, // Keyed by IANA name from ServerHello + handshakes_seen: u64, + parse_errors: u64, + all_findings: Vec, +} +``` + +### 6. `summarize()` Output + +```json +{ + "analyzer_name": "TLS", + "packets_analyzed": "", + "detail": { + "top_snis": ["example.com", "api.github.com", ...], + "ja3_hashes": {"e7d705a3...": 5, "abc123...": 2}, + "ja3s_hashes": {"def456...": 3}, + "tls_versions": {"771": 10, "772": 5}, + "cipher_suites": {"TLS_AES_128_GCM_SHA256": 8, ...}, + "parse_errors": 0 + } +} +``` + +`top_snis` is the top 20 SNIs by count (same pattern as HTTP's `top_hosts`). + +### 7. Public Accessors + +- `pub fn sni_counts(&self) -> &HashMap` +- `pub fn ja3_counts(&self) -> &HashMap` +- `pub fn ja3s_counts(&self) -> &HashMap` +- `pub fn version_counts(&self) -> &HashMap` +- `pub fn parse_error_count(&self) -> u64` +- `pub fn handshake_count(&self) -> u64` + +### 8. CLI Integration + +`src/main.rs` changes: +- Replace `HttpAnalyzer` + `NullHandler` with `StreamDispatcher` +- `--tls` or `--all` enables TLS in dispatcher +- `--tls` auto-enables reassembly (same pattern as `--http`) +- Collect `tls.findings()` and `tls.summarize()` in report output + +`src/cli.rs`: No changes (the `--tls` flag already exists at line 79). + +## Changes + +### New Files + +| File | Purpose | +|------|---------| +| `src/analyzer/tls.rs` | `TlsAnalyzer` implementing `StreamAnalyzer` | +| `src/dispatcher.rs` | `StreamDispatcher` implementing `StreamHandler` | +| `tests/tls_analyzer_tests.rs` | Unit tests with crafted TLS bytes | + +### Modified Files + +| File | Change | +|------|--------| +| `src/main.rs` | Use `StreamDispatcher` instead of direct analyzer, wire up `--tls` | +| `src/analyzer/mod.rs` | Add `pub mod tls` | +| `src/lib.rs` | Add `pub mod dispatcher` | +| `Cargo.toml` | Add `tls-parser = "0.12"`, `md-5 = "0.11"` | + +### No Changes To + +- Reassembly engine (`src/reassembly/`) +- HTTP analyzer (`src/analyzer/http.rs`) +- Finding struct (`src/findings.rs`) +- Reporter (`src/reporter/`) + +## Tests + +| Test | Description | +|------|-------------| +| `test_parse_client_hello` | Craft a minimal ClientHello, assert SNI extracted, JA3 computed | +| `test_parse_server_hello` | Craft a ServerHello, assert cipher and JA3S computed | +| `test_ja3_grease_filtering` | ClientHello with GREASE cipher suites, assert they're excluded from JA3 | +| `test_ja3_known_fingerprint` | Use a known ClientHello → verify JA3 hash matches published value | +| `test_weak_cipher_finding_client` | ClientHello with NULL cipher, assert finding generated | +| `test_weak_cipher_finding_server` | ServerHello selects RC4, assert finding generated | +| `test_normal_handshake_no_findings` | Valid modern handshake, assert no findings | +| `test_parse_error_counter` | Malformed TLS record, assert `parse_error_count() == 1` | +| `test_summarize_output` | Full handshake, assert `summarize()` contains SNI, JA3, version | +| `test_dispatcher_routes_tls` | Send TLS bytes through dispatcher, assert TLS analyzer receives them | +| `test_dispatcher_routes_http` | Send HTTP bytes through dispatcher, assert HTTP analyzer receives them | +| `test_dispatcher_content_detection` | TLS on port 80, assert dispatcher routes to TLS (not HTTP) | +| `test_stop_after_handshake` | Send handshake + application data, assert no parse errors from encrypted bytes | + +## Known Limitations (v1) + +- **No handshake message fragmentation across TLS records.** If a ClientHello/ServerHello spans multiple TLS records, it's counted as a parse error. This is vanishingly rare for these messages (both are well under 16KB record max). File follow-up issue. +- **No Certificate message parsing.** Self-signed detection requires Certificate message parsing (TLS 1.2 only). Out of scope per design decision — file as separate issue. +- **No JA4.** JA3 is the current standard. JA4 (sorts extensions deterministically) can be added as enhancement. +- **Port fallback is limited.** Only 443/8443 and 80/8080 in fallback list. Content detection handles all other cases. + +## Not In Scope + +- Changes to reassembly engine +- Changes to Finding struct +- Certificate parsing / self-signed detection +- JA4 fingerprinting +- Known malicious JA3 hash matching (requires threat intel feed) +- TLS decryption From f0801aea4ca45f5b803c6ced20072f675ddfd7d2 Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 08:15:12 -0500 Subject: [PATCH 03/14] =?UTF-8?q?docs:=20ADR=200002=20=E2=80=94=20modular?= =?UTF-8?q?=20protocol=20analyzer=20pattern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codifies the two-trait model (ProtocolAnalyzer for packet-level, StreamAnalyzer for stream-level), internal structure pattern (per-flow state, aggregate counters, findings, error tracking), and the steps to add a new analyzer. Documents existing DNS/HTTP analyzers and planned TLS analyzer. --- docs/adr/0002-modular-protocol-analyzers.md | 145 ++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/adr/0002-modular-protocol-analyzers.md diff --git a/docs/adr/0002-modular-protocol-analyzers.md b/docs/adr/0002-modular-protocol-analyzers.md new file mode 100644 index 0000000..656a8a3 --- /dev/null +++ b/docs/adr/0002-modular-protocol-analyzers.md @@ -0,0 +1,145 @@ +# ADR 0002: Modular Protocol Analyzer Pattern + +**Status:** Accepted +**Date:** 2026-04-07 +**Context:** wirerust has two analyzer patterns (DNS packet-level, HTTP stream-level) and is adding TLS. Codifying the pattern prevents drift as more analyzers are added. + +## Decision + +Protocol analyzers are self-contained modules that implement one of two traits depending on whether they operate on individual packets or reassembled TCP streams. + +### Two Trait Levels + +**Packet-level** — `ProtocolAnalyzer` trait. Receives individual parsed packets. No TCP reassembly required. Used for protocols that fit in a single packet (DNS over UDP, ARP, ICMP). + +```rust +pub trait ProtocolAnalyzer { + fn name(&self) -> &'static str; + fn can_decode(&self, packet: &ParsedPacket) -> bool; + fn analyze(&mut self, packet: &ParsedPacket) -> Vec; + fn summarize(&self) -> AnalysisSummary; +} +``` + +**Stream-level** — `StreamAnalyzer` trait (extends `StreamHandler`). Receives reassembled, ordered TCP stream data. Used for protocols that span multiple packets or require connection context (HTTP, TLS, SSH, SMB). + +```rust +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; +} +``` + +### Internal Structure Pattern + +Every analyzer follows the same internal structure: + +```rust +pub struct FooAnalyzer { + // 1. Per-flow state (stream analyzers only) + flows: HashMap, + + // 2. Aggregate counters (keyed by protocol-specific dimensions) + some_counts: HashMap, + + // 3. Findings + all_findings: Vec, + + // 4. Error tracking + parse_errors: u64, +} +``` + +**Per-flow state** tracks buffered data and parsing progress for each TCP connection. Cleaned up on `on_flow_close`. Stream analyzers only. + +**Aggregate counters** accumulate protocol-specific metrics across all flows (e.g., HTTP method counts, TLS SNI counts, DNS query counts). Bounded by `MAX_MAP_ENTRIES` to prevent memory exhaustion from cardinality explosion. + +**Findings** are security-relevant observations with category, verdict, confidence, optional MITRE technique, summary, and evidence. Accumulated in `Vec` and returned by `findings()`. + +**Error tracking** counts parse failures. Surfaced in `summarize()` output so users know if data was lost. Not logged to stderr — the counter is the signal. + +### Required Methods and Accessors + +| Method | Purpose | Required | +|--------|---------|----------| +| `new()` | Constructor with zero-initialized state | Yes | +| `name()` | Returns `&'static str` like `"HTTP"`, `"TLS"`, `"DNS"` | Yes | +| `summarize()` | Returns `AnalysisSummary` with `detail: HashMap` | Yes | +| `findings()` | Returns `Vec` | Yes (stream), via `analyze()` return (packet) | +| `parse_error_count()` | Returns `u64` | Yes | +| Domain-specific accessors | e.g., `sni_counts()`, `method_counts()` | For testing | + +### Adding a New Analyzer + +1. Create `src/analyzer/{protocol}.rs` +2. Implement `ProtocolAnalyzer` (packet-level) or `StreamAnalyzer` (stream-level) +3. Add `pub mod {protocol}` to `src/analyzer/mod.rs` +4. **Packet-level**: Add `can_decode` + `analyze` call in the packet loop in `main.rs` +5. **Stream-level**: Add `Option` to `StreamDispatcher`, add content signature to classification logic (ADR 0001) +6. Add CLI flag to `src/cli.rs` if needed (or reuse existing flag) +7. Wire up `findings()` and `summarize()` collection in `main.rs` +8. Add `tests/{protocol}_analyzer_tests.rs` + +### AnalysisSummary Format + +All analyzers produce the same output structure: + +```rust +pub struct AnalysisSummary { + pub analyzer_name: String, + pub packets_analyzed: u64, + pub detail: HashMap, +} +``` + +The `detail` map contains protocol-specific fields as `serde_json::Value`. This allows the reporter to render any analyzer's output without knowing its internal structure. The JSON reporter serializes it directly; the terminal reporter can pattern-match on known keys. + +### Finding Generation Guidelines + +- Generate findings only for **unambiguous security concerns** — not informational observations +- Follow the existing verdict/confidence model: `Likely`/`Inconclusive`/`Unlikely` x `High`/`Medium`/`Low` +- Include MITRE ATT&CK technique ID only when there's a clean mapping; `None` is better than a forced fit +- Include actionable evidence (specific values, not just "something was wrong") +- Cap findings with `MAX_FINDINGS` to prevent memory exhaustion on adversarial input + +## Alternatives Considered + +### Single Unified Trait + +One trait covering both packet-level and stream-level analyzers. + +- **Rejected:** Packet analyzers don't need `on_data`/`on_flow_close`, and stream analyzers don't need `can_decode`/`analyze(packet)`. A unified trait forces empty implementations. + +### Plugin System with Dynamic Loading + +Load analyzers as shared libraries at runtime. + +- **Rejected:** Premature. wirerust has 3 analyzers. Dynamic loading adds complexity (ABI stability, unsafe FFI) with no current benefit. Can revisit if the analyzer count grows significantly. + +### Analyzer Registry with Auto-Discovery + +Analyzers register themselves in a global registry (e.g., via `inventory` crate). + +- **Rejected:** Magic registration obscures control flow. Explicit wiring in `main.rs` is clearer and easier to debug. The number of analyzers is small enough that manual wiring is not a burden. + +## Consequences + +- **Consistency**: All analyzers follow the same pattern, making the codebase predictable for contributors. +- **Testability**: Public accessors on each analyzer enable direct unit testing without going through the full pipeline. +- **Isolation**: Each analyzer owns its state. No shared mutable state between analyzers. The dispatcher routes data; analyzers don't know about each other. +- **Bounded memory**: `MAX_MAP_ENTRIES` on counters, `MAX_FINDINGS` on findings, per-flow buffer caps — all analyzers must respect these. +- **Adding analyzers is cheap**: ~1 new file + trait impl + wiring in main.rs + dispatcher registration. No framework overhead. + +## Existing Analyzers + +| Analyzer | Trait | File | Since | +|----------|-------|------|-------| +| DNS | `ProtocolAnalyzer` | `src/analyzer/dns.rs` | v0.1.0 | +| HTTP | `StreamAnalyzer` | `src/analyzer/http.rs` | v0.1.0 | +| TLS | `StreamAnalyzer` | `src/analyzer/tls.rs` | Issue #2 (planned) | From b3fd6eebc2715c9031b64c1a5c7a25eff5db0f17 Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 08:26:45 -0500 Subject: [PATCH 04/14] docs: add TLS analyzer implementation plan (#2) 6 tasks: scaffolding, dispatcher tests, TLS record parsing + JA3, ServerHello + JA3S + weak cipher tests, summarize(), CLI integration. 14 total tests across 2 test files. --- .../plans/2026-04-07-tls-analyzer.md | 1485 +++++++++++++++++ 1 file changed, 1485 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-tls-analyzer.md diff --git a/docs/superpowers/plans/2026-04-07-tls-analyzer.md b/docs/superpowers/plans/2026-04-07-tls-analyzer.md new file mode 100644 index 0000000..43514d0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-tls-analyzer.md @@ -0,0 +1,1485 @@ +# TLS ClientHello + ServerHello Analyzer 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 a TLS analyzer that extracts SNI, computes JA3/JA3S fingerprints, and detects weak cipher suites, plus a StreamDispatcher to route reassembled TCP streams to the correct protocol analyzer. + +**Architecture:** A content-first `StreamDispatcher` (ADR 0001) routes flows to `HttpAnalyzer` or `TlsAnalyzer` based on first-byte inspection. `TlsAnalyzer` implements `StreamAnalyzer`, buffers TLS records per-flow, parses ClientHello/ServerHello via `tls-parser`, computes JA3/JA3S via `md-5`, and generates findings for weak ciphers. CLI wiring connects `--tls` flag to the dispatcher. + +**Tech Stack:** Rust 2024, tls-parser 0.12, md-5 0.11, nom (transitive via tls-parser) + +--- + +### Task 1: Add Dependencies and Module Scaffolding + +**Files:** +- Modify: `Cargo.toml` +- Modify: `src/lib.rs` +- Modify: `src/analyzer/mod.rs` +- Create: `src/analyzer/tls.rs` (stub) +- Create: `src/dispatcher.rs` (stub) + +- [ ] **Step 1: Add `tls-parser` and `md-5` to Cargo.toml** + +In `Cargo.toml`, add to `[dependencies]`: + +```toml +tls-parser = "0.12" +md-5 = "0.11" +``` + +- [ ] **Step 2: Add module declarations** + +In `src/lib.rs`, add at the end: + +```rust +pub mod dispatcher; +``` + +In `src/analyzer/mod.rs`, add: + +```rust +pub mod tls; +``` + +- [ ] **Step 3: Create stub `src/analyzer/tls.rs`** + +```rust +use std::collections::HashMap; + +use crate::analyzer::AnalysisSummary; +use crate::findings::Finding; +use crate::reassembly::flow::FlowKey; +use crate::reassembly::handler::{CloseReason, Direction, StreamAnalyzer, StreamHandler}; + +const MAX_BUF: usize = 65_536; +const MAX_MAP_ENTRIES: usize = 50_000; + +struct TlsFlowState { + buf: Vec, + client_hello_seen: bool, + server_hello_seen: bool, +} + +impl TlsFlowState { + fn new() -> Self { + TlsFlowState { + buf: Vec::new(), + client_hello_seen: false, + server_hello_seen: false, + } + } +} + +pub struct TlsAnalyzer { + flows: HashMap, + sni_counts: HashMap, + ja3_counts: HashMap, + ja3s_counts: HashMap, + version_counts: HashMap, + cipher_counts: HashMap, + handshakes_seen: u64, + parse_errors: u64, + all_findings: Vec, +} + +impl Default for TlsAnalyzer { + fn default() -> Self { + Self::new() + } +} + +impl TlsAnalyzer { + pub fn new() -> Self { + TlsAnalyzer { + flows: HashMap::new(), + sni_counts: HashMap::new(), + ja3_counts: HashMap::new(), + ja3s_counts: HashMap::new(), + version_counts: HashMap::new(), + cipher_counts: HashMap::new(), + handshakes_seen: 0, + parse_errors: 0, + all_findings: Vec::new(), + } + } + + pub fn sni_counts(&self) -> &HashMap { + &self.sni_counts + } + + pub fn ja3_counts(&self) -> &HashMap { + &self.ja3_counts + } + + pub fn ja3s_counts(&self) -> &HashMap { + &self.ja3s_counts + } + + pub fn version_counts(&self) -> &HashMap { + &self.version_counts + } + + pub fn parse_error_count(&self) -> u64 { + self.parse_errors + } + + pub fn handshake_count(&self) -> u64 { + self.handshakes_seen + } +} + +impl StreamHandler for TlsAnalyzer { + fn on_data(&mut self, _flow_key: &FlowKey, _direction: Direction, _data: &[u8], _offset: u64) { + // Will be implemented in Task 3 + } + + fn on_flow_close(&mut self, flow_key: &FlowKey, _reason: CloseReason) { + self.flows.remove(flow_key); + } +} + +impl StreamAnalyzer for TlsAnalyzer { + fn name(&self) -> &'static str { + "TLS" + } + + fn summarize(&self) -> AnalysisSummary { + AnalysisSummary { + analyzer_name: self.name().to_string(), + packets_analyzed: self.handshakes_seen, + detail: HashMap::new(), // Will be implemented in Task 6 + } + } + + fn findings(&self) -> Vec { + self.all_findings.clone() + } +} +``` + +- [ ] **Step 4: Create stub `src/dispatcher.rs`** + +```rust +use std::collections::HashMap; + +use crate::analyzer::http::HttpAnalyzer; +use crate::analyzer::tls::TlsAnalyzer; +use crate::reassembly::flow::FlowKey; +use crate::reassembly::handler::{CloseReason, Direction, StreamHandler}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DispatchTarget { + Http, + Tls, + None, +} + +pub struct StreamDispatcher { + routes: HashMap, + pub http: Option, + pub tls: Option, +} + +impl StreamDispatcher { + pub fn new(http: Option, tls: Option) -> Self { + StreamDispatcher { + routes: HashMap::new(), + http, + tls, + } + } +} + +fn classify(data: &[u8], flow_key: &FlowKey) -> DispatchTarget { + // Content-first detection + if data.len() >= 5 && data[0] == 0x16 && data[1] == 0x03 { + return DispatchTarget::Tls; + } + if data.starts_with(b"GET ") + || data.starts_with(b"POST ") + || data.starts_with(b"PUT ") + || data.starts_with(b"DELETE ") + || data.starts_with(b"HEAD ") + || data.starts_with(b"OPTIONS ") + || data.starts_with(b"PATCH ") + || data.starts_with(b"CONNECT ") + || data.starts_with(b"TRACE ") + || data.starts_with(b"HTTP/") + { + return DispatchTarget::Http; + } + // Port fallback for short data + let ports = [flow_key.lower_port, flow_key.upper_port]; + if ports.contains(&443) || ports.contains(&8443) { + return DispatchTarget::Tls; + } + if ports.contains(&80) || ports.contains(&8080) { + return DispatchTarget::Http; + } + DispatchTarget::None +} + +impl StreamHandler for StreamDispatcher { + fn on_data(&mut self, flow_key: &FlowKey, direction: Direction, data: &[u8], offset: u64) { + let target = *self + .routes + .entry(flow_key.clone()) + .or_insert_with(|| classify(data, flow_key)); + + match target { + DispatchTarget::Http => { + if let Some(ref mut http) = self.http { + http.on_data(flow_key, direction, data, offset); + } + } + DispatchTarget::Tls => { + if let Some(ref mut tls) = self.tls { + tls.on_data(flow_key, direction, data, offset); + } + } + DispatchTarget::None => {} + } + } + + fn on_flow_close(&mut self, flow_key: &FlowKey, reason: CloseReason) { + let target = self.routes.remove(flow_key); + match target { + Some(DispatchTarget::Http) => { + if let Some(ref mut http) = self.http { + http.on_flow_close(flow_key, reason); + } + } + Some(DispatchTarget::Tls) => { + if let Some(ref mut tls) = self.tls { + tls.on_flow_close(flow_key, reason); + } + } + _ => {} + } + } +} +``` + +- [ ] **Step 5: Verify compilation** + +Run: `cargo check 2>&1` + +Expected: Compiles with no errors (may have unused warnings — that's fine). + +- [ ] **Step 6: Commit** + +```bash +git add Cargo.toml src/lib.rs src/analyzer/mod.rs src/analyzer/tls.rs src/dispatcher.rs +git commit -m "scaffold: add TLS analyzer and StreamDispatcher stubs with dependencies" +``` + +--- + +### Task 2: StreamDispatcher Tests + +**Files:** +- Create: `tests/dispatcher_tests.rs` + +- [ ] **Step 1: Write dispatcher routing tests** + +```rust +use std::net::IpAddr; +use wirerust::analyzer::http::HttpAnalyzer; +use wirerust::analyzer::tls::TlsAnalyzer; +use wirerust::dispatcher::StreamDispatcher; +use wirerust::reassembly::flow::FlowKey; +use wirerust::reassembly::handler::{Direction, StreamHandler}; + +fn flow_key(src_port: u16, dst_port: u16) -> FlowKey { + FlowKey::new( + "10.0.0.1".parse::().unwrap(), + src_port, + "10.0.0.2".parse::().unwrap(), + dst_port, + ) +} + +#[test] +fn test_dispatcher_routes_tls() { + let mut dispatcher = StreamDispatcher::new(None, Some(TlsAnalyzer::new())); + let fk = flow_key(49152, 443); + + // TLS ClientHello record header: content_type=0x16, version=0x0303, length=0x0005 + let tls_data = [0x16, 0x03, 0x03, 0x00, 0x05, 0x01, 0x00, 0x00, 0x01, 0x00]; + dispatcher.on_data(&fk, Direction::ClientToServer, &tls_data, 0); + + // If routed correctly, TLS analyzer received data (no panic, no error) + // We can't directly assert routing, but we can verify HTTP didn't get it + assert!(dispatcher.http.is_none()); +} + +#[test] +fn test_dispatcher_routes_http() { + let mut dispatcher = StreamDispatcher::new(Some(HttpAnalyzer::new()), None); + let fk = flow_key(49152, 80); + + let http_data = b"GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\n"; + dispatcher.on_data(&fk, Direction::ClientToServer, http_data, 0); + + let http = dispatcher.http.as_ref().unwrap(); + assert_eq!(*http.method_counts().get("GET").unwrap(), 1); +} + +#[test] +fn test_dispatcher_content_detection_tls_on_port_80() { + let mut dispatcher = + StreamDispatcher::new(Some(HttpAnalyzer::new()), Some(TlsAnalyzer::new())); + let fk = flow_key(49152, 80); // Port 80, but content is TLS + + // TLS record header on port 80 — content detection should override port + let tls_data = [0x16, 0x03, 0x03, 0x00, 0x05, 0x01, 0x00, 0x00, 0x01, 0x00]; + dispatcher.on_data(&fk, Direction::ClientToServer, &tls_data, 0); + + // HTTP analyzer should NOT have received this data + let http = dispatcher.http.as_ref().unwrap(); + assert_eq!(http.method_counts().len(), 0); +} + +#[test] +fn test_dispatcher_port_fallback_short_data() { + let mut dispatcher = + StreamDispatcher::new(Some(HttpAnalyzer::new()), Some(TlsAnalyzer::new())); + let fk = flow_key(49152, 443); // Port 443 + + // Only 2 bytes — too short for content detection, falls back to port + let short_data = [0x16, 0x03]; + dispatcher.on_data(&fk, Direction::ClientToServer, &short_data, 0); + + // Should have routed to TLS based on port 443 + // HTTP should not have received it + let http = dispatcher.http.as_ref().unwrap(); + assert_eq!(http.method_counts().len(), 0); +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test --test dispatcher_tests 2>&1` + +Expected: All 4 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add tests/dispatcher_tests.rs +git commit -m "test: add StreamDispatcher routing tests" +``` + +--- + +### Task 3: TLS Record Parsing and ClientHello with JA3 + +**Files:** +- Modify: `src/analyzer/tls.rs` +- Create: `tests/tls_analyzer_tests.rs` + +This is the core task — implement the TLS record extraction loop, ClientHello processing, and JA3 computation. + +- [ ] **Step 1: Write the failing ClientHello test** + +Create `tests/tls_analyzer_tests.rs`: + +```rust +use std::net::IpAddr; +use wirerust::analyzer::tls::TlsAnalyzer; +use wirerust::reassembly::flow::FlowKey; +use wirerust::reassembly::handler::{Direction, StreamHandler}; + +fn test_flow_key() -> FlowKey { + FlowKey::new( + "10.0.0.1".parse::().unwrap(), + 49153, + "10.0.0.2".parse::().unwrap(), + 443, + ) +} + +/// Build a minimal TLS ClientHello record with SNI and specified cipher suites. +/// Returns the complete TLS record bytes (record header + handshake header + ClientHello body). +fn build_client_hello(sni: &str, cipher_ids: &[u16]) -> Vec { + // Extensions + let mut extensions = Vec::new(); + + // SNI extension (type 0x0000) + let sni_bytes = sni.as_bytes(); + let sni_list_len = (3 + sni_bytes.len()) as u16; // type(1) + name_len(2) + name + let sni_ext_len = (2 + sni_list_len) as u16; // list_len(2) + sni_list + extensions.extend_from_slice(&[0x00, 0x00]); // extension type: server_name + extensions.extend_from_slice(&sni_ext_len.to_be_bytes()); // extension data length + extensions.extend_from_slice(&sni_list_len.to_be_bytes()); // server name list length + extensions.push(0x00); // host_name type + extensions.extend_from_slice(&(sni_bytes.len() as u16).to_be_bytes()); + extensions.extend_from_slice(sni_bytes); + + // Supported Groups extension (type 0x000a) — x25519 (0x001d), secp256r1 (0x0017) + extensions.extend_from_slice(&[0x00, 0x0a]); // extension type: supported_groups + extensions.extend_from_slice(&[0x00, 0x06]); // extension data length + extensions.extend_from_slice(&[0x00, 0x04]); // named group list length + extensions.extend_from_slice(&[0x00, 0x1d]); // x25519 + extensions.extend_from_slice(&[0x00, 0x17]); // secp256r1 + + // EC Point Formats extension (type 0x000b) — uncompressed (0x00) + extensions.extend_from_slice(&[0x00, 0x0b]); // extension type: ec_point_formats + extensions.extend_from_slice(&[0x00, 0x02]); // extension data length + extensions.push(0x01); // ec point formats length + extensions.push(0x00); // uncompressed + + // Build ClientHello body + let mut ch_body = Vec::new(); + ch_body.extend_from_slice(&[0x03, 0x03]); // version: TLS 1.2 + ch_body.extend_from_slice(&[0u8; 32]); // random + ch_body.push(0x00); // session_id length: 0 + + // Cipher suites + let ciphers_len = (cipher_ids.len() * 2) as u16; + ch_body.extend_from_slice(&ciphers_len.to_be_bytes()); + for &id in cipher_ids { + ch_body.extend_from_slice(&id.to_be_bytes()); + } + + ch_body.push(0x01); // compression methods length + ch_body.push(0x00); // null compression + + // Extensions + let ext_len = extensions.len() as u16; + ch_body.extend_from_slice(&ext_len.to_be_bytes()); + ch_body.extend_from_slice(&extensions); + + // Handshake header: type=0x01 (ClientHello), length=3 bytes + let mut handshake = Vec::new(); + handshake.push(0x01); // handshake type: ClientHello + let ch_len = ch_body.len() as u32; + handshake.push((ch_len >> 16) as u8); + handshake.push((ch_len >> 8) as u8); + handshake.push(ch_len as u8); + handshake.extend_from_slice(&ch_body); + + // TLS record header: type=0x16, version=0x0301, length + let mut record = Vec::new(); + record.push(0x16); // content type: handshake + record.extend_from_slice(&[0x03, 0x01]); // record version: TLS 1.0 (standard for records) + let hs_len = handshake.len() as u16; + record.extend_from_slice(&hs_len.to_be_bytes()); + record.extend_from_slice(&handshake); + + record +} + +#[test] +fn test_parse_client_hello() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + // TLS_AES_128_GCM_SHA256 (0x1301), TLS_CHACHA20_POLY1305_SHA256 (0x1303) + let record = build_client_hello("example.com", &[0x1301, 0x1303]); + analyzer.on_data(&fk, Direction::ClientToServer, &record, 0); + + assert_eq!(*analyzer.sni_counts().get("example.com").unwrap(), 1); + assert_eq!(analyzer.ja3_counts().len(), 1); + assert!(!analyzer.ja3_counts().is_empty()); + assert_eq!(*analyzer.version_counts().get(&0x0303).unwrap(), 1); + assert_eq!(analyzer.parse_error_count(), 0); +} + +#[test] +fn test_ja3_grease_filtering() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + // Include GREASE value 0x0a0a alongside real cipher 0x1301 + let record = build_client_hello("test.com", &[0x0a0a, 0x1301]); + analyzer.on_data(&fk, Direction::ClientToServer, &record, 0); + + // JA3 should have been computed — GREASE filtered out + assert_eq!(analyzer.ja3_counts().len(), 1); + // Get the JA3 string by checking the hash exists + let ja3_hash = analyzer.ja3_counts().keys().next().unwrap(); + assert_eq!(ja3_hash.len(), 32); // MD5 hex = 32 chars +} + +#[test] +fn test_parse_error_counter() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + // Valid TLS record header but garbage handshake content + let bad_record = [0x16, 0x03, 0x03, 0x00, 0x05, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + analyzer.on_data(&fk, Direction::ClientToServer, &bad_record, 0); + + assert_eq!(analyzer.parse_error_count(), 1); +} + +#[test] +fn test_normal_request_no_parse_errors() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let record = build_client_hello("example.com", &[0x1301]); + analyzer.on_data(&fk, Direction::ClientToServer, &record, 0); + + assert_eq!(analyzer.parse_error_count(), 0); + assert!(analyzer.findings().is_empty()); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --test tls_analyzer_tests 2>&1` + +Expected: FAIL — `on_data` is a no-op stub. + +- [ ] **Step 3: Implement `on_data` with TLS record extraction and ClientHello processing** + +Replace the `on_data` method and add helper functions in `src/analyzer/tls.rs`. Replace the entire file content with: + +```rust +use std::collections::HashMap; + +use md5::{Digest, Md5}; +use tls_parser::{ + TlsCipherSuite, TlsCipherSuiteID, TlsExtension, TlsExtensionType, TlsMessage, + TlsMessageHandshake, parse_tls_extensions, parse_tls_plaintext, +}; + +use crate::analyzer::AnalysisSummary; +use crate::findings::{Confidence, Finding, ThreatCategory, Verdict}; +use crate::reassembly::flow::FlowKey; +use crate::reassembly::handler::{CloseReason, Direction, StreamAnalyzer, StreamHandler}; + +const MAX_BUF: usize = 65_536; +const MAX_MAP_ENTRIES: usize = 50_000; + +struct TlsFlowState { + buf: Vec, + client_hello_seen: bool, + server_hello_seen: bool, +} + +impl TlsFlowState { + fn new() -> Self { + TlsFlowState { + buf: Vec::new(), + client_hello_seen: false, + server_hello_seen: false, + } + } +} + +fn is_grease_u16(val: u16) -> bool { + (val & 0x0F0F) == 0x0A0A +} + +fn is_weak_cipher(id: TlsCipherSuiteID) -> bool { + if let Some(cs) = TlsCipherSuite::from_id(id.0) { + let name = cs.name.to_uppercase(); + name.contains("NULL") || name.contains("ANON") || name.contains("EXPORT") + } else { + false + } +} + +fn cipher_name(id: TlsCipherSuiteID) -> String { + TlsCipherSuite::from_id(id.0) + .map(|cs| cs.name.to_string()) + .unwrap_or_else(|| format!("0x{:04x}", id.0)) +} + +fn is_weak_server_cipher(id: TlsCipherSuiteID) -> bool { + if let Some(cs) = TlsCipherSuite::from_id(id.0) { + let name = cs.name.to_uppercase(); + name.contains("NULL") + || name.contains("ANON") + || name.contains("EXPORT") + || name.contains("RC4") + } else { + false + } +} + +fn compute_ja3( + version: u16, + ciphers: &[TlsCipherSuiteID], + extensions: &[TlsExtension<'_>], +) -> (String, String) { + let version_str = version.to_string(); + + let ciphers_str: String = ciphers + .iter() + .filter(|c| !is_grease_u16(c.0)) + .map(|c| c.0.to_string()) + .collect::>() + .join("-"); + + let mut ext_ids = Vec::new(); + let mut elliptic_curves = Vec::new(); + let mut ec_point_formats = Vec::new(); + + for ext in extensions { + if matches!(ext, TlsExtension::Grease(_, _)) { + continue; + } + let ext_type: TlsExtensionType = ext.into(); + if !is_grease_u16(ext_type.0) { + ext_ids.push(ext_type.0.to_string()); + } + match ext { + TlsExtension::EllipticCurves(groups) => { + for g in groups { + if !is_grease_u16(g.0) { + elliptic_curves.push(g.0.to_string()); + } + } + } + TlsExtension::EcPointFormats(formats) => { + for &f in *formats { + ec_point_formats.push((f as u16).to_string()); + } + } + _ => {} + } + } + + let ja3_string = format!( + "{},{},{},{},{}", + version_str, + ciphers_str, + ext_ids.join("-"), + elliptic_curves.join("-"), + ec_point_formats.join("-"), + ); + + let mut hasher = Md5::new(); + hasher.update(ja3_string.as_bytes()); + let hash = format!("{:x}", hasher.finalize()); + + (hash, ja3_string) +} + +fn compute_ja3s(version: u16, cipher: TlsCipherSuiteID, extensions: &[TlsExtension<'_>]) -> String { + let version_str = version.to_string(); + let cipher_str = cipher.0.to_string(); + + let ext_ids: Vec = extensions + .iter() + .filter(|e| !matches!(e, TlsExtension::Grease(_, _))) + .map(|e| { + let t: TlsExtensionType = e.into(); + t.0.to_string() + }) + .collect(); + + let ja3s_string = format!("{},{},{}", version_str, cipher_str, ext_ids.join("-")); + + let mut hasher = Md5::new(); + hasher.update(ja3s_string.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn extract_sni(extensions: &[TlsExtension<'_>]) -> Option { + for ext in extensions { + if let TlsExtension::SNI(names) = ext { + for (_, name_bytes) in names { + if let Ok(s) = std::str::from_utf8(name_bytes) { + let trimmed = s.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + } + } + None +} + +pub struct TlsAnalyzer { + flows: HashMap, + sni_counts: HashMap, + ja3_counts: HashMap, + ja3s_counts: HashMap, + version_counts: HashMap, + cipher_counts: HashMap, + handshakes_seen: u64, + parse_errors: u64, + all_findings: Vec, +} + +impl Default for TlsAnalyzer { + fn default() -> Self { + Self::new() + } +} + +impl TlsAnalyzer { + pub fn new() -> Self { + TlsAnalyzer { + flows: HashMap::new(), + sni_counts: HashMap::new(), + ja3_counts: HashMap::new(), + ja3s_counts: HashMap::new(), + version_counts: HashMap::new(), + cipher_counts: HashMap::new(), + handshakes_seen: 0, + parse_errors: 0, + all_findings: Vec::new(), + } + } + + pub fn sni_counts(&self) -> &HashMap { + &self.sni_counts + } + + pub fn ja3_counts(&self) -> &HashMap { + &self.ja3_counts + } + + pub fn ja3s_counts(&self) -> &HashMap { + &self.ja3s_counts + } + + pub fn version_counts(&self) -> &HashMap { + &self.version_counts + } + + pub fn parse_error_count(&self) -> u64 { + self.parse_errors + } + + pub fn handshake_count(&self) -> u64 { + self.handshakes_seen + } + + fn try_parse_records(&mut self, flow_key: &FlowKey) { + loop { + let state = match self.flows.get(flow_key) { + Some(s) if !s.client_hello_seen || !s.server_hello_seen => s, + _ => return, + }; + + let buf = &state.buf; + if buf.len() < 5 { + return; + } + + let content_type = buf[0]; + let record_len = u16::from_be_bytes([buf[3], buf[4]]) as usize; + let total = 5 + record_len; + + if buf.len() < total { + return; + } + + // Skip non-handshake records + if content_type != 0x16 { + if let Some(state) = self.flows.get_mut(flow_key) { + state.buf.drain(..total); + } + continue; + } + + // Parse the handshake record + let record_bytes: Vec = buf[..total].to_vec(); + match parse_tls_plaintext(&record_bytes) { + Ok((_, record)) => { + for msg in &record.msg { + match msg { + TlsMessage::Handshake(TlsMessageHandshake::ClientHello(ch)) => { + let version = ch.version.0; + if self.version_counts.len() < MAX_MAP_ENTRIES + || self.version_counts.contains_key(&version) + { + *self.version_counts.entry(version).or_insert(0) += 1; + } + + let extensions = ch + .ext + .and_then(|e| parse_tls_extensions(e).ok()) + .map(|(_, exts)| exts) + .unwrap_or_default(); + + // SNI + if let Some(sni) = extract_sni(&extensions) { + if self.sni_counts.len() < MAX_MAP_ENTRIES + || self.sni_counts.contains_key(&sni) + { + *self.sni_counts.entry(sni).or_insert(0) += 1; + } + } + + // JA3 + let (ja3_hash, _) = + compute_ja3(version, &ch.ciphers, &extensions); + if self.ja3_counts.len() < MAX_MAP_ENTRIES + || self.ja3_counts.contains_key(&ja3_hash) + { + *self.ja3_counts.entry(ja3_hash).or_insert(0) += 1; + } + + // Weak cipher check + let weak: Vec = ch + .ciphers + .iter() + .filter(|c| is_weak_cipher(**c)) + .map(|c| cipher_name(*c)) + .collect(); + if !weak.is_empty() { + self.all_findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Likely, + confidence: Confidence::High, + summary: + "ClientHello offers weak cipher suites (NULL/anonymous/export)" + .to_string(), + evidence: weak, + mitre_technique: None, + source_ip: None, + timestamp: None, + }); + } + + self.handshakes_seen += 1; + if let Some(state) = self.flows.get_mut(flow_key) { + state.client_hello_seen = true; + } + } + TlsMessage::Handshake(TlsMessageHandshake::ServerHello(sh)) => { + let version = sh.version.0; + if self.version_counts.len() < MAX_MAP_ENTRIES + || self.version_counts.contains_key(&version) + { + *self.version_counts.entry(version).or_insert(0) += 1; + } + + // Cipher name for counts + let name = cipher_name(sh.cipher); + if self.cipher_counts.len() < MAX_MAP_ENTRIES + || self.cipher_counts.contains_key(&name) + { + *self.cipher_counts.entry(name).or_insert(0) += 1; + } + + // JA3S + let extensions = sh + .ext + .and_then(|e| parse_tls_extensions(e).ok()) + .map(|(_, exts)| exts) + .unwrap_or_default(); + + let ja3s_hash = + compute_ja3s(version, sh.cipher, &extensions); + if self.ja3s_counts.len() < MAX_MAP_ENTRIES + || self.ja3s_counts.contains_key(&ja3s_hash) + { + *self.ja3s_counts.entry(ja3s_hash).or_insert(0) += 1; + } + + // Weak cipher selection check + if is_weak_server_cipher(sh.cipher) { + let cipher_display = cipher_name(sh.cipher); + self.all_findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Likely, + confidence: Confidence::Medium, + summary: format!( + "ServerHello selected weak cipher suite ({})", + cipher_display + ), + evidence: vec![format!( + "Selected cipher: {} (0x{:04x})", + cipher_display, sh.cipher.0 + )], + mitre_technique: None, + source_ip: None, + timestamp: None, + }); + } + + if let Some(state) = self.flows.get_mut(flow_key) { + state.server_hello_seen = true; + } + } + _ => {} + } + } + } + Err(nom::Err::Incomplete(_)) => { + self.parse_errors += 1; + } + Err(_) => { + self.parse_errors += 1; + if let Some(state) = self.flows.get_mut(flow_key) { + state.buf.clear(); + } + return; + } + } + + if let Some(state) = self.flows.get_mut(flow_key) { + state.buf.drain(..total); + } + } + } +} + +impl StreamHandler for TlsAnalyzer { + fn on_data(&mut self, flow_key: &FlowKey, _direction: Direction, data: &[u8], _offset: u64) { + { + let state = self + .flows + .entry(flow_key.clone()) + .or_insert_with(TlsFlowState::new); + + if state.client_hello_seen && state.server_hello_seen { + return; + } + + let remaining = MAX_BUF.saturating_sub(state.buf.len()); + if remaining > 0 { + state + .buf + .extend_from_slice(&data[..data.len().min(remaining)]); + } + } + self.try_parse_records(flow_key); + } + + fn on_flow_close(&mut self, flow_key: &FlowKey, _reason: CloseReason) { + self.flows.remove(flow_key); + } +} + +impl StreamAnalyzer for TlsAnalyzer { + fn name(&self) -> &'static str { + "TLS" + } + + fn summarize(&self) -> AnalysisSummary { + AnalysisSummary { + analyzer_name: self.name().to_string(), + packets_analyzed: self.handshakes_seen, + detail: HashMap::new(), // Will be implemented in Task 6 + } + } + + fn findings(&self) -> Vec { + self.all_findings.clone() + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test --test tls_analyzer_tests 2>&1` + +Expected: All 4 tests pass. + +- [ ] **Step 5: Run all tests to check for regressions** + +Run: `cargo test 2>&1` + +Expected: All existing tests pass (HTTP, reassembly, etc.) plus new TLS + dispatcher tests. + +- [ ] **Step 6: Run clippy** + +Run: `cargo clippy --tests 2>&1` + +Expected: No errors. Fix any warnings. + +- [ ] **Step 7: Commit** + +```bash +git add src/analyzer/tls.rs tests/tls_analyzer_tests.rs +git commit -m "feat: implement TLS record parsing, ClientHello extraction, and JA3 fingerprinting" +``` + +--- + +### Task 4: ServerHello Tests + +**Files:** +- Modify: `tests/tls_analyzer_tests.rs` + +- [ ] **Step 1: Add ServerHello builder and tests** + +Add to `tests/tls_analyzer_tests.rs`: + +```rust +/// Build a minimal TLS ServerHello record. +fn build_server_hello(cipher_id: u16) -> Vec { + // Extensions: just renegotiation_info (0xff01) with empty data + let mut extensions = Vec::new(); + extensions.extend_from_slice(&[0xff, 0x01]); // renegotiation_info + extensions.extend_from_slice(&[0x00, 0x01]); // extension data length + extensions.push(0x00); // empty renegotiation info + + // ServerHello body + let mut sh_body = Vec::new(); + sh_body.extend_from_slice(&[0x03, 0x03]); // version: TLS 1.2 + sh_body.extend_from_slice(&[0u8; 32]); // random + sh_body.push(0x00); // session_id length: 0 + sh_body.extend_from_slice(&cipher_id.to_be_bytes()); // selected cipher + sh_body.push(0x00); // compression: null + + let ext_len = extensions.len() as u16; + sh_body.extend_from_slice(&ext_len.to_be_bytes()); + sh_body.extend_from_slice(&extensions); + + // Handshake header + let mut handshake = Vec::new(); + handshake.push(0x02); // handshake type: ServerHello + let sh_len = sh_body.len() as u32; + handshake.push((sh_len >> 16) as u8); + handshake.push((sh_len >> 8) as u8); + handshake.push(sh_len as u8); + handshake.extend_from_slice(&sh_body); + + // TLS record header + let mut record = Vec::new(); + record.push(0x16); + record.extend_from_slice(&[0x03, 0x03]); + let hs_len = handshake.len() as u16; + record.extend_from_slice(&hs_len.to_be_bytes()); + record.extend_from_slice(&handshake); + + record +} + +#[test] +fn test_parse_server_hello() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + // Send ClientHello first + let ch = build_client_hello("example.com", &[0x1301, 0x1303]); + analyzer.on_data(&fk, Direction::ClientToServer, &ch, 0); + + // Then ServerHello selecting TLS_AES_128_GCM_SHA256 (0x1301) + let sh = build_server_hello(0x1301); + analyzer.on_data(&fk, Direction::ServerToClient, &sh, 0); + + assert_eq!(analyzer.ja3s_counts().len(), 1); + assert_eq!(analyzer.parse_error_count(), 0); +} + +#[test] +fn test_weak_cipher_finding_client() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + // TLS_RSA_WITH_NULL_SHA (0x0002) — NULL cipher + let record = build_client_hello("test.com", &[0x0002, 0x1301]); + analyzer.on_data(&fk, Direction::ClientToServer, &record, 0); + + let findings = analyzer.findings(); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].category, wirerust::findings::ThreatCategory::Anomaly); + assert_eq!(findings[0].confidence, wirerust::findings::Confidence::High); + assert!(findings[0].summary.contains("weak cipher")); +} + +#[test] +fn test_weak_cipher_finding_server() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let ch = build_client_hello("test.com", &[0x1301]); + analyzer.on_data(&fk, Direction::ClientToServer, &ch, 0); + + // Server selects TLS_RSA_WITH_RC4_128_SHA (0x0005) + let sh = build_server_hello(0x0005); + analyzer.on_data(&fk, Direction::ServerToClient, &sh, 0); + + let findings = analyzer.findings(); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].confidence, wirerust::findings::Confidence::Medium); + assert!(findings[0].summary.contains("weak cipher")); +} + +#[test] +fn test_normal_handshake_no_findings() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let ch = build_client_hello("example.com", &[0x1301, 0x1303]); + analyzer.on_data(&fk, Direction::ClientToServer, &ch, 0); + + let sh = build_server_hello(0x1301); + analyzer.on_data(&fk, Direction::ServerToClient, &sh, 0); + + assert!(analyzer.findings().is_empty()); + assert_eq!(analyzer.parse_error_count(), 0); +} + +#[test] +fn test_stop_after_handshake() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let ch = build_client_hello("example.com", &[0x1301]); + analyzer.on_data(&fk, Direction::ClientToServer, &ch, 0); + + let sh = build_server_hello(0x1301); + analyzer.on_data(&fk, Direction::ServerToClient, &sh, 0); + + // Send encrypted application data (content_type=0x17) — should be ignored + let app_data = [0x17, 0x03, 0x03, 0x00, 0x10, 0xAA; 21]; + analyzer.on_data(&fk, Direction::ServerToClient, &app_data, 0); + + // No parse errors from the encrypted data + assert_eq!(analyzer.parse_error_count(), 0); +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test --test tls_analyzer_tests 2>&1` + +Expected: All tests pass (4 original + 5 new = 9 total). + +- [ ] **Step 3: Commit** + +```bash +git add tests/tls_analyzer_tests.rs +git commit -m "test: add ServerHello, weak cipher, and stop-after-handshake tests" +``` + +--- + +### Task 5: Summarize Output + +**Files:** +- Modify: `src/analyzer/tls.rs` +- Modify: `tests/tls_analyzer_tests.rs` + +- [ ] **Step 1: Write the failing summarize test** + +Add to `tests/tls_analyzer_tests.rs`: + +```rust +use wirerust::reassembly::handler::StreamAnalyzer; + +#[test] +fn test_summarize_output() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let ch = build_client_hello("example.com", &[0x1301, 0x1303]); + analyzer.on_data(&fk, Direction::ClientToServer, &ch, 0); + + let sh = build_server_hello(0x1301); + analyzer.on_data(&fk, Direction::ServerToClient, &sh, 0); + + let summary = analyzer.summarize(); + assert_eq!(summary.analyzer_name, "TLS"); + assert_eq!(summary.packets_analyzed, 1); + + let detail = &summary.detail; + assert!(detail["top_snis"] + .as_array() + .unwrap() + .contains(&serde_json::json!("example.com"))); + assert!(detail.contains_key("ja3_hashes")); + assert!(detail.contains_key("ja3s_hashes")); + assert!(detail.contains_key("tls_versions")); + assert!(detail.contains_key("cipher_suites")); + assert_eq!(detail["parse_errors"], 0); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --test tls_analyzer_tests test_summarize_output 2>&1` + +Expected: FAIL — `detail` map is empty. + +- [ ] **Step 3: Implement `summarize()`** + +In `src/analyzer/tls.rs`, replace the `summarize()` method in the `StreamAnalyzer` impl: + +```rust + fn summarize(&self) -> AnalysisSummary { + let mut detail = HashMap::new(); + + // Top SNIs (top 20 by count) + let mut top_snis: Vec<_> = self.sni_counts.iter().collect(); + top_snis.sort_by(|a, b| b.1.cmp(a.1)); + let top_snis: Vec<&str> = top_snis.iter().take(20).map(|(k, _)| k.as_str()).collect(); + detail.insert("top_snis".to_string(), serde_json::json!(top_snis)); + + detail.insert("ja3_hashes".to_string(), serde_json::json!(self.ja3_counts)); + detail.insert( + "ja3s_hashes".to_string(), + serde_json::json!(self.ja3s_counts), + ); + detail.insert( + "tls_versions".to_string(), + serde_json::json!( + self.version_counts + .iter() + .map(|(k, v)| (k.to_string(), *v)) + .collect::>() + ), + ); + detail.insert( + "cipher_suites".to_string(), + serde_json::json!(self.cipher_counts), + ); + detail.insert( + "parse_errors".to_string(), + serde_json::json!(self.parse_errors), + ); + + AnalysisSummary { + analyzer_name: self.name().to_string(), + packets_analyzed: self.handshakes_seen, + detail, + } + } +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test --test tls_analyzer_tests 2>&1` + +Expected: All 10 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/analyzer/tls.rs tests/tls_analyzer_tests.rs +git commit -m "feat: implement TLS summarize() with SNI, JA3, versions, ciphers" +``` + +--- + +### Task 6: CLI Integration + +**Files:** +- Modify: `src/main.rs` + +- [ ] **Step 1: Replace direct handler with StreamDispatcher** + +Replace the entire `run_analyze` function in `src/main.rs`. The key changes: +- Import `StreamDispatcher` and `TlsAnalyzer` +- Destructure `tls` from CLI args +- Create `StreamDispatcher` wrapping optional HTTP + TLS analyzers +- Use dispatcher as the single StreamHandler +- Collect findings and summaries from both analyzers via dispatcher + +Replace the `run_analyze` function: + +```rust +fn run_analyze( + targets: &[std::path::PathBuf], + enable_dns: bool, + enable_http: bool, + enable_tls: bool, + use_color: bool, + cli: &Cli, +) -> Result<()> { + let mut summary = Summary::new(); + let mut dns_analyzer = DnsAnalyzer::new(); + let mut all_findings = Vec::new(); + let mut total_decode_errors: u64 = 0; + + // Determine if reassembly is needed + let needs_reassembly = cli.reassemble || enable_http || enable_tls; + let skip_reassembly = cli.no_reassemble; + + if (enable_http || enable_tls) && skip_reassembly { + eprintln!( + "Warning: --http/--tls require TCP reassembly, but --no-reassemble is set. Stream analysis will be skipped." + ); + } + + 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 + }; + + let http_analyzer = if enable_http && !skip_reassembly { + Some(HttpAnalyzer::new()) + } else { + None + }; + let tls_analyzer = if enable_tls && !skip_reassembly { + Some(TlsAnalyzer::new()) + } else { + None + }; + let mut dispatcher = StreamDispatcher::new(http_analyzer, tls_analyzer); + + 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 { + match decode_packet(&raw.data, source.datalink) { + Ok(parsed) => { + 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 dispatcher); + } + } + Err(e) => { + if total_decode_errors == 0 { + eprintln!( + "Warning: failed to decode packet ({e}). Further errors counted silently." + ); + } + total_decode_errors += 1; + } + } + pb.inc(1); + } + pb.finish_and_clear(); + } + } + + summary.skipped_packets = total_decode_errors; + + if let Some(ref mut reasm) = reassembler { + reasm.finalize(&mut dispatcher); + all_findings.extend(reasm.findings().to_vec()); + } + + if let Some(ref http) = dispatcher.http { + all_findings.extend(http.findings()); + } + if let Some(ref tls) = dispatcher.tls { + all_findings.extend(tls.findings()); + } + + let mut analyzer_summaries = Vec::new(); + if enable_dns { + analyzer_summaries.push(dns_analyzer.summarize()); + } + if let Some(ref http) = dispatcher.http { + analyzer_summaries.push(http.summarize()); + } + if let Some(ref tls) = dispatcher.tls { + analyzer_summaries.push(tls.summarize()); + } + + 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 2: Update the `Commands::Analyze` match arm** + +In `main()`, update the match arm to pass `tls`: + +```rust + Commands::Analyze { + targets, + dns, + http, + tls, + all, + .. + } => { + run_analyze( + targets, + *dns || *all, + *http || *all, + *tls || *all, + use_color, + &cli, + )?; + } +``` + +- [ ] **Step 3: Update imports** + +Add to the imports at the top of `src/main.rs`: + +```rust +use wirerust::analyzer::tls::TlsAnalyzer; +use wirerust::dispatcher::StreamDispatcher; +``` + +Remove the now-unused imports: +- `use wirerust::reassembly::handler::{CloseReason, Direction, StreamHandler};` (CloseReason, Direction, StreamHandler no longer used directly) +- The `NullHandler` struct and its `impl` block + +Keep `StreamAnalyzer` if it's used for type bounds, otherwise remove it too. + +- [ ] **Step 4: Verify compilation** + +Run: `cargo check 2>&1` + +Expected: Compiles. Fix any unused import warnings. + +- [ ] **Step 5: Run all tests** + +Run: `cargo test 2>&1` + +Expected: All tests pass including existing HTTP, reassembly, CLI, and new TLS/dispatcher tests. + +- [ ] **Step 6: Run clippy and fmt** + +Run: `cargo clippy --tests 2>&1 && cargo fmt --check 2>&1` + +Expected: No warnings, no formatting issues. + +- [ ] **Step 7: Commit** + +```bash +git add src/main.rs +git commit -m "feat: wire TLS analyzer into CLI via StreamDispatcher" +``` + +--- + +## Files Modified + +| File | Change | +|------|--------| +| `Cargo.toml` | Add `tls-parser = "0.12"`, `md-5 = "0.11"` | +| `src/lib.rs` | Add `pub mod dispatcher` | +| `src/analyzer/mod.rs` | Add `pub mod tls` | +| `src/analyzer/tls.rs` | New: `TlsAnalyzer` with record parsing, JA3/JA3S, weak cipher findings, summarize | +| `src/dispatcher.rs` | New: `StreamDispatcher` with content-first routing | +| `src/main.rs` | Use `StreamDispatcher`, wire `--tls` flag, remove `NullHandler` | +| `tests/tls_analyzer_tests.rs` | New: 10 tests (ClientHello, ServerHello, JA3, weak ciphers, summarize, etc.) | +| `tests/dispatcher_tests.rs` | New: 4 tests (routing, content detection, port fallback) | + +## Self-Review Checklist + +- [x] Spec coverage: StreamDispatcher (ADR 0001) → Task 1+2. TlsAnalyzer with record parsing → Task 3. ClientHello/JA3 → Task 3. ServerHello/JA3S → Task 3+4. Weak cipher findings → Task 3+4. summarize() → Task 5. CLI integration → Task 6. Public accessors → Task 1 (stub) + Task 3 (final). All 13 spec tests covered. +- [x] No placeholders: All code blocks contain complete implementation. No "TBD" or "similar to" references. +- [x] Type consistency: `TlsCipherSuiteID`, `TlsExtensionType`, `TlsVersion` used consistently with `.0` access for u16 values. `FlowKey` cloned where needed. `HashMap` pattern consistent across counters. From 03771746bfc5642e1ae2334c80ba9c088c6c0dc7 Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 08:35:50 -0500 Subject: [PATCH 05/14] scaffold: add TLS analyzer and StreamDispatcher stubs with dependencies --- Cargo.toml | 2 + src/analyzer/mod.rs | 1 + src/analyzer/tls.rs | 111 ++++++++++++++++++++++++++++++++++++++++++++ src/dispatcher.rs | 98 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 5 files changed, 213 insertions(+) create mode 100644 src/analyzer/tls.rs create mode 100644 src/dispatcher.rs diff --git a/Cargo.toml b/Cargo.toml index c3b782b..9bfbb8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ license = "MIT" [dependencies] httparse = "1" +tls-parser = "0.12" +md-5 = "0.11" clap = { version = "4", features = ["derive"] } etherparse = "0.16" pcap-file = "2" diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 2b2ec85..72ce0b5 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -1,5 +1,6 @@ pub mod dns; pub mod http; +pub mod tls; use std::collections::HashMap; diff --git a/src/analyzer/tls.rs b/src/analyzer/tls.rs new file mode 100644 index 0000000..a37c7f6 --- /dev/null +++ b/src/analyzer/tls.rs @@ -0,0 +1,111 @@ +use std::collections::HashMap; + +use crate::analyzer::AnalysisSummary; +use crate::findings::Finding; +use crate::reassembly::flow::FlowKey; +use crate::reassembly::handler::{CloseReason, Direction, StreamAnalyzer, StreamHandler}; + +const MAX_BUF: usize = 65_536; +const MAX_MAP_ENTRIES: usize = 50_000; + +struct TlsFlowState { + buf: Vec, + client_hello_seen: bool, + server_hello_seen: bool, +} + +impl TlsFlowState { + fn new() -> Self { + TlsFlowState { + buf: Vec::new(), + client_hello_seen: false, + server_hello_seen: false, + } + } +} + +pub struct TlsAnalyzer { + flows: HashMap, + sni_counts: HashMap, + ja3_counts: HashMap, + ja3s_counts: HashMap, + version_counts: HashMap, + cipher_counts: HashMap, + handshakes_seen: u64, + parse_errors: u64, + all_findings: Vec, +} + +impl Default for TlsAnalyzer { + fn default() -> Self { + Self::new() + } +} + +impl TlsAnalyzer { + pub fn new() -> Self { + TlsAnalyzer { + flows: HashMap::new(), + sni_counts: HashMap::new(), + ja3_counts: HashMap::new(), + ja3s_counts: HashMap::new(), + version_counts: HashMap::new(), + cipher_counts: HashMap::new(), + handshakes_seen: 0, + parse_errors: 0, + all_findings: Vec::new(), + } + } + + pub fn sni_counts(&self) -> &HashMap { + &self.sni_counts + } + + pub fn ja3_counts(&self) -> &HashMap { + &self.ja3_counts + } + + pub fn ja3s_counts(&self) -> &HashMap { + &self.ja3s_counts + } + + pub fn version_counts(&self) -> &HashMap { + &self.version_counts + } + + pub fn parse_error_count(&self) -> u64 { + self.parse_errors + } + + pub fn handshake_count(&self) -> u64 { + self.handshakes_seen + } +} + +impl StreamHandler for TlsAnalyzer { + fn on_data(&mut self, _flow_key: &FlowKey, _direction: Direction, _data: &[u8], _offset: u64) { + // Will be implemented in Task 3 + } + + fn on_flow_close(&mut self, flow_key: &FlowKey, _reason: CloseReason) { + self.flows.remove(flow_key); + } +} + +impl StreamAnalyzer for TlsAnalyzer { + fn name(&self) -> &'static str { + "TLS" + } + + fn summarize(&self) -> AnalysisSummary { + AnalysisSummary { + analyzer_name: self.name().to_string(), + packets_analyzed: self.handshakes_seen, + detail: HashMap::new(), + } + } + + fn findings(&self) -> Vec { + self.all_findings.clone() + } +} diff --git a/src/dispatcher.rs b/src/dispatcher.rs new file mode 100644 index 0000000..e354f9b --- /dev/null +++ b/src/dispatcher.rs @@ -0,0 +1,98 @@ +use std::collections::HashMap; + +use crate::analyzer::http::HttpAnalyzer; +use crate::analyzer::tls::TlsAnalyzer; +use crate::reassembly::flow::FlowKey; +use crate::reassembly::handler::{CloseReason, Direction, StreamHandler}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DispatchTarget { + Http, + Tls, + None, +} + +pub struct StreamDispatcher { + routes: HashMap, + pub http: Option, + pub tls: Option, +} + +impl StreamDispatcher { + pub fn new(http: Option, tls: Option) -> Self { + StreamDispatcher { + routes: HashMap::new(), + http, + tls, + } + } +} + +fn classify(data: &[u8], flow_key: &FlowKey) -> DispatchTarget { + // Content-first detection + if data.len() >= 5 && data[0] == 0x16 && data[1] == 0x03 { + return DispatchTarget::Tls; + } + if data.starts_with(b"GET ") + || data.starts_with(b"POST ") + || data.starts_with(b"PUT ") + || data.starts_with(b"DELETE ") + || data.starts_with(b"HEAD ") + || data.starts_with(b"OPTIONS ") + || data.starts_with(b"PATCH ") + || data.starts_with(b"CONNECT ") + || data.starts_with(b"TRACE ") + || data.starts_with(b"HTTP/") + { + return DispatchTarget::Http; + } + // Port fallback for short data + let ports = [flow_key.lower_port, flow_key.upper_port]; + if ports.contains(&443) || ports.contains(&8443) { + return DispatchTarget::Tls; + } + if ports.contains(&80) || ports.contains(&8080) { + return DispatchTarget::Http; + } + DispatchTarget::None +} + +impl StreamHandler for StreamDispatcher { + fn on_data(&mut self, flow_key: &FlowKey, direction: Direction, data: &[u8], offset: u64) { + let target = *self + .routes + .entry(flow_key.clone()) + .or_insert_with(|| classify(data, flow_key)); + + match target { + DispatchTarget::Http => { + if let Some(ref mut http) = self.http { + http.on_data(flow_key, direction, data, offset); + } + } + DispatchTarget::Tls => { + if let Some(ref mut tls) = self.tls { + tls.on_data(flow_key, direction, data, offset); + } + } + DispatchTarget::None => {} + } + } + + fn on_flow_close(&mut self, flow_key: &FlowKey, reason: CloseReason) { + let target = self.routes.remove(flow_key); + match target { + Some(DispatchTarget::Http) => { + if let Some(ref mut http) = self.http { + http.on_flow_close(flow_key, reason); + } + } + Some(DispatchTarget::Tls) => { + if let Some(ref mut tls) = self.tls { + tls.on_flow_close(flow_key, reason); + } + } + _ => {} + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 9dac35d..a65375b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod analyzer; pub mod cli; pub mod decoder; +pub mod dispatcher; pub mod findings; pub mod reader; pub mod reassembly; From 5cf194941dae66a41e38248d4ded7de69902b5bf Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 08:40:03 -0500 Subject: [PATCH 06/14] test: add StreamDispatcher routing tests --- tests/dispatcher_tests.rs | 72 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/dispatcher_tests.rs diff --git a/tests/dispatcher_tests.rs b/tests/dispatcher_tests.rs new file mode 100644 index 0000000..3bbb75c --- /dev/null +++ b/tests/dispatcher_tests.rs @@ -0,0 +1,72 @@ +use std::net::IpAddr; +use wirerust::analyzer::http::HttpAnalyzer; +use wirerust::analyzer::tls::TlsAnalyzer; +use wirerust::dispatcher::StreamDispatcher; +use wirerust::reassembly::flow::FlowKey; +use wirerust::reassembly::handler::{Direction, StreamHandler}; + +fn flow_key(src_port: u16, dst_port: u16) -> FlowKey { + FlowKey::new( + "10.0.0.1".parse::().unwrap(), + src_port, + "10.0.0.2".parse::().unwrap(), + dst_port, + ) +} + +#[test] +fn test_dispatcher_routes_tls() { + let mut dispatcher = StreamDispatcher::new(None, Some(TlsAnalyzer::new())); + let fk = flow_key(49152, 443); + + // TLS ClientHello record header: content_type=0x16, version=0x0303, length=0x0005 + let tls_data = [0x16, 0x03, 0x03, 0x00, 0x05, 0x01, 0x00, 0x00, 0x01, 0x00]; + dispatcher.on_data(&fk, Direction::ClientToServer, &tls_data, 0); + + // If routed correctly, TLS analyzer received data (no panic, no error) + // We can't directly assert routing, but we can verify HTTP didn't get it + assert!(dispatcher.http.is_none()); +} + +#[test] +fn test_dispatcher_routes_http() { + let mut dispatcher = StreamDispatcher::new(Some(HttpAnalyzer::new()), None); + let fk = flow_key(49152, 80); + + let http_data = b"GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\n"; + dispatcher.on_data(&fk, Direction::ClientToServer, http_data, 0); + + let http = dispatcher.http.as_ref().unwrap(); + assert_eq!(*http.method_counts().get("GET").unwrap(), 1); +} + +#[test] +fn test_dispatcher_content_detection_tls_on_port_80() { + let mut dispatcher = + StreamDispatcher::new(Some(HttpAnalyzer::new()), Some(TlsAnalyzer::new())); + let fk = flow_key(49152, 80); // Port 80, but content is TLS + + // TLS record header on port 80 — content detection should override port + let tls_data = [0x16, 0x03, 0x03, 0x00, 0x05, 0x01, 0x00, 0x00, 0x01, 0x00]; + dispatcher.on_data(&fk, Direction::ClientToServer, &tls_data, 0); + + // HTTP analyzer should NOT have received this data + let http = dispatcher.http.as_ref().unwrap(); + assert_eq!(http.method_counts().len(), 0); +} + +#[test] +fn test_dispatcher_port_fallback_short_data() { + let mut dispatcher = + StreamDispatcher::new(Some(HttpAnalyzer::new()), Some(TlsAnalyzer::new())); + let fk = flow_key(49152, 443); // Port 443 + + // Only 2 bytes — too short for content detection, falls back to port + let short_data = [0x16, 0x03]; + dispatcher.on_data(&fk, Direction::ClientToServer, &short_data, 0); + + // Should have routed to TLS based on port 443 + // HTTP should not have received it + let http = dispatcher.http.as_ref().unwrap(); + assert_eq!(http.method_counts().len(), 0); +} From 5a3b9b2ab42580ac5910cff00843693d6a9363f6 Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 08:59:34 -0500 Subject: [PATCH 07/14] feat: implement TLS record parsing, ClientHello extraction, and JA3 fingerprinting --- src/analyzer/tls.rs | 393 +++++++++++++++++++++++++++++++++++- tests/tls_analyzer_tests.rs | 131 ++++++++++++ 2 files changed, 518 insertions(+), 6 deletions(-) create mode 100644 tests/tls_analyzer_tests.rs diff --git a/src/analyzer/tls.rs b/src/analyzer/tls.rs index a37c7f6..92f89fa 100644 --- a/src/analyzer/tls.rs +++ b/src/analyzer/tls.rs @@ -1,15 +1,156 @@ use std::collections::HashMap; +use md5::{Digest, Md5}; +use tls_parser::{ + Err as NomErr, TlsCipherSuite, TlsCipherSuiteID, TlsExtension, TlsExtensionType, TlsMessage, + TlsMessageHandshake, parse_tls_extensions, parse_tls_plaintext, +}; + use crate::analyzer::AnalysisSummary; -use crate::findings::Finding; +use crate::findings::{Confidence, Finding, ThreatCategory, Verdict}; use crate::reassembly::flow::FlowKey; use crate::reassembly::handler::{CloseReason, Direction, StreamAnalyzer, StreamHandler}; const MAX_BUF: usize = 65_536; const MAX_MAP_ENTRIES: usize = 50_000; +// ── helpers ────────────────────────────────────────────────────────────────── + +/// Returns true if `val` is a TLS GREASE value (RFC 8701). +fn is_grease_u16(val: u16) -> bool { + (val & 0x0F0F) == 0x0A0A +} + +/// Returns true if the cipher is considered weak for client-advertised suites +/// (NULL / ANON / EXPORT ciphers). +fn is_weak_cipher(id: TlsCipherSuiteID) -> bool { + match TlsCipherSuite::from_id(id.0) { + Some(cs) => { + let n = cs.name.to_uppercase(); + n.contains("NULL") || n.contains("ANON") || n.contains("EXPORT") + } + None => false, + } +} + +/// Returns true if a cipher is weak when selected by the server (adds RC4). +fn is_weak_server_cipher(id: TlsCipherSuiteID) -> bool { + if is_weak_cipher(id) { + return true; + } + match TlsCipherSuite::from_id(id.0) { + Some(cs) => cs.name.to_uppercase().contains("RC4"), + None => false, + } +} + +/// Human-readable cipher name, falling back to hex for unknown IDs. +fn cipher_name(id: TlsCipherSuiteID) -> String { + match TlsCipherSuite::from_id(id.0) { + Some(cs) => cs.name.to_string(), + None => format!("0x{:04x}", id.0), + } +} + +/// Convert a byte slice to a lowercase hex string. +fn bytes_to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +// ── JA3 / JA3S ─────────────────────────────────────────────────────────────── + +/// Compute JA3 fingerprint from ClientHello fields. +/// +/// Returns `(md5_hex, ja3_string)`. +fn compute_ja3( + version: u16, + ciphers: &[TlsCipherSuiteID], + extensions: &[TlsExtension<'_>], +) -> (String, String) { + // Ciphers — filter GREASE + let cipher_str: String = ciphers + .iter() + .filter(|c| !is_grease_u16(c.0)) + .map(|c| c.0.to_string()) + .collect::>() + .join("-"); + + // Extension type IDs — filter GREASE + let ext_ids: String = extensions + .iter() + .filter_map(|e| { + let t: TlsExtensionType = e.into(); + let v: u16 = t.into(); + if is_grease_u16(v) { None } else { Some(v.to_string()) } + }) + .collect::>() + .join("-"); + + // Elliptic curves (named groups) — filter GREASE + let mut curves: Vec = Vec::new(); + let mut point_formats: Vec = Vec::new(); + + for ext in extensions { + match ext { + TlsExtension::EllipticCurves(groups) => { + for g in groups { + if !is_grease_u16(g.0) { + curves.push(g.0.to_string()); + } + } + } + TlsExtension::EcPointFormats(fmts) => { + for &b in *fmts { + point_formats.push(b.to_string()); + } + } + _ => {} + } + } + + let curves_str = curves.join("-"); + let pf_str = point_formats.join("-"); + + let ja3_str = format!("{version},{cipher_str},{ext_ids},{curves_str},{pf_str}"); + let hash = bytes_to_hex(Md5::digest(ja3_str.as_bytes()).as_slice()); + (hash, ja3_str) +} + +/// Compute JA3S fingerprint from ServerHello fields. +/// +/// Returns the MD5 hex string. +fn compute_ja3s(version: u16, cipher: TlsCipherSuiteID, extensions: &[TlsExtension<'_>]) -> String { + let ext_ids: String = extensions + .iter() + .filter_map(|e| { + let t: TlsExtensionType = e.into(); + let v: u16 = t.into(); + if is_grease_u16(v) { None } else { Some(v.to_string()) } + }) + .collect::>() + .join("-"); + + let ja3s_str = format!("{},{},{}", version, cipher.0, ext_ids); + bytes_to_hex(Md5::digest(ja3s_str.as_bytes()).as_slice()) +} + +/// Extract SNI hostname from the parsed extension list. +fn extract_sni(extensions: &[TlsExtension<'_>]) -> Option { + for ext in extensions { + if let TlsExtension::SNI(list) = ext + && let Some((_, hostname)) = list.first() + { + return String::from_utf8(hostname.to_vec()).ok(); + } + } + None +} + +// ── per-flow state ──────────────────────────────────────────────────────────── + struct TlsFlowState { - buf: Vec, + client_buf: Vec, + server_buf: Vec, client_hello_seen: bool, server_hello_seen: bool, } @@ -17,13 +158,21 @@ struct TlsFlowState { impl TlsFlowState { fn new() -> Self { TlsFlowState { - buf: Vec::new(), + client_buf: Vec::new(), + server_buf: Vec::new(), client_hello_seen: false, server_hello_seen: false, } } + + /// Returns true once both hellos have been seen (no more buffering needed). + fn done(&self) -> bool { + self.client_hello_seen && self.server_hello_seen + } } +// ── analyzer ───────────────────────────────────────────────────────────────── + pub struct TlsAnalyzer { flows: HashMap, sni_counts: HashMap, @@ -57,6 +206,8 @@ impl TlsAnalyzer { } } + // ── public accessors ────────────────────────────────────────────────── + pub fn sni_counts(&self) -> &HashMap { &self.sni_counts } @@ -80,11 +231,217 @@ impl TlsAnalyzer { pub fn handshake_count(&self) -> u64 { self.handshakes_seen } + + // ── internal helpers ────────────────────────────────────────────────── + + fn increment(map: &mut HashMap, key: K, limit: usize) { + if map.len() < limit || map.contains_key(&key) { + *map.entry(key).or_insert(0) += 1; + } + } + + /// Process a single complete ClientHello. + fn handle_client_hello( + &mut self, + ch: &tls_parser::TlsClientHelloContents<'_>, + _flow_key: &FlowKey, + ) { + self.handshakes_seen += 1; + + let version = ch.version.0; + Self::increment(&mut self.version_counts, version, MAX_MAP_ENTRIES); + + // Parse extensions + let exts: Vec> = ch + .ext + .and_then(|raw| parse_tls_extensions(raw).ok().map(|(_, v)| v)) + .unwrap_or_default(); + + // SNI + if let Some(sni) = extract_sni(&exts) { + Self::increment(&mut self.sni_counts, sni, MAX_MAP_ENTRIES); + } + + // JA3 + let (ja3_hash, _ja3_str) = compute_ja3(version, &ch.ciphers, &exts); + Self::increment(&mut self.ja3_counts, ja3_hash, MAX_MAP_ENTRIES); + + // Weak cipher detection + let weak: Vec = ch + .ciphers + .iter() + .filter(|&&id| is_weak_cipher(id)) + .map(|&id| cipher_name(id)) + .collect(); + + if !weak.is_empty() { + self.all_findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Likely, + confidence: Confidence::High, + summary: "ClientHello offers weak cipher suites (NULL/anonymous/export)".to_string(), + evidence: weak, + mitre_technique: None, + source_ip: None, + timestamp: None, + }); + } + } + + /// Process a single complete ServerHello. + fn handle_server_hello( + &mut self, + sh: &tls_parser::TlsServerHelloContents<'_>, + _flow_key: &FlowKey, + ) { + let version = sh.version.0; + Self::increment(&mut self.version_counts, version, MAX_MAP_ENTRIES); + + let exts: Vec> = sh + .ext + .and_then(|raw| parse_tls_extensions(raw).ok().map(|(_, v)| v)) + .unwrap_or_default(); + + // JA3S + let ja3s_hash = compute_ja3s(version, sh.cipher, &exts); + Self::increment(&mut self.ja3s_counts, ja3s_hash, MAX_MAP_ENTRIES); + + // Cipher tracking + let name = cipher_name(sh.cipher); + Self::increment(&mut self.cipher_counts, name.clone(), MAX_MAP_ENTRIES); + + if is_weak_server_cipher(sh.cipher) { + self.all_findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Likely, + confidence: Confidence::Medium, + summary: format!("ServerHello selected weak cipher suite ({})", name), + evidence: vec![format!("Selected cipher: {} (0x{:04x})", name, sh.cipher.0)], + mitre_technique: None, + source_ip: None, + timestamp: None, + }); + } + } + + /// Extract complete TLS records from the given direction's buffer and process them. + fn try_parse_records(&mut self, flow_key: &FlowKey, direction: Direction) { + loop { + // Need at least 5 bytes for a TLS record header. + let buf_len = match self.flows.get(flow_key) { + Some(s) => match direction { + Direction::ClientToServer => s.client_buf.len(), + Direction::ServerToClient => s.server_buf.len(), + }, + None => return, + }; + + if buf_len < 5 { + return; + } + + // Peek at the length field without removing data yet. + let (record_type, payload_len) = { + let buf = match direction { + Direction::ClientToServer => &self.flows[flow_key].client_buf, + Direction::ServerToClient => &self.flows[flow_key].server_buf, + }; + let record_type = buf[0]; + let payload_len = u16::from_be_bytes([buf[3], buf[4]]) as usize; + (record_type, payload_len) + }; + + let total_record_len = 5 + payload_len; + if buf_len < total_record_len { + // Incomplete record — wait for more data. + return; + } + + // We have a complete record. Clone it out so we can parse without holding &self. + let record_bytes: Vec = match direction { + Direction::ClientToServer => { + self.flows[flow_key].client_buf[..total_record_len].to_vec() + } + Direction::ServerToClient => { + self.flows[flow_key].server_buf[..total_record_len].to_vec() + } + }; + + // Drain consumed bytes from buffer. + if let Some(state) = self.flows.get_mut(flow_key) { + match direction { + Direction::ClientToServer => state.client_buf.drain(..total_record_len), + Direction::ServerToClient => state.server_buf.drain(..total_record_len), + }; + } + + // Only process handshake records (0x16). + if record_type != 0x16 { + continue; + } + + match parse_tls_plaintext(&record_bytes) { + Ok((_rem, plaintext)) => { + for msg in &plaintext.msg { + match msg { + TlsMessage::Handshake(TlsMessageHandshake::ClientHello(ch)) => { + if let Some(state) = self.flows.get_mut(flow_key) { + state.client_hello_seen = true; + } + self.handle_client_hello(ch, flow_key); + } + TlsMessage::Handshake(TlsMessageHandshake::ServerHello(sh)) => { + if let Some(state) = self.flows.get_mut(flow_key) { + state.server_hello_seen = true; + } + self.handle_server_hello(sh, flow_key); + } + _ => {} + } + } + } + Err(NomErr::Incomplete(_)) => { + // Should not happen since we verified length, but treat gracefully. + } + Err(_) => { + self.parse_errors += 1; + } + } + } + } } +// ── StreamHandler ───────────────────────────────────────────────────────────── + impl StreamHandler for TlsAnalyzer { - fn on_data(&mut self, _flow_key: &FlowKey, _direction: Direction, _data: &[u8], _offset: u64) { - // Will be implemented in Task 3 + fn on_data(&mut self, flow_key: &FlowKey, direction: Direction, data: &[u8], _offset: u64) { + // Check whether this flow is already done before we get a mutable ref. + let done = self.flows.get(flow_key).is_some_and(|s| s.done()); + if done { + return; + } + + { + let state = self.flows.entry(flow_key.clone()).or_insert_with(TlsFlowState::new); + match direction { + Direction::ClientToServer => { + let remaining = MAX_BUF.saturating_sub(state.client_buf.len()); + if remaining > 0 { + let to_copy = data.len().min(remaining); + state.client_buf.extend_from_slice(&data[..to_copy]); + } + } + Direction::ServerToClient => { + let remaining = MAX_BUF.saturating_sub(state.server_buf.len()); + if remaining > 0 { + let to_copy = data.len().min(remaining); + state.server_buf.extend_from_slice(&data[..to_copy]); + } + } + } + } + + self.try_parse_records(flow_key, direction); } fn on_flow_close(&mut self, flow_key: &FlowKey, _reason: CloseReason) { @@ -92,16 +449,40 @@ impl StreamHandler for TlsAnalyzer { } } +// ── StreamAnalyzer ──────────────────────────────────────────────────────────── + impl StreamAnalyzer for TlsAnalyzer { fn name(&self) -> &'static str { "TLS" } fn summarize(&self) -> AnalysisSummary { + let mut detail = HashMap::new(); + + // Top SNIs (top 20 by count) + let mut top_snis: Vec<_> = self.sni_counts.iter().collect(); + top_snis.sort_by(|a, b| b.1.cmp(a.1)); + let top_snis: Vec<&str> = top_snis.iter().take(20).map(|(k, _)| k.as_str()).collect(); + detail.insert("top_snis".to_string(), serde_json::json!(top_snis)); + + detail.insert("ja3_hashes".to_string(), serde_json::json!(self.ja3_counts)); + detail.insert("ja3s_hashes".to_string(), serde_json::json!(self.ja3s_counts)); + detail.insert( + "tls_versions".to_string(), + serde_json::json!( + self.version_counts + .iter() + .map(|(k, v)| (k.to_string(), *v)) + .collect::>() + ), + ); + detail.insert("cipher_suites".to_string(), serde_json::json!(self.cipher_counts)); + detail.insert("parse_errors".to_string(), serde_json::json!(self.parse_errors)); + AnalysisSummary { analyzer_name: self.name().to_string(), packets_analyzed: self.handshakes_seen, - detail: HashMap::new(), + detail, } } diff --git a/tests/tls_analyzer_tests.rs b/tests/tls_analyzer_tests.rs new file mode 100644 index 0000000..c94f629 --- /dev/null +++ b/tests/tls_analyzer_tests.rs @@ -0,0 +1,131 @@ +use std::net::IpAddr; +use wirerust::analyzer::tls::TlsAnalyzer; +use wirerust::reassembly::flow::FlowKey; +use wirerust::reassembly::handler::{Direction, StreamAnalyzer, StreamHandler}; + +fn test_flow_key() -> FlowKey { + FlowKey::new( + "10.0.0.1".parse::().unwrap(), + 49153, + "10.0.0.2".parse::().unwrap(), + 443, + ) +} + +/// Build a minimal TLS ClientHello record with SNI and specified cipher suites. +fn build_client_hello(sni: &str, cipher_ids: &[u16]) -> Vec { + let mut extensions = Vec::new(); + + // SNI extension (type 0x0000) + let sni_bytes = sni.as_bytes(); + let sni_list_len = (3 + sni_bytes.len()) as u16; + let sni_ext_len = 2 + sni_list_len; + extensions.extend_from_slice(&[0x00, 0x00]); // extension type: server_name + extensions.extend_from_slice(&sni_ext_len.to_be_bytes()); + extensions.extend_from_slice(&sni_list_len.to_be_bytes()); + extensions.push(0x00); // host_name type + extensions.extend_from_slice(&(sni_bytes.len() as u16).to_be_bytes()); + extensions.extend_from_slice(sni_bytes); + + // Supported Groups extension (type 0x000a) + extensions.extend_from_slice(&[0x00, 0x0a]); // extension type + extensions.extend_from_slice(&[0x00, 0x06]); // extension data length + extensions.extend_from_slice(&[0x00, 0x04]); // named group list length + extensions.extend_from_slice(&[0x00, 0x1d]); // x25519 + extensions.extend_from_slice(&[0x00, 0x17]); // secp256r1 + + // EC Point Formats extension (type 0x000b) + extensions.extend_from_slice(&[0x00, 0x0b]); // extension type + extensions.extend_from_slice(&[0x00, 0x02]); // extension data length + extensions.push(0x01); // ec point formats length + extensions.push(0x00); // uncompressed + + // Build ClientHello body + let mut ch_body = Vec::new(); + ch_body.extend_from_slice(&[0x03, 0x03]); // version: TLS 1.2 + ch_body.extend_from_slice(&[0u8; 32]); // random + ch_body.push(0x00); // session_id length: 0 + + let ciphers_len = (cipher_ids.len() * 2) as u16; + ch_body.extend_from_slice(&ciphers_len.to_be_bytes()); + for &id in cipher_ids { + ch_body.extend_from_slice(&id.to_be_bytes()); + } + + ch_body.push(0x01); // compression methods length + ch_body.push(0x00); // null compression + + let ext_len = extensions.len() as u16; + ch_body.extend_from_slice(&ext_len.to_be_bytes()); + ch_body.extend_from_slice(&extensions); + + // Handshake header + let mut handshake = Vec::new(); + handshake.push(0x01); // ClientHello + let ch_len = ch_body.len() as u32; + handshake.push((ch_len >> 16) as u8); + handshake.push((ch_len >> 8) as u8); + handshake.push(ch_len as u8); + handshake.extend_from_slice(&ch_body); + + // TLS record header + let mut record = Vec::new(); + record.push(0x16); // handshake + record.extend_from_slice(&[0x03, 0x01]); // TLS 1.0 record version + let hs_len = handshake.len() as u16; + record.extend_from_slice(&hs_len.to_be_bytes()); + record.extend_from_slice(&handshake); + + record +} + +#[test] +fn test_parse_client_hello() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let record = build_client_hello("example.com", &[0x1301, 0x1303]); + analyzer.on_data(&fk, Direction::ClientToServer, &record, 0); + + assert_eq!(*analyzer.sni_counts().get("example.com").unwrap(), 1); + assert_eq!(analyzer.ja3_counts().len(), 1); + assert!(!analyzer.ja3_counts().is_empty()); + assert_eq!(*analyzer.version_counts().get(&0x0303).unwrap(), 1); + assert_eq!(analyzer.parse_error_count(), 0); +} + +#[test] +fn test_ja3_grease_filtering() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let record = build_client_hello("test.com", &[0x0a0a, 0x1301]); + analyzer.on_data(&fk, Direction::ClientToServer, &record, 0); + + assert_eq!(analyzer.ja3_counts().len(), 1); + let ja3_hash = analyzer.ja3_counts().keys().next().unwrap(); + assert_eq!(ja3_hash.len(), 32); // MD5 hex = 32 chars +} + +#[test] +fn test_parse_error_counter() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let bad_record = [0x16, 0x03, 0x03, 0x00, 0x05, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + analyzer.on_data(&fk, Direction::ClientToServer, &bad_record, 0); + + assert_eq!(analyzer.parse_error_count(), 1); +} + +#[test] +fn test_normal_request_no_parse_errors() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let record = build_client_hello("example.com", &[0x1301]); + analyzer.on_data(&fk, Direction::ClientToServer, &record, 0); + + assert_eq!(analyzer.parse_error_count(), 0); + assert!(analyzer.findings().is_empty()); +} From ea970965ce507fc7023468c1c8c546fbc91a2f70 Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 09:21:37 -0500 Subject: [PATCH 08/14] test: add ServerHello, weak cipher, summarize, and stop-after-handshake tests --- tests/tls_analyzer_tests.rs | 151 ++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/tests/tls_analyzer_tests.rs b/tests/tls_analyzer_tests.rs index c94f629..c2300c1 100644 --- a/tests/tls_analyzer_tests.rs +++ b/tests/tls_analyzer_tests.rs @@ -79,6 +79,46 @@ fn build_client_hello(sni: &str, cipher_ids: &[u16]) -> Vec { record } +/// Build a minimal TLS ServerHello record. +fn build_server_hello(cipher_id: u16) -> Vec { + // Extensions: just renegotiation_info (0xff01) with empty data + let mut extensions = Vec::new(); + extensions.extend_from_slice(&[0xff, 0x01]); // renegotiation_info + extensions.extend_from_slice(&[0x00, 0x01]); // extension data length + extensions.push(0x00); // empty renegotiation info + + // ServerHello body + let mut sh_body = Vec::new(); + sh_body.extend_from_slice(&[0x03, 0x03]); // version: TLS 1.2 + sh_body.extend_from_slice(&[0u8; 32]); // random + sh_body.push(0x00); // session_id length: 0 + sh_body.extend_from_slice(&cipher_id.to_be_bytes()); // selected cipher + sh_body.push(0x00); // compression: null + + let ext_len = extensions.len() as u16; + sh_body.extend_from_slice(&ext_len.to_be_bytes()); + sh_body.extend_from_slice(&extensions); + + // Handshake header + let mut handshake = Vec::new(); + handshake.push(0x02); // handshake type: ServerHello + let sh_len = sh_body.len() as u32; + handshake.push((sh_len >> 16) as u8); + handshake.push((sh_len >> 8) as u8); + handshake.push(sh_len as u8); + handshake.extend_from_slice(&sh_body); + + // TLS record header + let mut record = Vec::new(); + record.push(0x16); + record.extend_from_slice(&[0x03, 0x03]); + let hs_len = handshake.len() as u16; + record.extend_from_slice(&hs_len.to_be_bytes()); + record.extend_from_slice(&handshake); + + record +} + #[test] fn test_parse_client_hello() { let mut analyzer = TlsAnalyzer::new(); @@ -129,3 +169,114 @@ fn test_normal_request_no_parse_errors() { assert_eq!(analyzer.parse_error_count(), 0); assert!(analyzer.findings().is_empty()); } + +#[test] +fn test_parse_server_hello() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let ch = build_client_hello("example.com", &[0x1301, 0x1303]); + analyzer.on_data(&fk, Direction::ClientToServer, &ch, 0); + + let sh = build_server_hello(0x1301); + analyzer.on_data(&fk, Direction::ServerToClient, &sh, 0); + + assert_eq!(analyzer.ja3s_counts().len(), 1); + assert_eq!(analyzer.parse_error_count(), 0); +} + +#[test] +fn test_weak_cipher_finding_client() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + // TLS_RSA_WITH_NULL_SHA (0x0002) — NULL cipher + let record = build_client_hello("test.com", &[0x0002, 0x1301]); + analyzer.on_data(&fk, Direction::ClientToServer, &record, 0); + + let findings = analyzer.findings(); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].category, wirerust::findings::ThreatCategory::Anomaly); + assert_eq!(findings[0].confidence, wirerust::findings::Confidence::High); + assert!(findings[0].summary.contains("weak cipher")); +} + +#[test] +fn test_weak_cipher_finding_server() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let ch = build_client_hello("test.com", &[0x1301]); + analyzer.on_data(&fk, Direction::ClientToServer, &ch, 0); + + // Server selects TLS_RSA_WITH_RC4_128_SHA (0x0005) + let sh = build_server_hello(0x0005); + analyzer.on_data(&fk, Direction::ServerToClient, &sh, 0); + + let findings = analyzer.findings(); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].confidence, wirerust::findings::Confidence::Medium); + assert!(findings[0].summary.contains("weak cipher")); +} + +#[test] +fn test_normal_handshake_no_findings() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let ch = build_client_hello("example.com", &[0x1301, 0x1303]); + analyzer.on_data(&fk, Direction::ClientToServer, &ch, 0); + + let sh = build_server_hello(0x1301); + analyzer.on_data(&fk, Direction::ServerToClient, &sh, 0); + + assert!(analyzer.findings().is_empty()); + assert_eq!(analyzer.parse_error_count(), 0); +} + +#[test] +fn test_stop_after_handshake() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let ch = build_client_hello("example.com", &[0x1301]); + analyzer.on_data(&fk, Direction::ClientToServer, &ch, 0); + + let sh = build_server_hello(0x1301); + analyzer.on_data(&fk, Direction::ServerToClient, &sh, 0); + + // Send encrypted application data (content_type=0x17) — should be ignored + let mut app_data = vec![0x17, 0x03, 0x03, 0x00, 0x10]; + app_data.extend_from_slice(&[0xAA; 16]); + analyzer.on_data(&fk, Direction::ServerToClient, &app_data, 0); + + // No parse errors from the encrypted data + assert_eq!(analyzer.parse_error_count(), 0); +} + +#[test] +fn test_summarize_output() { + let mut analyzer = TlsAnalyzer::new(); + let fk = test_flow_key(); + + let ch = build_client_hello("example.com", &[0x1301, 0x1303]); + analyzer.on_data(&fk, Direction::ClientToServer, &ch, 0); + + let sh = build_server_hello(0x1301); + analyzer.on_data(&fk, Direction::ServerToClient, &sh, 0); + + let summary = analyzer.summarize(); + assert_eq!(summary.analyzer_name, "TLS"); + assert_eq!(summary.packets_analyzed, 1); + + let detail = &summary.detail; + assert!(detail["top_snis"] + .as_array() + .unwrap() + .contains(&serde_json::json!("example.com"))); + assert!(detail.contains_key("ja3_hashes")); + assert!(detail.contains_key("ja3s_hashes")); + assert!(detail.contains_key("tls_versions")); + assert!(detail.contains_key("cipher_suites")); + assert_eq!(detail["parse_errors"], 0); +} From dc903d24829a5df2447cb96c82aa55f6d45ded40 Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 09:25:40 -0500 Subject: [PATCH 09/14] feat: wire TLS analyzer into CLI via StreamDispatcher --- src/main.rs | 71 ++++++++++++++++++++++++++--------------------------- 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1b90e53..868bf31 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,12 +7,12 @@ use indicatif::{ProgressBar, ProgressStyle}; use wirerust::analyzer::ProtocolAnalyzer; use wirerust::analyzer::dns::DnsAnalyzer; use wirerust::analyzer::http::HttpAnalyzer; +use wirerust::analyzer::tls::TlsAnalyzer; use wirerust::cli::{Cli, Commands, OutputFormat}; use wirerust::decoder::decode_packet; +use wirerust::dispatcher::StreamDispatcher; use wirerust::reader::PcapSource; -use wirerust::reassembly::flow::FlowKey; use wirerust::reassembly::handler::StreamAnalyzer; -use wirerust::reassembly::handler::{CloseReason, Direction, StreamHandler}; use wirerust::reassembly::{ReassemblyConfig, TcpReassembler}; use wirerust::reporter::Reporter; use wirerust::reporter::json::JsonReporter; @@ -29,10 +29,18 @@ fn main() -> Result<()> { targets, dns, http, + tls, all, .. } => { - run_analyze(targets, *dns || *all, *http || *all, use_color, &cli)?; + run_analyze( + targets, + *dns || *all, + *http || *all, + *tls || *all, + use_color, + &cli, + )?; } Commands::Summary { targets, .. } => { run_summary(targets, use_color, &cli)?; @@ -46,6 +54,7 @@ fn run_analyze( targets: &[std::path::PathBuf], enable_dns: bool, enable_http: bool, + enable_tls: bool, use_color: bool, cli: &Cli, ) -> Result<()> { @@ -54,13 +63,12 @@ fn run_analyze( let mut all_findings = Vec::new(); let mut total_decode_errors: u64 = 0; - // Determine if reassembly is needed - let needs_reassembly = cli.reassemble || enable_http; + let needs_reassembly = cli.reassemble || enable_http || enable_tls; let skip_reassembly = cli.no_reassemble; - if enable_http && skip_reassembly { + if (enable_http || enable_tls) && skip_reassembly { eprintln!( - "Warning: --http requires TCP reassembly, but --no-reassemble is set. HTTP analysis will be skipped." + "Warning: --http/--tls require TCP reassembly, but --no-reassemble is set. Stream analysis will be skipped." ); } @@ -75,17 +83,17 @@ fn run_analyze( 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 null_handler = NullHandler; - let mut http_analyzer = if enable_http && !skip_reassembly { + let http_analyzer = if enable_http && !skip_reassembly { Some(HttpAnalyzer::new()) } else { None }; + let tls_analyzer = if enable_tls && !skip_reassembly { + Some(TlsAnalyzer::new()) + } else { + None + }; + let mut dispatcher = StreamDispatcher::new(http_analyzer, tls_analyzer); for target in targets { let pcap_files = resolve_targets(target)?; @@ -107,18 +115,7 @@ fn run_analyze( all_findings.extend(findings); } if let Some(ref mut reasm) = reassembler { - match http_analyzer { - Some(ref mut http) => { - reasm.process_packet(&parsed, raw.timestamp_secs, http); - } - None => { - reasm.process_packet( - &parsed, - raw.timestamp_secs, - &mut null_handler, - ); - } - } + reasm.process_packet(&parsed, raw.timestamp_secs, &mut dispatcher); } } Err(e) => { @@ -139,25 +136,27 @@ fn run_analyze( summary.skipped_packets = total_decode_errors; if let Some(ref mut reasm) = reassembler { - match http_analyzer { - Some(ref mut http) => { - reasm.finalize(http); - all_findings.extend(http.findings()); - } - None => { - reasm.finalize(&mut null_handler); - } - } + reasm.finalize(&mut dispatcher); all_findings.extend(reasm.findings().to_vec()); } + if let Some(ref http) = dispatcher.http { + all_findings.extend(http.findings()); + } + if let Some(ref tls) = dispatcher.tls { + all_findings.extend(tls.findings()); + } + let mut analyzer_summaries = Vec::new(); if enable_dns { analyzer_summaries.push(dns_analyzer.summarize()); } - if let Some(ref http) = http_analyzer { + if let Some(ref http) = dispatcher.http { analyzer_summaries.push(http.summarize()); } + if let Some(ref tls) = dispatcher.tls { + analyzer_summaries.push(tls.summarize()); + } let output = match cli.output_format { Some(OutputFormat::Json) => { From 9312753f4d79257e9ee9edbe21fbca32aae6b19c Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 09:35:14 -0500 Subject: [PATCH 10/14] fix: track extension parse failures in parse_errors counter Extension parsing failures were silently producing empty extension lists via .ok().unwrap_or_default(), computing JA3 with missing data without any indication to the user. Now increments parse_errors so users know fingerprints may be incomplete. Also counts unexpected Incomplete errors from record parsing. Partial JA3 with empty extension fields is still computed (matches Zeek behavior). --- src/analyzer/tls.rs | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/analyzer/tls.rs b/src/analyzer/tls.rs index 92f89fa..bbc6f7d 100644 --- a/src/analyzer/tls.rs +++ b/src/analyzer/tls.rs @@ -251,11 +251,17 @@ impl TlsAnalyzer { let version = ch.version.0; Self::increment(&mut self.version_counts, version, MAX_MAP_ENTRIES); - // Parse extensions - let exts: Vec> = ch - .ext - .and_then(|raw| parse_tls_extensions(raw).ok().map(|(_, v)| v)) - .unwrap_or_default(); + // Parse extensions (compute partial JA3 with empty fields on failure) + let exts: Vec> = match ch.ext { + Some(raw) => match parse_tls_extensions(raw) { + Ok((_, v)) => v, + Err(_) => { + self.parse_errors += 1; + Vec::new() + } + }, + None => Vec::new(), + }; // SNI if let Some(sni) = extract_sni(&exts) { @@ -297,10 +303,16 @@ impl TlsAnalyzer { let version = sh.version.0; Self::increment(&mut self.version_counts, version, MAX_MAP_ENTRIES); - let exts: Vec> = sh - .ext - .and_then(|raw| parse_tls_extensions(raw).ok().map(|(_, v)| v)) - .unwrap_or_default(); + let exts: Vec> = match sh.ext { + Some(raw) => match parse_tls_extensions(raw) { + Ok((_, v)) => v, + Err(_) => { + self.parse_errors += 1; + Vec::new() + } + }, + None => Vec::new(), + }; // JA3S let ja3s_hash = compute_ja3s(version, sh.cipher, &exts); @@ -401,7 +413,8 @@ impl TlsAnalyzer { } } Err(NomErr::Incomplete(_)) => { - // Should not happen since we verified length, but treat gracefully. + // Should not happen since we verified length — count as error if it does. + self.parse_errors += 1; } Err(_) => { self.parse_errors += 1; From e8f9f3d43fd4fe7033c8b4378094ed6f11130f85 Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 09:46:58 -0500 Subject: [PATCH 11/14] feat: detect deprecated SSL 2.0/3.0 protocol versions SSLv3 is prohibited by RFC 7568 (POODLE vulnerability). SSLv2 is even worse (DROWN attack). Generate Anomaly/Likely/High findings for both ClientHello and ServerHello using these versions. Verified against tests/fixtures/tls.pcap (SSL 3.0 traffic). --- src/analyzer/tls.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/analyzer/tls.rs b/src/analyzer/tls.rs index bbc6f7d..038d969 100644 --- a/src/analyzer/tls.rs +++ b/src/analyzer/tls.rs @@ -292,6 +292,27 @@ impl TlsAnalyzer { timestamp: None, }); } + + // Deprecated protocol version detection (SSLv2/SSLv3) + if version <= 0x0300 { + let version_name = match version { + 0x0200 => "SSL 2.0", + 0x0300 => "SSL 3.0", + _ => "Unknown legacy SSL", + }; + self.all_findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Likely, + confidence: Confidence::High, + summary: format!( + "ClientHello uses deprecated protocol ({version_name}, RFC 7568 prohibits SSLv3)" + ), + evidence: vec![format!("Version: 0x{version:04x} ({version_name})")], + mitre_technique: None, + source_ip: None, + timestamp: None, + }); + } } /// Process a single complete ServerHello. @@ -334,6 +355,27 @@ impl TlsAnalyzer { timestamp: None, }); } + + // Deprecated protocol version — server selecting SSLv2/SSLv3 is critical + if version <= 0x0300 { + let version_name = match version { + 0x0200 => "SSL 2.0", + 0x0300 => "SSL 3.0", + _ => "Unknown legacy SSL", + }; + self.all_findings.push(Finding { + category: ThreatCategory::Anomaly, + verdict: Verdict::Likely, + confidence: Confidence::High, + summary: format!( + "ServerHello negotiated deprecated protocol ({version_name}, RFC 7568 prohibits SSLv3)" + ), + evidence: vec![format!("Version: 0x{version:04x} ({version_name})")], + mitre_technique: None, + source_ip: None, + timestamp: None, + }); + } } /// Extract complete TLS records from the given direction's buffer and process them. From cfab26abe2958c32ecfa8678c9a0bf565c77db6f Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 09:57:53 -0500 Subject: [PATCH 12/14] test: add TLS integration tests with real PCAP fixtures Add TLS 1.2 (aes256gcm, with SNI) and TLS 1.3 (rfc8446) pcap fixtures from the Wireshark test suite. 4 integration tests verify: - TLS 1.2: SNI extraction, JA3/JA3S computation, version tracking - TLS 1.3: legacy_version (771) in JA3, 2 handshakes parsed - SSL 3.0: deprecated protocol + weak cipher findings generated - summarize() output has all required fields --- tests/fixtures/tls12-aes256gcm.pcap | Bin 0 -> 2064 bytes tests/fixtures/tls13-rfc8446.pcap | Bin 0 -> 4158 bytes tests/tls_integration_tests.rs | 129 ++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 tests/fixtures/tls12-aes256gcm.pcap create mode 100644 tests/fixtures/tls13-rfc8446.pcap create mode 100644 tests/tls_integration_tests.rs diff --git a/tests/fixtures/tls12-aes256gcm.pcap b/tests/fixtures/tls12-aes256gcm.pcap new file mode 100644 index 0000000000000000000000000000000000000000..576739c852ad32e5eb602f083e8476ccf3789692 GIT binary patch literal 2064 zcmZvc2{crD9LN9HWe5>v$)2Q4lBIPt{V#FUR& zCeOEo!7d>H>@Z6kUXn^|EjVvtFG&n(Gj{(Xtxn@A8*ZXoq-T*eg4Ow2G(xlMc>Alm zWwnh_-v)IJUt7HIHp{7drs>xu21)@C!93+*7zj%tcBrsTI|LGtYcz8U6Ao8)!p5p6 z5S|$;ACdOKg#BlQJAaN92I7kVzyV?E0Sa{qLV_Taw@T3agvD)!j&>tFu5Y&WVC763Xe@(c66-6(xn@-Kr6Yr@%1ezTTuDvCUvhBW zp%#0@520_g1|S4^AI;pt=-$dM7@aVWzI&GLGD{zV%?o#95Sv{YKQXL5MCY(u*~Pd=`zny-pW$~-pq;TA}3yq4jYP_ zPtwbUX-dTMs&fLZK^i9dozr zbjGWk?jx+})14y4I^%C{c=TYwv9-|+6(g$5JMj-RpGbAX1g#z2Pi}r)Xj_IC9SZk4 z87Q1q=d*?1+Qg&WlFbNF9JS)MoJp^|Z1u3B!Z=;w#nrtx#52n#Bz&$L*ztVUEe^_d z!<|{gU($0#W?RAVgZ7vPW63|--}w$c<_$efQq{L3($({OBrn8Ues?iZa6H7ut zQ8$$MeM`7R=;3nVN>W&?`{jO(otXpcO`^l?vOX9s3_taer8YWcEo7Q*K;1Xp;hPxS z!K!x}BQT~m>&A@#*=2v-E&S~S%jGVeP5#!0>lb-dbhv^T;pzBn(tt5yZq1;poo{@< z8As6^^KgEy81~8q^rllQ%AK-gFGz7WP&>!=o)h}Z;aW=jUF(`1-Ac0Aw5wiS&qxa9 zINp?B#9Dd(<^ZPGt|*(RQ&uofP4+4=>`01S-i_|AVG9=JKy7!GVdia}R*rUSgs1vy zjg6MqYsOaH3>k`afAjdzJ<^wq=l07v!ePV zhKsI@IP?S>TnygqIuPV)NICWLotlAm=_e%>3%G~ge3R{M<`t~jX*KcPEv@LjUie4D zO>&}zoF=`ItlY=RTafxXFAjYssDzrCzk*FC)--P93eQ@xShLNm9pF9T7Ai*72u s>DNuo92IQp^p0OPHChh&GAPZo5z6>_wxa;YDAxT)GN#_>QBq6NzYuQZA^-pY literal 0 HcmV?d00001 diff --git a/tests/fixtures/tls13-rfc8446.pcap b/tests/fixtures/tls13-rfc8446.pcap new file mode 100644 index 0000000000000000000000000000000000000000..4500f5931fb66e4264616ef75dad695eb1f565c7 GIT binary patch literal 4158 zcmbuB2{hDeAIG0rY=a6jB1=NZp0Z4Cw#g--uDwJmE~T;*LKsWNI+jRDD!XXxTNI-( z_BH#G^{Q+sOA_z@-@Wy|+;iUdJ?}f`eCM41nK{q%`+lF#@Be$cUgbYQ0yOyN2MrL= zgMGD&`Sbe>Ko)v{743?DN%al!QcE=8VFa2;gkYnk8c?G@a~{J45Rj0uk%)3Gx@V$1 zXo*fD7nsBy004nt#E{uGhGI+_nmJJj@D5sO5`_|xUt8yV9j|iVdjAx;_$%`%!UKHm9!$Yl3feFcdCAk2Z_Kysj} zB&uYpB&%ebGMh50n5sCcFjZKP03HGvAP+FmW;Ztxu$|qIBOyqv01MdBC`L3IL5E_b zqeC##(<6`wzzm=-a0rM1VZa9j7&zw|?z%s`N%;M<=2`m7Xgkh1&_Su64;UhtO%)Hw z11aeL2p|DiSx{&nUZ3446bcF1!vID=4=u18aKZVZQy4%r63PzJpUM51BAg#GK<_D> zUv~kV9h@PeJT?-}j*E_hgYx^p8Tw~_P`kub|>>Hkhsu!Z7B83i1Cme4K3^w7*_{{AM zw9*B*US(4fxYML`3l?U+VQhIxX9kk2RQg(n_su$yI_U=Rr*F(?sYbDCXE;R$DZjOL zP`N^lXEk~rQ0s9?*>!l%u^)HmvaGg3Tv&|GRnH5r;(dc4;3Q&25L*m>*T+Ln-6%e0}F{V7k zk`MWe7Br9Z3)(s4Oce(UX&o{CusZ6~S@M9M`6X&UM#Pfw0bxFAw2tG_rjiQ(HFAN~z$AbSn(} z$XT`4Pc?UWIS#6~MhF)SowBhRO|Nl!%pcCkO%+=U(@EfdJDj{2(o-SBroKW*3C-QG z`9Nc$7ZeON{|pnVOJM+hP+!2>-r?XzqZLf3IqTLAr{H+d(n1iSb6C%x9XtqWV@OF`?c3i3A|^|EGFG?G#uYuV81Lzh*ZD2huQ16 z^_T7o)KyebDe-h$se{GPa^4X4MvP0@kgut=$(tr(?DBN_9MQN}qVJBZN9Zn$T(!`S z-#j!D@{&%)FYo&p8M`X=zPa;d)w+dX{c~lK_EDhR8{V09CD!khoS^~3SqD|FCutM+ zbP^b@cngg(DMjrYee)YFHVNHHw?x>qK162gII%#iR!AHtsbPa|545#WT1A^?HK*!K z2N4WhIocW>E9Db$*9qD|4}ZC~z-1VezYpQQwvois=xjALGm{m6exlXo_Qd*3(vz=# z$qR&qrwh_Gx#GD-R|jf&2jw|w2lBJ8dhbYjqAbUU{Yqcq5m5ppBIR!2IGDpH>x9J> z_ywH&)v5)~m#ZnnAs;n$`BEM$kLlN2NsFkj@;!&o*Z?|X2Z%UWf1~bvpAncS4;Tv2Z0LRTkUy@{LMPM zd^f8I?C|!}(Do1~-?bevP^p&>6)~A22r)kxF>LKm#Jc`M3+MX>E&*Kf7W0LrpZ8V~sqtt0X zEoa0EGB?So_E)y-%&%fhdh(%}jfCbWVjYDlcGv2EsB%JbI(swIWgL?}AGtEaiXXl? zfR|A!%S_=DsN|fej{RzWd+oX=R{>t@lm10Z)0%o~73K1MnwEiGrPPh z_@k^z%tv8Mf>?}QGJ@^>FS&+l7vhEdOJwR30_YCa+_7cKtn?U!qA|(jAGqJ{Yv%D7in{ z|IKZy`qQ{YC#iF^z5Z!AcH?BP3xW|*8X8r@&ymAVRmFW%-qUA#*V3A8xNZ9^QoNm5 zDB~HGG1=7HaW|gSy34uL=N%y{8J<_0aq$ny@}8f}trnKZ@fXx-mNYY4+8^)CPaBMV z)~&r;WiCsndl~E#h$zcYzrz+A-YC02VXolhAKAWsVguwC`jtH>#TWPjH5jW)iY&kd zN(I(-tbi?#4~!LNeJfVi#eZOhy|x9b!ha45F$88rj$w!xRXmnzDmm9=_(_U8+s3&> zo>9xAp|dG@GPFpYS?hOzhl>zB$^w!hu3>F&Tx4v~5x58k_J3*IXt)55((4v*<8mha zXxyG+GQ>B0pCUpl-9n+7xU0uf@7yxj5F)Mc@;tE~D$`;@w&3nkE>uC&+V!;8gmD$llC6;NGr6pvo9 z2N4|6N=kWbhaBHxjLVy2p};ZzGA}Y#aqPFft0uD*++pqd zACK|Ms}101EZ?*mL0crkL!IsH!kuqRk%LTUg2EM&1+2{ z4>e>Gp`^HBj0-1t23~$y!M7OF(X>o+6e3GMutvB}gqYyE=L)z)ROFn2x z-YK?32bM>eRlX~~M2ZCZOlR(NDZzL@Lk$WDzSFkyRxl~vR86@rleb}fc(xcub`nCi z1%d+Bc4R?Skur>|>wgw(`q@eVeNGgI&=8xe|_&8xw-jdP|SGj}- zr*7pd3TFkT^=S$7O=%Ys#Pn%W8W-G@z(V1Feh1a%vzgueVe_VXdCRQ?+*VWpUQ|1mqri0HQ$*)EkRl+?yH@R=O0NzmN=cI z7irE&7j?-U6Y{etlQ3E(OSAcvnchX_q`_6HbL|934U4GlT8E1KqV8GUKcjxRyxG^4?1UIF85s@ryyPOO+9PC% zCX#8|f_)pAMJ35Gm_)8RpM%Kd(wL{;-2IspH;5heRLOUtOHvWJb40p*_j;@vrS?A< aIa2JM#4%{vO^~~yVVyF7KilvzVE7+Svs@kk literal 0 HcmV?d00001 diff --git a/tests/tls_integration_tests.rs b/tests/tls_integration_tests.rs new file mode 100644 index 0000000..db02f5a --- /dev/null +++ b/tests/tls_integration_tests.rs @@ -0,0 +1,129 @@ +use wirerust::analyzer::tls::TlsAnalyzer; +use wirerust::decoder::decode_packet; +use wirerust::dispatcher::StreamDispatcher; +use wirerust::reader::PcapSource; +use wirerust::reassembly::handler::StreamAnalyzer; +use wirerust::reassembly::{ReassemblyConfig, TcpReassembler}; + +/// Run the full pipeline: PCAP → decoder → reassembler → dispatcher → TLS analyzer +fn analyze_pcap(path: &str) -> TlsAnalyzer { + let source = PcapSource::from_file(std::path::Path::new(path)).unwrap(); + let config = ReassemblyConfig::default(); + let mut reasm = TcpReassembler::new(config); + let mut dispatcher = StreamDispatcher::new(None, Some(TlsAnalyzer::new())); + + for raw in &source.packets { + if let Ok(parsed) = decode_packet(&raw.data, source.datalink) { + reasm.process_packet(&parsed, raw.timestamp_secs, &mut dispatcher); + } + } + reasm.finalize(&mut dispatcher); + + // Move the TLS analyzer out of the dispatcher + dispatcher.tls.unwrap() +} + +#[test] +fn test_tls12_pcap_sni_and_ja3() { + let tls = analyze_pcap("tests/fixtures/tls12-aes256gcm.pcap"); + + // Should have parsed at least 1 ClientHello + assert!(tls.handshake_count() >= 1); + assert_eq!(tls.parse_error_count(), 0); + + // SNI should be "localhost" + assert_eq!(*tls.sni_counts().get("localhost").unwrap(), 1); + + // JA3 should be computed (32-char hex) + assert_eq!(tls.ja3_counts().len(), 1); + let ja3 = tls.ja3_counts().keys().next().unwrap(); + assert_eq!(ja3.len(), 32); + + // JA3S should be computed + assert!(!tls.ja3s_counts().is_empty()); + + // TLS version should be 771 (0x0303 = TLS 1.2) + assert!(tls.version_counts().contains_key(&0x0303)); + + // No findings for modern cipher suites + assert!(tls.findings().is_empty()); +} + +#[test] +fn test_tls13_pcap_version_and_ja3() { + let tls = analyze_pcap("tests/fixtures/tls13-rfc8446.pcap"); + + // Should have parsed 2 ClientHellos (2 connections in this capture) + assert_eq!(tls.handshake_count(), 2); + assert_eq!(tls.parse_error_count(), 0); + + // TLS 1.3 ClientHello legacy_version is 0x0303 (771) + // This is correct per JA3 spec — use header version, not supported_versions + assert!(tls.version_counts().contains_key(&0x0303)); + + // 2 unique JA3 hashes + assert_eq!(tls.ja3_counts().len(), 2); + + // JA3S hashes computed + assert_eq!(tls.ja3s_counts().len(), 2); + + // No findings — modern ciphers + assert!(tls.findings().is_empty()); +} + +#[test] +fn test_ssl30_pcap_generates_findings() { + let tls = analyze_pcap("tests/fixtures/tls.pcap"); + + // SSL 3.0 (version 0x0300 = 768) should be detected + assert!(tls.version_counts().contains_key(&0x0300)); + + // Should generate findings for deprecated protocol AND weak ciphers + let findings = tls.findings(); + assert!( + !findings.is_empty(), + "SSL 3.0 traffic should generate security findings" + ); + + // At least one finding about deprecated protocol + assert!( + findings + .iter() + .any(|f| f.summary.contains("deprecated protocol")), + "Should flag SSL 3.0 as deprecated" + ); + + // At least one finding about weak ciphers (export ciphers in this capture) + assert!( + findings + .iter() + .any(|f| f.summary.contains("weak cipher")), + "Should flag export cipher suites" + ); +} + +#[test] +fn test_summarize_has_all_required_fields() { + let tls = analyze_pcap("tests/fixtures/tls12-aes256gcm.pcap"); + let summary = tls.summarize(); + + assert_eq!(summary.analyzer_name, "TLS"); + let detail = &summary.detail; + + // All required keys present + assert!(detail.contains_key("top_snis"), "missing top_snis"); + assert!(detail.contains_key("ja3_hashes"), "missing ja3_hashes"); + assert!(detail.contains_key("ja3s_hashes"), "missing ja3s_hashes"); + assert!( + detail.contains_key("tls_versions"), + "missing tls_versions" + ); + assert!( + detail.contains_key("cipher_suites"), + "missing cipher_suites" + ); + assert!( + detail.contains_key("parse_errors"), + "missing parse_errors" + ); +} From f2805093b354241577058e35947351315cc1f461 Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 09:59:37 -0500 Subject: [PATCH 13/14] style: apply rustfmt formatting --- Cargo.lock | 252 +++++++++++++++++++++++++++++++++ src/analyzer/tls.rs | 35 ++++- tests/dispatcher_tests.rs | 6 +- tests/tls_analyzer_tests.rs | 20 ++- tests/tls_integration_tests.rs | 14 +- 5 files changed, 299 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e7f8ca..8d32e0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,6 +109,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -230,6 +239,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -261,6 +276,15 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csv" version = "1.4.0" @@ -299,6 +323,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "either" version = "1.15.0" @@ -403,6 +438,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -504,12 +548,60 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom-derive" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ff943d68b88d0b87a6e0d58615e8fa07f9fd5a1319fa0a72efc1f62275c79a7" +dependencies = [ + "nom", + "nom-derive-impl", + "rustversion", +] + +[[package]] +name = "nom-derive-impl" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b9a93a84b0d3ec3e70e02d332dc33ac6dfac9cde63e17fcb77172dededa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -525,6 +617,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -560,6 +674,44 @@ dependencies = [ "thiserror", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -606,6 +758,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -630,6 +791,21 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rayon" version = "1.11.0" @@ -679,6 +855,15 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -759,6 +944,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "strsim" version = "0.11.1" @@ -826,6 +1017,56 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tls-parser" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22c36249c6082584b1f224e70f6bdadf5102197be6cfa92b353efe605d9ac741" +dependencies = [ + "nom", + "nom-derive", + "num_enum", + "phf", + "phf_codegen", + "rusticata-macros", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1107,6 +1348,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + [[package]] name = "wirerust" version = "0.1.0" @@ -1119,6 +1369,7 @@ dependencies = [ "etherparse", "httparse", "indicatif", + "md-5", "owo-colors", "pcap-file", "predicates", @@ -1126,6 +1377,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "tls-parser", ] [[package]] diff --git a/src/analyzer/tls.rs b/src/analyzer/tls.rs index 038d969..dc0a08a 100644 --- a/src/analyzer/tls.rs +++ b/src/analyzer/tls.rs @@ -81,7 +81,11 @@ fn compute_ja3( .filter_map(|e| { let t: TlsExtensionType = e.into(); let v: u16 = t.into(); - if is_grease_u16(v) { None } else { Some(v.to_string()) } + if is_grease_u16(v) { + None + } else { + Some(v.to_string()) + } }) .collect::>() .join("-"); @@ -125,7 +129,11 @@ fn compute_ja3s(version: u16, cipher: TlsCipherSuiteID, extensions: &[TlsExtensi .filter_map(|e| { let t: TlsExtensionType = e.into(); let v: u16 = t.into(); - if is_grease_u16(v) { None } else { Some(v.to_string()) } + if is_grease_u16(v) { + None + } else { + Some(v.to_string()) + } }) .collect::>() .join("-"); @@ -285,7 +293,8 @@ impl TlsAnalyzer { category: ThreatCategory::Anomaly, verdict: Verdict::Likely, confidence: Confidence::High, - summary: "ClientHello offers weak cipher suites (NULL/anonymous/export)".to_string(), + summary: "ClientHello offers weak cipher suites (NULL/anonymous/export)" + .to_string(), evidence: weak, mitre_technique: None, source_ip: None, @@ -477,7 +486,10 @@ impl StreamHandler for TlsAnalyzer { } { - let state = self.flows.entry(flow_key.clone()).or_insert_with(TlsFlowState::new); + let state = self + .flows + .entry(flow_key.clone()) + .or_insert_with(TlsFlowState::new); match direction { Direction::ClientToServer => { let remaining = MAX_BUF.saturating_sub(state.client_buf.len()); @@ -521,7 +533,10 @@ impl StreamAnalyzer for TlsAnalyzer { detail.insert("top_snis".to_string(), serde_json::json!(top_snis)); detail.insert("ja3_hashes".to_string(), serde_json::json!(self.ja3_counts)); - detail.insert("ja3s_hashes".to_string(), serde_json::json!(self.ja3s_counts)); + detail.insert( + "ja3s_hashes".to_string(), + serde_json::json!(self.ja3s_counts), + ); detail.insert( "tls_versions".to_string(), serde_json::json!( @@ -531,8 +546,14 @@ impl StreamAnalyzer for TlsAnalyzer { .collect::>() ), ); - detail.insert("cipher_suites".to_string(), serde_json::json!(self.cipher_counts)); - detail.insert("parse_errors".to_string(), serde_json::json!(self.parse_errors)); + detail.insert( + "cipher_suites".to_string(), + serde_json::json!(self.cipher_counts), + ); + detail.insert( + "parse_errors".to_string(), + serde_json::json!(self.parse_errors), + ); AnalysisSummary { analyzer_name: self.name().to_string(), diff --git a/tests/dispatcher_tests.rs b/tests/dispatcher_tests.rs index 3bbb75c..5cd2865 100644 --- a/tests/dispatcher_tests.rs +++ b/tests/dispatcher_tests.rs @@ -42,8 +42,7 @@ fn test_dispatcher_routes_http() { #[test] fn test_dispatcher_content_detection_tls_on_port_80() { - let mut dispatcher = - StreamDispatcher::new(Some(HttpAnalyzer::new()), Some(TlsAnalyzer::new())); + let mut dispatcher = StreamDispatcher::new(Some(HttpAnalyzer::new()), Some(TlsAnalyzer::new())); let fk = flow_key(49152, 80); // Port 80, but content is TLS // TLS record header on port 80 — content detection should override port @@ -57,8 +56,7 @@ fn test_dispatcher_content_detection_tls_on_port_80() { #[test] fn test_dispatcher_port_fallback_short_data() { - let mut dispatcher = - StreamDispatcher::new(Some(HttpAnalyzer::new()), Some(TlsAnalyzer::new())); + let mut dispatcher = StreamDispatcher::new(Some(HttpAnalyzer::new()), Some(TlsAnalyzer::new())); let fk = flow_key(49152, 443); // Port 443 // Only 2 bytes — too short for content detection, falls back to port diff --git a/tests/tls_analyzer_tests.rs b/tests/tls_analyzer_tests.rs index c2300c1..0419962 100644 --- a/tests/tls_analyzer_tests.rs +++ b/tests/tls_analyzer_tests.rs @@ -196,7 +196,10 @@ fn test_weak_cipher_finding_client() { let findings = analyzer.findings(); assert_eq!(findings.len(), 1); - assert_eq!(findings[0].category, wirerust::findings::ThreatCategory::Anomaly); + assert_eq!( + findings[0].category, + wirerust::findings::ThreatCategory::Anomaly + ); assert_eq!(findings[0].confidence, wirerust::findings::Confidence::High); assert!(findings[0].summary.contains("weak cipher")); } @@ -215,7 +218,10 @@ fn test_weak_cipher_finding_server() { let findings = analyzer.findings(); assert_eq!(findings.len(), 1); - assert_eq!(findings[0].confidence, wirerust::findings::Confidence::Medium); + assert_eq!( + findings[0].confidence, + wirerust::findings::Confidence::Medium + ); assert!(findings[0].summary.contains("weak cipher")); } @@ -270,10 +276,12 @@ fn test_summarize_output() { assert_eq!(summary.packets_analyzed, 1); let detail = &summary.detail; - assert!(detail["top_snis"] - .as_array() - .unwrap() - .contains(&serde_json::json!("example.com"))); + assert!( + detail["top_snis"] + .as_array() + .unwrap() + .contains(&serde_json::json!("example.com")) + ); assert!(detail.contains_key("ja3_hashes")); assert!(detail.contains_key("ja3s_hashes")); assert!(detail.contains_key("tls_versions")); diff --git a/tests/tls_integration_tests.rs b/tests/tls_integration_tests.rs index db02f5a..05a8b7d 100644 --- a/tests/tls_integration_tests.rs +++ b/tests/tls_integration_tests.rs @@ -95,9 +95,7 @@ fn test_ssl30_pcap_generates_findings() { // At least one finding about weak ciphers (export ciphers in this capture) assert!( - findings - .iter() - .any(|f| f.summary.contains("weak cipher")), + findings.iter().any(|f| f.summary.contains("weak cipher")), "Should flag export cipher suites" ); } @@ -114,16 +112,10 @@ fn test_summarize_has_all_required_fields() { assert!(detail.contains_key("top_snis"), "missing top_snis"); assert!(detail.contains_key("ja3_hashes"), "missing ja3_hashes"); assert!(detail.contains_key("ja3s_hashes"), "missing ja3s_hashes"); - assert!( - detail.contains_key("tls_versions"), - "missing tls_versions" - ); + assert!(detail.contains_key("tls_versions"), "missing tls_versions"); assert!( detail.contains_key("cipher_suites"), "missing cipher_suites" ); - assert!( - detail.contains_key("parse_errors"), - "missing parse_errors" - ); + assert!(detail.contains_key("parse_errors"), "missing parse_errors"); } From 343a101fdc2e615d5b67cbf7bafd654d87404e81 Mon Sep 17 00:00:00 2001 From: Zious Date: Tue, 7 Apr 2026 10:07:00 -0500 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20dispatcher=20reclassification,=20record=20length=20?= =?UTF-8?q?check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Don't cache DispatchTarget::None — allow reclassification on subsequent on_data when more bytes arrive (Copilot #1) 2. Add MAX_RECORD_PAYLOAD (18,432) sanity check to reject impossibly large TLS records that would pin buffer memory (Copilot #2) 3. Stronger dispatcher test assertions — verify HTTP parse_error_count stays 0 when TLS is routed correctly (Copilot #3) 4. Early return in dispatcher when no analyzers enabled (Copilot #4) --- src/analyzer/tls.rs | 15 +++++++++++++++ src/dispatcher.rs | 18 ++++++++++++++---- tests/dispatcher_tests.rs | 2 ++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/analyzer/tls.rs b/src/analyzer/tls.rs index dc0a08a..b5bd66a 100644 --- a/src/analyzer/tls.rs +++ b/src/analyzer/tls.rs @@ -13,6 +13,9 @@ use crate::reassembly::handler::{CloseReason, Direction, StreamAnalyzer, StreamH const MAX_BUF: usize = 65_536; const MAX_MAP_ENTRIES: usize = 50_000; +/// Max valid TLS record payload: 18,432 bytes (TLS 1.2 ciphertext max per RFC 5246). +/// TLS 1.3 max is 16,640 but we use the larger value as a safe upper bound. +const MAX_RECORD_PAYLOAD: usize = 18_432; // ── helpers ────────────────────────────────────────────────────────────────── @@ -414,6 +417,18 @@ impl TlsAnalyzer { (record_type, payload_len) }; + // Reject impossibly large records (DoS protection) + if payload_len > MAX_RECORD_PAYLOAD { + self.parse_errors += 1; + if let Some(state) = self.flows.get_mut(flow_key) { + match direction { + Direction::ClientToServer => state.client_buf.clear(), + Direction::ServerToClient => state.server_buf.clear(), + }; + } + return; + } + let total_record_len = 5 + payload_len; if buf_len < total_record_len { // Incomplete record — wait for more data. diff --git a/src/dispatcher.rs b/src/dispatcher.rs index e354f9b..1af8c9d 100644 --- a/src/dispatcher.rs +++ b/src/dispatcher.rs @@ -59,10 +59,20 @@ fn classify(data: &[u8], flow_key: &FlowKey) -> DispatchTarget { impl StreamHandler for StreamDispatcher { fn on_data(&mut self, flow_key: &FlowKey, direction: Direction, data: &[u8], offset: u64) { - let target = *self - .routes - .entry(flow_key.clone()) - .or_insert_with(|| classify(data, flow_key)); + if self.http.is_none() && self.tls.is_none() { + return; + } + + // Don't cache None — allow reclassification on next on_data with more bytes + let target = if let Some(&cached) = self.routes.get(flow_key) { + cached + } else { + let target = classify(data, flow_key); + if target != DispatchTarget::None { + self.routes.insert(flow_key.clone(), target); + } + target + }; match target { DispatchTarget::Http => { diff --git a/tests/dispatcher_tests.rs b/tests/dispatcher_tests.rs index 5cd2865..a2864b7 100644 --- a/tests/dispatcher_tests.rs +++ b/tests/dispatcher_tests.rs @@ -52,6 +52,7 @@ fn test_dispatcher_content_detection_tls_on_port_80() { // HTTP analyzer should NOT have received this data let http = dispatcher.http.as_ref().unwrap(); assert_eq!(http.method_counts().len(), 0); + assert_eq!(http.parse_error_count(), 0); // Confirms HTTP didn't try to parse TLS bytes } #[test] @@ -67,4 +68,5 @@ fn test_dispatcher_port_fallback_short_data() { // HTTP should not have received it let http = dispatcher.http.as_ref().unwrap(); assert_eq!(http.method_counts().len(), 0); + assert_eq!(http.parse_error_count(), 0); // Confirms HTTP didn't try to parse TLS bytes }