diff --git a/src/analyzer/arp.rs b/src/analyzer/arp.rs index 5ccffeac..447ecefa 100644 --- a/src/analyzer/arp.rs +++ b/src/analyzer/arp.rs @@ -4339,6 +4339,128 @@ mod story_115 { } } // mod story_115 +// --------------------------------------------------------------------------- +// BC-2.16.016 test — ARP Findings Output is Unbounded (characterization) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod bc_2_16_016 { + //! BC-2.16.016 characterization test: `process_arp` returns a `Vec` + //! with NO upper bound. Processing >10,000 rebind-triggering frames must + //! produce >10,000 findings with no MAX_FINDINGS cap applied. + //! + //! This test PASSES on the current codebase (the no-cap invariant is already + //! in place) and acts as a regression guard: if a MAX_FINDINGS cap is ever + //! accidentally added to the ARP path, this test will fail. + //! + //! DF-TEST-NAMESPACE-001: tests wrapped in `mod bc_2_16_016`. + //! DF-AC-TEST-NAME-SYNC-001: function name follows BC-S.SS.NNN convention. + + use super::*; + use crate::decoder::ArpFrame; + + /// Build a minimal ARP Reply (op=2) with the given sender_ip, sender_mac, + /// and matching outer_src_mac. Used for characterization-test frame synthesis. + /// + /// target_ip is fixed to `192.168.0.1` — outside the `10.x.x.x` range used + /// for sender_ip in the no-cap test, so sender_ip never equals target_ip and + /// no GARP detection fires (D2 GARP requires sender_ip == target_ip per RFC 826). + fn make_reply_frame(sender_ip: [u8; 4], sender_mac: [u8; 6]) -> ArpFrame { + ArpFrame { + operation: 2, + sender_mac, + sender_ip, + target_mac: [0u8; 6], + target_ip: [192, 168, 0, 1], + outer_src_mac: Some(sender_mac), + packet_len: 42, + } + } + + /// BC-2.16.016 characterization test: ARP findings Vec is NOT capped. + /// + /// Processes N = 10,001 distinct sender IPs × 2 frames each (alternating + /// MACs), for a total of 20,002 frames. With `spoof_threshold=1`, each + /// second frame triggers a D1 rebind finding immediately. Expected: + /// `all_findings.len() == 10_001` (exactly one D1 per IP) and + /// `all_findings.len() > 10_000` (the no-cap invariant is satisfied). + /// + /// Postcondition 1 of BC-2.16.016: the Vec returned across all + /// `process_arp` calls is NOT truncated by any MAX_FINDINGS constant. + /// + /// Regression guard (BC-2.16.016 Invariant 5): if a MAX_FINDINGS cap is + /// ever added to the ARP path, `all_findings.len()` will plateau at that + /// cap and the `== 10_001` assertion will fail. + /// + /// This test is expected to PASS on the current codebase. + #[test] + #[allow(non_snake_case)] + fn test_BC_2_16_016_arp_findings_vec_has_no_cap() { + // storm_rate=u32::MAX suppresses all D3 findings so the count is purely D1. + let mut analyzer = ArpAnalyzer::new(1, u32::MAX); + + const MAC_A: [u8; 6] = [0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA]; + const MAC_B: [u8; 6] = [0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB]; + const N: usize = 10_001; // number of distinct sender IPs + + let mut all_findings: Vec = Vec::new(); + + for i in 0..N { + // Synthesize a unique sender_ip in the range 10.0.X.Y (N <= 65535 so + // the address space fits without wrapping into broadcast addresses). + let hi = (i / 256) as u8; + let lo = (i % 256) as u8; + let sender_ip: [u8; 4] = [10, 0, hi, lo]; + let ts = i as u32; + + // Frame 1 for this IP: first observation — inserts binding, no D1 finding. + let frame1 = make_reply_frame(sender_ip, MAC_A); + let f1 = analyzer.process_arp(&frame1, ts); + all_findings.extend(f1); + + // Frame 2 for this IP: rebind (MAC_A → MAC_B). + // spoof_threshold=1 → rebind_count(1) >= threshold(1) → HIGH D1 emitted. + let frame2 = make_reply_frame(sender_ip, MAC_B); + let f2 = analyzer.process_arp(&frame2, ts); + all_findings.extend(f2); + } + + // BC-2.16.016 Postcondition 1: findings Vec must NOT be truncated. + // With N=10,001 IPs each producing exactly one D1 finding on rebind, + // the total must equal N — not be capped at 10,000 (the reassembly + // MAX_FINDINGS constant that applies only to HTTP/TLS/Modbus/DNP3). + assert_eq!( + all_findings.len(), + N, + "BC-2.16.016 PC1: all_findings.len() must equal N={N} (one D1 finding per IP \ + rebind, no MAX_FINDINGS cap). Got {}. If this is 10,000, a MAX_FINDINGS cap \ + has been accidentally introduced on the ARP path.", + all_findings.len() + ); + + // Belt-and-suspenders: explicitly assert > 10,000 to document the no-cap intent. + assert!( + all_findings.len() > 10_000, + "BC-2.16.016 PC1 (no-cap invariant): all_findings.len() must exceed 10,000 \ + (the reassembly MAX_FINDINGS constant) to prove the ARP path is unbounded. \ + Got {}.", + all_findings.len() + ); + + // BC-2.16.016 PC2: no dropped_findings counter — ArpAnalyzer has no such field. + // Confirmed implicitly: `all_findings.len() == N` with no dropped_findings signal. + + // BC-2.16.016 PC3: summarize() must NOT contain a dropped_findings key. + let summary = analyzer.summarize(); + assert!( + !summary.detail.contains_key("dropped_findings"), + "BC-2.16.016 PC3 / BC-2.16.010 Invariant 1: summarize() must NOT emit a \ + 'dropped_findings' key. Found unexpected key. Keys present: {:?}", + summary.detail.keys().collect::>() + ); + } +} + // --------------------------------------------------------------------------- // Kani formal-verification harnesses (VP-024 Sub-B and Sub-D) // --------------------------------------------------------------------------- diff --git a/src/cli.rs b/src/cli.rs index f4d85123..71247d89 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -194,7 +194,22 @@ pub enum Commands { /// Analyze ARP traffic for spoofing, GARP anomalies, malformed frames, and /// L2/L3 sender-MAC mismatch. Default-off; included by --all. // BC-2.16.011 - #[arg(long)] + #[arg( + long, + long_help = "Analyze ARP traffic for spoofing, GARP anomalies, malformed frames, \ + and L2/L3 sender-MAC mismatch. Default-off; included by --all.\n\n\ + Findings output is UNBOUNDED: unlike HTTP/TLS/Modbus/DNP3 analyzers \ + (which cap findings at 10,000 via the TCP reassembly layer), ARP \ + operates at the Ethernet link layer and bypasses that cap entirely. \ + A capture with N spoof or storm events produces up to N findings with \ + no platform-imposed limit. Operators analyzing adversarial or large \ + captures should be aware that findings can grow proportionally to the \ + number of triggering frames.\n\n\ + Note: the ARP binding table (MAX_ARP_BINDINGS = 65,536 entries) and \ + storm-counter table (MAX_STORM_COUNTERS = 4,096 entries) are memory \ + bounds on internal analyzer state only — they do not cap the findings \ + output." + )] arp: bool, /// D1 spoof escalation threshold: number of MAC rebinds within diff --git a/tests/bc_2_16_016_arp_tests.rs b/tests/bc_2_16_016_arp_tests.rs new file mode 100644 index 00000000..96b7974c --- /dev/null +++ b/tests/bc_2_16_016_arp_tests.rs @@ -0,0 +1,126 @@ +//! BC-2.16.016 test suite: ARP Findings Output is Unbounded — No MAX_FINDINGS Cap +//! +//! Exercises BC-2.16.016 Postcondition 4: +//! The CLI `--help` text for `--arp` MUST document that ARP findings output +//! is UNBOUNDED (no cap). Operators analyzing adversarial captures with massive +//! ARP-storm or ARP-spoof events must be informed that findings output can grow +//! proportionally to the number of triggering frames, without any platform-imposed +//! bound. +//! +//! Test coverage: +//! test_BC_2_16_016_cli_help_documents_arp_findings_unbounded +//! — RED GATE: asserts the word "unbounded" appears in the `--arp` flag's help +//! text in `wirerust analyze --help`. FAILS before the long_help is added +//! to `src/cli.rs` (PC-015 doc fix). PASSES after implementation. +//! +//! Canonical pattern: `assert_cmd::Command::cargo_bin("wirerust")` with +//! `["analyze", "--help"]`, capture stdout, find `--arp` entry, assert keyword. +//! This pattern is established by `cli_integration_tests.rs` (mitre_help_text_* +//! tests) and the `no_collapse_help_names_real_output_flags` test. +//! +//! DF-TEST-NAMESPACE-001: all tests wrapped in `mod bc_2_16_016`. +//! DF-AC-TEST-NAME-SYNC-001: function name follows BC-S.SS.NNN convention. +//! DF-CANONICAL-FRAME-HOLDOUT-001: not applicable (CLI test, no frame synthesis). +//! +//! Run via: +//! cargo test --test bc_2_16_016_arp_tests + +#![allow(non_snake_case)] + +mod bc_2_16_016 { + use assert_cmd::Command; + + // ----------------------------------------------------------------------- + // BC-2.16.016 PC4 — RED GATE: --arp help text must document unbounded findings + // ----------------------------------------------------------------------- + + /// BC-2.16.016 Postcondition 4 (RED GATE): + /// `wirerust analyze --help` must document that ARP findings output is + /// UNBOUNDED — specifically, the `--arp` flag's help entry must contain + /// the word "unbounded" (case-insensitive). + /// + /// **This test FAILS before the PC-015 implementation** because `src/cli.rs` + /// lines 194–198 define `--arp` with a short one-line `///` doc-comment that + /// mentions spoofing and GARP detection but says nothing about findings being + /// unbounded. The word "unbounded" is absent from the rendered help output. + /// + /// **Red Gate assertion**: after running `wirerust analyze --help`, this test + /// locates the `--arp` flag entry in the output (the text between `--arp` and + /// the next `--arp-spoof-threshold` sibling flag) and asserts it contains + /// "unbounded". It will FAIL on current code and PASS after the long_help + /// is added to the `--arp` arg in `src/cli.rs`. + /// + /// BC-2.16.016 reference: + /// Postcondition 4: "The CLI `--help` text for `--arp` MUST document the + /// absence of a findings cap." + /// Invariant 4 (Security awareness): "A malicious pcap with millions of ARP + /// spoof or storm events can cause unbounded Vec growth..." + /// Architecture Anchor: `src/cli.rs` lines 194–213 — `--arp` flag definition; + /// `long_help` MUST document unbounded findings behavior (PC-015 doc fix). + #[test] + fn test_BC_2_16_016_cli_help_documents_arp_findings_unbounded() { + let output = Command::cargo_bin("wirerust") + .expect("wirerust binary must be built — run `cargo build` first") + .args(["analyze", "--help"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let help = String::from_utf8(output) + .expect("BC-2.16.016 PC4: wirerust analyze --help output must be valid UTF-8"); + + // Locate the `--arp` flag entry in the help output. + // Clap renders the flag as `--arp` followed by its description on the + // next indented line(s). We find the text between `--arp` and the next + // sibling `--arp-spoof-threshold` flag to scope the assertion tightly, + // preventing a false pass if another flag's description happens to + // mention "unbounded". + let arp_flag_pos = help.find("--arp\n").or_else(|| { + // Clap may render `--arp` with trailing spaces before a newline + // if the flag has no value description. Try a broader search. + help.find("--arp ").filter(|&p| { + // Make sure it's the `--arp` standalone flag, not --arp-spoof-threshold + // or --arp-storm-rate. Check that it's not followed immediately by '-'. + help.get(p + 5..) + .map(|s| !s.starts_with('-')) + .unwrap_or(false) + }) + }); + + let arp_flag_pos = arp_flag_pos.expect( + "BC-2.16.016 PC4: `--arp` flag must appear in `wirerust analyze --help` output. \ + If this fails, the --arp flag was removed or renamed.", + ); + + // Extract the text from `--arp` to the next sibling `--arp-spoof-threshold`. + let after_arp = &help[arp_flag_pos..]; + let next_sibling_pos = after_arp.find("--arp-spoof-threshold").expect( + "BC-2.16.016 PC4: `--arp-spoof-threshold` must appear after `--arp` in help \ + output (used to scope the --arp entry text).", + ); + let arp_entry = &after_arp[..next_sibling_pos]; + + // BC-2.16.016 PC4: the `--arp` help entry must document that findings are unbounded. + // The word "unbounded" is the canonical keyword specified in BC-2.16.016 PC4 + // and scope.md §PC-015 Fix Classification item 1. + // + // RED GATE: this assertion FAILS on the current codebase because `src/cli.rs` + // `--arp` doc-comment contains: + // "Analyze ARP traffic for spoofing, GARP anomalies, malformed frames, and + // L2/L3 sender-MAC mismatch. Default-off; included by --all." + // The word "unbounded" is absent. After the PC-015 doc fix adds a long_help + // mentioning unbounded findings, this assertion PASSES. + assert!( + arp_entry.to_lowercase().contains("unbounded"), + "BC-2.16.016 PC4 (RED GATE): the `--arp` flag help text (between `--arp` and \ + `--arp-spoof-threshold` in `wirerust analyze --help`) must contain the word \ + 'unbounded' to document that ARP findings output is not capped. \ + This is the PC-015 doc fix. Currently FAILING because long_help is absent \ + from `src/cli.rs` lines 194-198. \ + `--arp` help entry:\n{}", + arp_entry + ); + } +}