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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/analyzer/arp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Finding>`
//! 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<Finding> 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<Finding> = 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::<Vec<_>>()
);
}
}

// ---------------------------------------------------------------------------
// Kani formal-verification harnesses (VP-024 Sub-B and Sub-D)
// ---------------------------------------------------------------------------
Expand Down
17 changes: 16 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 126 additions & 0 deletions tests/bc_2_16_016_arp_tests.rs
Original file line number Diff line number Diff line change
@@ -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<Finding> 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
);
}
}