From 433b3343e6536d2107725631464cab8fbbfafdf0 Mon Sep 17 00:00:00 2001 From: Zious Date: Fri, 10 Apr 2026 16:08:42 -0400 Subject: [PATCH 1/2] test: add missing HTTP analyzer test coverage (#20) Add 6 tests closing 5 of 7 gaps identified in issue #20 (the other 2 were already covered by existing tests): - test_buffer_cap_no_panic_on_oversized_headers: >64KB header data is silently truncated, no panic, no unbounded growth - test_detect_long_uri: URI >2048 chars triggers Execution finding - test_detect_empty_user_agent: empty UA header triggers Anomaly finding - test_missing_user_agent_no_finding: absent UA header is not flagged - test_detect_admin_panel_paths: /wp-admin, /admin, /phpmyadmin, /manager - test_partial_response_reassembly: split response across two chunks Closes #20 --- tests/http_analyzer_tests.rs | 202 +++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/tests/http_analyzer_tests.rs b/tests/http_analyzer_tests.rs index cab7c6c..d25271a 100644 --- a/tests/http_analyzer_tests.rs +++ b/tests/http_analyzer_tests.rs @@ -509,3 +509,205 @@ fn test_cross_flow_isolation_poisoning() { skipped_before + b"more data".len() as u64 ); } + +// --------------------------------------------------------------------------- +// Issue #20: missing HTTP analyzer test coverage +// --------------------------------------------------------------------------- + +#[test] +fn test_buffer_cap_no_panic_on_oversized_headers() { + // MAX_HEADER_BUF is 65_536. Data beyond this limit is silently + // truncated — the buffer must not grow unbounded. The truncated + // partial header will stay in httparse::Status::Partial state. + // Key assertion: no panic, no findings, parse errors stay at 0 + // (Partial is not an error). + let mut analyzer = HttpAnalyzer::new(); + let fk = test_flow_key(); + + // Use a single header with a massive value to exceed 64KB without + // hitting MAX_HEADERS (96). The request line + Host: header + the + // large X-Big header totals > 65536 bytes, so the buffer truncates + // mid-value. httparse sees an incomplete header and returns Partial. + let mut oversized = b"GET / HTTP/1.1\r\nHost: example.com\r\nX-Big: ".to_vec(); + oversized.extend_from_slice(&vec![b'A'; 70_000]); + // No \r\n\r\n — after truncation, httparse sees a partial header value. + + analyzer.on_data(&fk, Direction::ClientToServer, &oversized, 0); + + // No panic occurred (implicit). No parse errors — Partial is not an error. + assert_eq!( + analyzer.parse_error_count(), + 0, + "partial header from buffer cap should not count as a parse error" + ); + // No findings from truncated partial data. + assert!( + analyzer.findings().is_empty(), + "truncated partial data should not produce findings" + ); + // Subsequent valid data on a NEW flow should still work (analyzer not corrupted). + let fk2 = test_flow_key_b(); + let valid = b"GET /ok HTTP/1.1\r\nHost: example.com\r\n\r\n"; + analyzer.on_data(&fk2, Direction::ClientToServer, valid, 0); + assert_eq!( + *analyzer.method_counts().get("GET").unwrap(), + 1, + "valid request on a different flow should parse after buffer-cap hit" + ); +} + +#[test] +fn test_detect_long_uri() { + // URIs > 2048 chars should trigger an Execution finding with the + // URI length in the summary and a truncated prefix in evidence. + let mut analyzer = HttpAnalyzer::new(); + let fk = test_flow_key(); + + let long_path = "/".to_string() + &"A".repeat(2100); + let request = format!("GET {long_path} HTTP/1.1\r\nHost: target.com\r\n\r\n"); + analyzer.on_data(&fk, Direction::ClientToServer, request.as_bytes(), 0); + + let findings = analyzer.findings(); + let long_uri_finding = findings + .iter() + .find(|f| f.summary.contains("Abnormally long URI")) + .expect("expected a long-URI finding for URI > 2048 chars"); + assert_eq!(long_uri_finding.category, ThreatCategory::Execution); + assert_eq!(long_uri_finding.verdict, Verdict::Likely); + assert_eq!(long_uri_finding.confidence, Confidence::Medium); + assert!( + long_uri_finding.summary.contains("2101 chars"), + "summary should include the URI length, got: {}", + long_uri_finding.summary + ); + assert!( + long_uri_finding.evidence[0].starts_with("URI prefix:"), + "evidence should contain truncated URI prefix, got: {}", + long_uri_finding.evidence[0] + ); +} + +#[test] +fn test_detect_empty_user_agent() { + // An empty User-Agent header (present but "") should trigger an + // Anomaly finding. This is more suspicious than a missing UA — + // real browsers always populate it, and even common tools + // (curl, wget, Python requests) send a default string. + let mut analyzer = HttpAnalyzer::new(); + let fk = test_flow_key(); + + let request = b"GET /page HTTP/1.1\r\nHost: example.com\r\nUser-Agent: \r\n\r\n"; + analyzer.on_data(&fk, Direction::ClientToServer, request, 0); + + let findings = analyzer.findings(); + let ua_finding = findings + .iter() + .find(|f| f.summary.contains("Empty User-Agent")) + .expect("expected an empty-UA finding"); + assert_eq!(ua_finding.category, ThreatCategory::Anomaly); + assert_eq!(ua_finding.verdict, Verdict::Inconclusive); + assert_eq!(ua_finding.confidence, Confidence::Low); +} + +#[test] +fn test_missing_user_agent_no_finding() { + // A missing User-Agent header (not present at all) should NOT + // trigger the empty-UA finding. The detection specifically checks + // for Some(""), not None. + let mut analyzer = HttpAnalyzer::new(); + let fk = test_flow_key(); + + let request = b"GET /page HTTP/1.1\r\nHost: example.com\r\n\r\n"; + analyzer.on_data(&fk, Direction::ClientToServer, request, 0); + + assert!( + !analyzer + .findings() + .iter() + .any(|f| f.summary.contains("User-Agent")), + "missing (absent) User-Agent should not trigger empty-UA finding" + ); +} + +#[test] +fn test_detect_admin_panel_paths() { + // Admin panel URIs should trigger Reconnaissance findings. + let patterns = [ + "/wp-admin/index.php", + "/admin/dashboard", + "/phpmyadmin/", + "/manager/html", + ]; + + for pattern in &patterns { + let mut analyzer = HttpAnalyzer::new(); + let fk = test_flow_key(); + + let request = format!("GET {pattern} HTTP/1.1\r\nHost: target.com\r\n\r\n"); + analyzer.on_data(&fk, Direction::ClientToServer, request.as_bytes(), 0); + + let findings = analyzer.findings(); + let admin_finding = findings + .iter() + .find(|f| f.summary.contains("Admin panel")) + .unwrap_or_else(|| panic!("expected admin-panel finding for URI {pattern}")); + assert_eq!( + admin_finding.category, + ThreatCategory::Reconnaissance, + "admin panel finding for {pattern} should be Reconnaissance" + ); + assert_eq!( + admin_finding.verdict, + Verdict::Inconclusive, + "admin panel finding for {pattern} should be Inconclusive" + ); + assert_eq!( + admin_finding.confidence, + Confidence::Low, + "admin panel finding for {pattern} should be Low confidence" + ); + assert_eq!( + admin_finding.mitre_technique.as_deref(), + Some("T1046"), + "admin panel finding for {pattern} should map to T1046" + ); + } +} + +#[test] +fn test_partial_response_reassembly() { + // Split a response header across two on_data calls. The parser + // should buffer the first partial chunk and complete the parse + // when the rest arrives. + let mut analyzer = HttpAnalyzer::new(); + let fk = test_flow_key(); + + // Send a request first so the response direction is active. + let request = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"; + analyzer.on_data(&fk, Direction::ClientToServer, request, 0); + + // Split response across two chunks mid-header. + let part1 = b"HTTP/1.1 200 OK\r\nContent-Len"; + let part2 = b"gth: 0\r\n\r\n"; + + analyzer.on_data(&fk, Direction::ServerToClient, part1, 0); + // After part1: should be Partial — no transaction yet. + assert_eq!( + analyzer.transaction_count(), + 0, + "partial response should not complete a transaction" + ); + + analyzer.on_data(&fk, Direction::ServerToClient, part2, part1.len() as u64); + // After part2: response fully assembled → transaction counted. + assert_eq!( + analyzer.transaction_count(), + 1, + "completed response should count as a transaction" + ); + assert_eq!( + *analyzer.status_code_counts().get(&200).unwrap(), + 1, + "status code 200 should be recorded" + ); +} From 1b5bf7470ac1fa73712b078f9cede8c5436875b0 Mon Sep 17 00:00:00 2001 From: Zious Date: Fri, 10 Apr 2026 16:22:52 -0400 Subject: [PATCH 2/2] fix: strengthen buffer cap test to prove truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review: send follow-up terminator on the same flow after oversized header. If buffer had retained all bytes, the request would complete parsing. With truncation, it stays unparsed — proving the 64KB cap is enforced, not just that no panic occurs. --- tests/http_analyzer_tests.rs | 38 ++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/tests/http_analyzer_tests.rs b/tests/http_analyzer_tests.rs index d25271a..d94afb8 100644 --- a/tests/http_analyzer_tests.rs +++ b/tests/http_analyzer_tests.rs @@ -517,34 +517,56 @@ fn test_cross_flow_isolation_poisoning() { #[test] fn test_buffer_cap_no_panic_on_oversized_headers() { // MAX_HEADER_BUF is 65_536. Data beyond this limit is silently - // truncated — the buffer must not grow unbounded. The truncated - // partial header will stay in httparse::Status::Partial state. - // Key assertion: no panic, no findings, parse errors stay at 0 - // (Partial is not an error). + // truncated — the buffer must not grow unbounded. To prove the cap + // is enforced, we first send an oversized partial header, then send + // the missing terminator on the SAME flow. If the buffer had been + // allowed to retain all bytes, the second chunk would complete the + // request; with truncation, it must remain unparsed. let mut analyzer = HttpAnalyzer::new(); let fk = test_flow_key(); // Use a single header with a massive value to exceed 64KB without // hitting MAX_HEADERS (96). The request line + Host: header + the // large X-Big header totals > 65536 bytes, so the buffer truncates - // mid-value. httparse sees an incomplete header and returns Partial. + // mid-value. let mut oversized = b"GET / HTTP/1.1\r\nHost: example.com\r\nX-Big: ".to_vec(); oversized.extend_from_slice(&vec![b'A'; 70_000]); - // No \r\n\r\n — after truncation, httparse sees a partial header value. + // No \r\n\r\n yet. analyzer.on_data(&fk, Direction::ClientToServer, &oversized, 0); - // No panic occurred (implicit). No parse errors — Partial is not an error. + // The oversized partial request should not parse. + assert!( + analyzer.method_counts().get("GET").is_none(), + "oversized partial request should not be counted as parsed" + ); assert_eq!( analyzer.parse_error_count(), 0, "partial header from buffer cap should not count as a parse error" ); - // No findings from truncated partial data. + + // Now try to complete the same request on the SAME flow. If the full + // oversized buffer had been retained, this would complete parsing. + // Because the buffer is capped/truncated, the terminator is silently + // dropped (remaining capacity is 0), and the request stays unparsed. + let completion = b"\r\n\r\n"; + analyzer.on_data( + &fk, + Direction::ClientToServer, + completion, + oversized.len() as u64, + ); + + assert!( + analyzer.method_counts().get("GET").is_none(), + "same-flow completion after buffer-cap truncation must not produce a parsed request" + ); assert!( analyzer.findings().is_empty(), "truncated partial data should not produce findings" ); + // Subsequent valid data on a NEW flow should still work (analyzer not corrupted). let fk2 = test_flow_key_b(); let valid = b"GET /ok HTTP/1.1\r\nHost: example.com\r\n\r\n";