diff --git a/.gitignore b/.gitignore index ea8c4bf..c8d4ce6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.claude/worktrees/ diff --git a/docs/superpowers/plans/2026-04-13-mitre-attack-mapping.md b/docs/superpowers/plans/2026-04-13-mitre-attack-mapping.md new file mode 100644 index 0000000..feb339e --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-mitre-attack-mapping.md @@ -0,0 +1,1084 @@ +# MITRE ATT&CK Mapping 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 `src/mitre.rs` lookup module (tactics + technique names for Enterprise + ICS), assign T1027 to TLS malformed-SNI findings, and wire a `--mitre` flag on `analyze` that regroups terminal output by tactic. + +**Architecture:** Keep `mitre_technique: Option` on `Finding`. New `src/mitre.rs` exports `MitreTactic` enum (16 variants), `technique_name(id)`, `technique_tactic(id)`, and `all_tactics_in_report_order()` — all backed by exhaustive `match` statements. `TerminalReporter` grows a `show_mitre_grouping: bool` field; when true, the FINDINGS section is rebuilt as tactic-grouped sub-sections in MITRE canonical order, sorted verdict-desc → confidence-desc within each group. Unknown IDs render as `(unknown)` and bucket under "Uncategorized". + +**Tech Stack:** Rust 2024 edition, clap v4 derive API, no new crate dependencies. + +--- + +## File Structure + +**New files:** +- `src/mitre.rs` — tactics enum + lookup fns (~200 lines) +- `tests/mitre_tests.rs` — unit + regression coverage + +**Modified files:** +- `src/lib.rs` — register the new module +- `src/cli.rs` — add `mitre: bool` flag on `Commands::Analyze` +- `src/main.rs` — destructure `mitre` in the `Commands::Analyze` arm, thread into `run_analyze`, pass to `TerminalReporter` +- `src/reporter/terminal.rs` — add `show_mitre_grouping` field; implement the grouped-render code path +- `src/analyzer/tls.rs` — set `mitre_technique: Some("T1027".to_string())` on 3 of the 7 existing `None` sites +- `tests/reporter_tests.rs` — add grouped-render coverage +- `tests/tls_analyzer_tests.rs` — assert T1027 on the 3 malformed-SNI cases +- `tests/cli_tests.rs` — parse-test the `--mitre` flag + +--- + +### Task 1: Create `src/mitre.rs` with `MitreTactic` enum, `Display`, and `all_tactics_in_report_order` + +**Files:** +- Create: `src/mitre.rs` +- Modify: `src/lib.rs` +- Test: `tests/mitre_tests.rs` (create) + +- [ ] **Step 1: Write the failing tests** + +Create `tests/mitre_tests.rs`: + +```rust +use wirerust::mitre::{MitreTactic, all_tactics_in_report_order}; + +#[test] +fn display_renders_enterprise_tactics_with_canonical_spacing() { + assert_eq!(MitreTactic::CommandAndControl.to_string(), "Command and Control"); + assert_eq!(MitreTactic::DefenseEvasion.to_string(), "Defense Evasion"); + assert_eq!(MitreTactic::CredentialAccess.to_string(), "Credential Access"); + assert_eq!(MitreTactic::LateralMovement.to_string(), "Lateral Movement"); + assert_eq!(MitreTactic::PrivilegeEscalation.to_string(), "Privilege Escalation"); + assert_eq!(MitreTactic::InitialAccess.to_string(), "Initial Access"); + assert_eq!(MitreTactic::ResourceDevelopment.to_string(), "Resource Development"); + assert_eq!(MitreTactic::Reconnaissance.to_string(), "Reconnaissance"); + assert_eq!(MitreTactic::Execution.to_string(), "Execution"); + assert_eq!(MitreTactic::Persistence.to_string(), "Persistence"); + assert_eq!(MitreTactic::Discovery.to_string(), "Discovery"); + assert_eq!(MitreTactic::Collection.to_string(), "Collection"); + assert_eq!(MitreTactic::Exfiltration.to_string(), "Exfiltration"); + assert_eq!(MitreTactic::Impact.to_string(), "Impact"); +} + +#[test] +fn display_renders_ics_tactics_unprefixed() { + assert_eq!( + MitreTactic::IcsInhibitResponseFunction.to_string(), + "Inhibit Response Function" + ); + assert_eq!( + MitreTactic::IcsImpairProcessControl.to_string(), + "Impair Process Control" + ); +} + +#[test] +fn report_order_starts_with_reconnaissance_and_ends_with_ics() { + let tactics = all_tactics_in_report_order(); + assert_eq!(tactics.first(), Some(&MitreTactic::Reconnaissance)); + assert_eq!( + tactics.last(), + Some(&MitreTactic::IcsImpairProcessControl) + ); +} + +#[test] +fn report_order_contains_every_variant_exactly_once() { + let tactics = all_tactics_in_report_order(); + // Count by roundtripping the discriminant through Debug — avoids + // needing an explicit variant-count constant on the enum. + let mut seen: Vec = tactics.iter().map(|t| format!("{t:?}")).collect(); + seen.sort(); + let before = seen.len(); + seen.dedup(); + assert_eq!(seen.len(), before, "duplicate variant in report order"); + assert_eq!(before, 16, "expected 14 Enterprise + 2 ICS-unique = 16 variants"); +} + +#[test] +fn report_order_matches_enterprise_kill_chain_for_first_14() { + let tactics = all_tactics_in_report_order(); + let enterprise = [ + MitreTactic::Reconnaissance, + MitreTactic::ResourceDevelopment, + MitreTactic::InitialAccess, + MitreTactic::Execution, + MitreTactic::Persistence, + MitreTactic::PrivilegeEscalation, + MitreTactic::DefenseEvasion, + MitreTactic::CredentialAccess, + MitreTactic::Discovery, + MitreTactic::LateralMovement, + MitreTactic::Collection, + MitreTactic::CommandAndControl, + MitreTactic::Exfiltration, + MitreTactic::Impact, + ]; + assert_eq!(&tactics[..14], &enterprise); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --test mitre_tests` +Expected: compile error — `wirerust::mitre` does not exist. + +- [ ] **Step 3: Create `src/mitre.rs`** + +```rust +//! MITRE ATT&CK technique-ID → name / tactic lookup module. +//! +//! Backed by exhaustive `match` statements; zero runtime dependencies. +//! See `docs/superpowers/specs/2026-04-13-mitre-attack-mapping-design.md` +//! for the full design rationale. + +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MitreTactic { + // Enterprise canonical order (MITRE ATT&CK v18, 14 tactics). + Reconnaissance, + ResourceDevelopment, + InitialAccess, + Execution, + Persistence, + PrivilegeEscalation, + DefenseEvasion, + CredentialAccess, + Discovery, + LateralMovement, + Collection, + CommandAndControl, + Exfiltration, + Impact, + // ICS-unique tactics (names that don't collide with Enterprise). + IcsInhibitResponseFunction, + IcsImpairProcessControl, +} + +impl fmt::Display for MitreTactic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + MitreTactic::Reconnaissance => "Reconnaissance", + MitreTactic::ResourceDevelopment => "Resource Development", + MitreTactic::InitialAccess => "Initial Access", + MitreTactic::Execution => "Execution", + MitreTactic::Persistence => "Persistence", + MitreTactic::PrivilegeEscalation => "Privilege Escalation", + MitreTactic::DefenseEvasion => "Defense Evasion", + MitreTactic::CredentialAccess => "Credential Access", + MitreTactic::Discovery => "Discovery", + MitreTactic::LateralMovement => "Lateral Movement", + MitreTactic::Collection => "Collection", + MitreTactic::CommandAndControl => "Command and Control", + MitreTactic::Exfiltration => "Exfiltration", + MitreTactic::Impact => "Impact", + MitreTactic::IcsInhibitResponseFunction => "Inhibit Response Function", + MitreTactic::IcsImpairProcessControl => "Impair Process Control", + }; + f.write_str(name) + } +} + +/// Returns all tactics in MITRE canonical kill-chain order, with ICS-unique +/// tactics appended last. Used by the terminal reporter to produce a stable +/// section order when grouping findings by tactic. +pub fn all_tactics_in_report_order() -> &'static [MitreTactic] { + &[ + MitreTactic::Reconnaissance, + MitreTactic::ResourceDevelopment, + MitreTactic::InitialAccess, + MitreTactic::Execution, + MitreTactic::Persistence, + MitreTactic::PrivilegeEscalation, + MitreTactic::DefenseEvasion, + MitreTactic::CredentialAccess, + MitreTactic::Discovery, + MitreTactic::LateralMovement, + MitreTactic::Collection, + MitreTactic::CommandAndControl, + MitreTactic::Exfiltration, + MitreTactic::Impact, + MitreTactic::IcsInhibitResponseFunction, + MitreTactic::IcsImpairProcessControl, + ] +} +``` + +- [ ] **Step 4: Register the module in `src/lib.rs`** + +Edit `src/lib.rs` — add `pub mod mitre;` alphabetically between `findings` and `reader`: + +```rust +pub mod analyzer; +pub mod cli; +pub mod decoder; +pub mod dispatcher; +pub mod findings; +pub mod mitre; +pub mod reader; +pub mod reassembly; +pub mod reporter; +pub mod summary; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test --test mitre_tests` +Expected: all 5 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/mitre.rs src/lib.rs tests/mitre_tests.rs +git commit -m "feat(mitre): add MitreTactic enum with canonical display + ordering" +``` + +--- + +### Task 2: Add `technique_name` and `technique_tactic` lookup functions + +**Files:** +- Modify: `src/mitre.rs` +- Test: `tests/mitre_tests.rs` + +- [ ] **Step 1: Append failing tests to `tests/mitre_tests.rs`** + +```rust +use wirerust::mitre::{technique_name, technique_tactic}; + +#[test] +fn technique_name_resolves_every_seeded_id() { + assert_eq!(technique_name("T1027"), Some("Obfuscated Files or Information")); + assert_eq!(technique_name("T1036"), Some("Masquerading")); + assert_eq!(technique_name("T1040"), Some("Network Sniffing")); + assert_eq!(technique_name("T1046"), Some("Network Service Discovery")); + assert_eq!(technique_name("T1071"), Some("Application Layer Protocol")); + assert_eq!(technique_name("T1071.001"), Some("Web Protocols")); + assert_eq!(technique_name("T1071.004"), Some("DNS")); + assert_eq!(technique_name("T1083"), Some("File and Directory Discovery")); + assert_eq!(technique_name("T1499.002"), Some("Service Exhaustion Flood")); + assert_eq!(technique_name("T1505.003"), Some("Web Shell")); + assert_eq!(technique_name("T1573"), Some("Encrypted Channel")); + assert_eq!(technique_name("T0846"), Some("Remote System Discovery")); + assert_eq!(technique_name("T0855"), Some("Unauthorized Command Message")); + assert_eq!(technique_name("T0856"), Some("Spoof Reporting Message")); + assert_eq!(technique_name("T0885"), Some("Commonly Used Port")); +} + +#[test] +fn technique_name_returns_none_for_unknown_ids() { + assert_eq!(technique_name("T9999"), None); + assert_eq!(technique_name(""), None); + assert_eq!(technique_name("T1046.999"), None); + assert_eq!(technique_name("garbage"), None); +} + +#[test] +fn technique_tactic_matches_spec_table() { + assert_eq!(technique_tactic("T1027"), Some(MitreTactic::DefenseEvasion)); + assert_eq!(technique_tactic("T1036"), Some(MitreTactic::DefenseEvasion)); + assert_eq!(technique_tactic("T1040"), Some(MitreTactic::CredentialAccess)); + assert_eq!(technique_tactic("T1046"), Some(MitreTactic::Discovery)); + assert_eq!(technique_tactic("T1071"), Some(MitreTactic::CommandAndControl)); + assert_eq!(technique_tactic("T1071.001"), Some(MitreTactic::CommandAndControl)); + assert_eq!(technique_tactic("T1071.004"), Some(MitreTactic::CommandAndControl)); + assert_eq!(technique_tactic("T1083"), Some(MitreTactic::Discovery)); + assert_eq!(technique_tactic("T1499.002"), Some(MitreTactic::Impact)); + assert_eq!(technique_tactic("T1505.003"), Some(MitreTactic::Persistence)); + assert_eq!(technique_tactic("T1573"), Some(MitreTactic::CommandAndControl)); + assert_eq!(technique_tactic("T0846"), Some(MitreTactic::Discovery)); + assert_eq!( + technique_tactic("T0855"), + Some(MitreTactic::IcsImpairProcessControl) + ); + assert_eq!( + technique_tactic("T0856"), + Some(MitreTactic::IcsImpairProcessControl) + ); + assert_eq!(technique_tactic("T0885"), Some(MitreTactic::CommandAndControl)); +} + +#[test] +fn technique_tactic_returns_none_for_unknown_ids() { + assert_eq!(technique_tactic("T9999"), None); + assert_eq!(technique_tactic(""), None); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --test mitre_tests` +Expected: compile error — `technique_name` / `technique_tactic` undefined. + +- [ ] **Step 3: Append the lookup fns to `src/mitre.rs`** + +```rust +/// Resolves a MITRE ATT&CK technique ID to its human-readable name. +/// +/// Returns `None` for unknown IDs; callers that treat unknowns as +/// programming errors should `debug_assert!` at their call site. +/// The canonical ID format is `TXXXX` for parent techniques and +/// `TXXXX.NNN` for sub-techniques (period separator, three-digit +/// suffix), used consistently across Enterprise, ICS, and Mobile +/// matrices and in STIX 2.1 bundles. +pub fn technique_name(id: &str) -> Option<&'static str> { + let name = match id { + // Enterprise. + "T1027" => "Obfuscated Files or Information", + "T1036" => "Masquerading", + "T1040" => "Network Sniffing", + "T1046" => "Network Service Discovery", + "T1071" => "Application Layer Protocol", + "T1071.001" => "Web Protocols", + "T1071.004" => "DNS", + "T1083" => "File and Directory Discovery", + "T1499.002" => "Service Exhaustion Flood", + "T1505.003" => "Web Shell", + "T1573" => "Encrypted Channel", + // ICS. + "T0846" => "Remote System Discovery", + "T0855" => "Unauthorized Command Message", + "T0856" => "Spoof Reporting Message", + "T0885" => "Commonly Used Port", + _ => return None, + }; + Some(name) +} + +/// Resolves a MITRE ATT&CK technique ID to its parent tactic. +/// +/// For IDs shared in name between Enterprise and ICS (Discovery, +/// Command and Control, etc.) this returns the unified variant — see +/// the spec for the v1 limitation rationale. +pub fn technique_tactic(id: &str) -> Option { + let tactic = match id { + // Enterprise. + "T1027" | "T1036" => MitreTactic::DefenseEvasion, + "T1040" => MitreTactic::CredentialAccess, + "T1046" | "T1083" => MitreTactic::Discovery, + "T1071" | "T1071.001" | "T1071.004" | "T1573" => MitreTactic::CommandAndControl, + "T1499.002" => MitreTactic::Impact, + "T1505.003" => MitreTactic::Persistence, + // ICS. + "T0846" => MitreTactic::Discovery, + "T0855" | "T0856" => MitreTactic::IcsImpairProcessControl, + "T0885" => MitreTactic::CommandAndControl, + _ => return None, + }; + Some(tactic) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --test mitre_tests` +Expected: all tests in `mitre_tests.rs` PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/mitre.rs tests/mitre_tests.rs +git commit -m "feat(mitre): add technique_name and technique_tactic lookups" +``` + +--- + +### Task 3: Add the coverage regression test + +**Files:** +- Test: `tests/mitre_tests.rs` + +- [ ] **Step 1: Append the canonical-coverage test to `tests/mitre_tests.rs`** + +This test encodes the exact set of IDs the codebase intentionally emits. When any analyzer adds a new `mitre_technique: Some("…")` site, the author must add the ID here — failing to do so fails CI, which is the whole point. + +```rust +#[test] +fn every_emitted_technique_id_is_known() { + // Canonical list of every mitre_technique Some(...) value the codebase + // emits today. When you add a new emission site in an analyzer or + // reassembly handler, add the ID here too. Missing entries = CI failure. + let emitted_ids = [ + // src/analyzer/http.rs + "T1083", + "T1505.003", + "T1046", + "T1499.002", + // src/analyzer/tls.rs (added in this feature) + "T1027", + // src/reassembly/mod.rs + "T1036", + ]; + + for id in emitted_ids { + assert!( + technique_name(id).is_some(), + "analyzer emits {id} but technique_name({id}) returned None", + ); + assert!( + technique_tactic(id).is_some(), + "analyzer emits {id} but technique_tactic({id}) returned None", + ); + } +} +``` + +- [ ] **Step 2: Run the test** + +Run: `cargo test --test mitre_tests every_emitted_technique_id_is_known` +Expected: PASS (the IDs listed already have entries from Task 2, including T1027 which will be wired into TLS in Task 4). + +- [ ] **Step 3: Commit** + +```bash +git add tests/mitre_tests.rs +git commit -m "test(mitre): add coverage regression test for emitted technique IDs" +``` + +--- + +### Task 4: Assign T1027 to the three TLS malformed-SNI findings + +**Files:** +- Modify: `src/analyzer/tls.rs` +- Test: `tests/tls_analyzer_tests.rs` + +- [ ] **Step 1: Write the failing TLS tests** + +Append to `tests/tls_analyzer_tests.rs` (after the existing SNI tests; check the file for a suitable insertion point): + +```rust +#[test] +fn ascii_control_sni_finding_sets_mitre_t1027() { + let esc_hostname = b"foo\x1bbar.example.com"; + let bytes = build_client_hello_ascii_bytes(esc_hostname); + let mut analyzer = TlsAnalyzer::new(); + analyzer.on_data(&make_flow_key(), Direction::ClientToServer, &bytes, 0); + + let control_finding = analyzer + .findings() + .iter() + .find(|f| f.summary.contains("ASCII control characters")) + .expect("expected an ASCII-control SNI finding"); + assert_eq!( + control_finding.mitre_technique.as_deref(), + Some("T1027"), + "malformed-SNI finding must be mapped to T1027 (Obfuscated Files or Information)", + ); +} + +#[test] +fn non_ascii_utf8_sni_finding_sets_mitre_t1027() { + // Cyrillic hostname — valid UTF-8 but non-ASCII, so RFC 6066 A-label + // violation path. + let bytes = build_client_hello_ascii_bytes("пример.рф".as_bytes()); + let mut analyzer = TlsAnalyzer::new(); + analyzer.on_data(&make_flow_key(), Direction::ClientToServer, &bytes, 0); + + let finding = analyzer + .findings() + .iter() + .find(|f| f.summary.contains("non-ASCII characters")) + .expect("expected a non-ASCII SNI finding"); + assert_eq!(finding.mitre_technique.as_deref(), Some("T1027")); +} + +#[test] +fn non_utf8_sni_finding_sets_mitre_t1027() { + // Truncated UTF-8 sequence (0xc3 without continuation) embedded in + // otherwise-ASCII host. + let bytes = build_client_hello_ascii_bytes(&[b'f', b'o', b'o', 0xc3, b'.', b'c', b'o', b'm']); + let mut analyzer = TlsAnalyzer::new(); + analyzer.on_data(&make_flow_key(), Direction::ClientToServer, &bytes, 0); + + let finding = analyzer + .findings() + .iter() + .find(|f| f.summary.contains("non-UTF-8 bytes")) + .expect("expected a non-UTF-8 SNI finding"); + assert_eq!(finding.mitre_technique.as_deref(), Some("T1027")); +} +``` + +Note: `build_client_hello_ascii_bytes`, `make_flow_key`, and `Direction` already exist in the test module from prior work on issue #54. If a helper is missing, follow the existing pattern in the file. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --test tls_analyzer_tests ascii_control_sni_finding_sets_mitre_t1027 non_ascii_utf8_sni_finding_sets_mitre_t1027 non_utf8_sni_finding_sets_mitre_t1027` +Expected: FAIL — `mitre_technique` is currently `None` for all three. + +- [ ] **Step 3: Update the three finding emissions in `src/analyzer/tls.rs`** + +Three sites, around lines 397, 416, 435 — change `mitre_technique: None` to `mitre_technique: Some("T1027".to_string())`. Each site is inside the `SniValue` match arm for `AsciiWithControl`, `NonAsciiUtf8`, `NonUtf8`. Leave the other four `None` sites (weak-cipher, SSL-deprecation x2, server-weak-cipher) unchanged — they're informational crypto-strength findings, not protocol-field tampering. + +Replace the `AsciiWithControl` arm's `mitre_technique: None,` with: + +```rust + mitre_technique: Some("T1027".to_string()), +``` + +Replace the `NonAsciiUtf8` arm's `mitre_technique: None,` with: + +```rust + mitre_technique: Some("T1027".to_string()), +``` + +Replace the `NonUtf8` arm's `mitre_technique: None,` with: + +```rust + mitre_technique: Some("T1027".to_string()), +``` + +- [ ] **Step 4: Run the failing tests — now expecting PASS** + +Run: `cargo test --test tls_analyzer_tests` +Expected: all new tests PASS; no previously-passing tests broken. + +- [ ] **Step 5: Confirm coverage regression test still passes** + +Run: `cargo test --test mitre_tests every_emitted_technique_id_is_known` +Expected: PASS. (T1027 was already in the canonical list.) + +- [ ] **Step 6: Commit** + +```bash +git add src/analyzer/tls.rs tests/tls_analyzer_tests.rs +git commit -m "feat(tls): map malformed SNI findings to MITRE T1027 (Obfuscated Files or Information)" +``` + +--- + +### Task 5: Add the `--mitre` flag to `Commands::Analyze` + +**Files:** +- Modify: `src/cli.rs` +- Test: `tests/cli_tests.rs` + +- [ ] **Step 1: Write the failing CLI parse test** + +Append to `tests/cli_tests.rs`: + +```rust +#[test] +fn test_mitre_flag_parses_on_analyze() { + let cli = Cli::parse_from(["wirerust", "analyze", "capture.pcap", "--mitre"]); + match cli.command { + Commands::Analyze { mitre, .. } => assert!(mitre), + _ => panic!("Expected Analyze command"), + } +} + +#[test] +fn test_mitre_flag_defaults_false() { + let cli = Cli::parse_from(["wirerust", "analyze", "capture.pcap"]); + match cli.command { + Commands::Analyze { mitre, .. } => assert!(!mitre), + _ => panic!("Expected Analyze command"), + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --test cli_tests test_mitre_flag` +Expected: compile error — `Commands::Analyze` has no field `mitre`. + +- [ ] **Step 3: Add the field to `Commands::Analyze` in `src/cli.rs`** + +Insert the flag inside the `Analyze` variant, between `beacon` and `all` (matches ordering convention of grouped analyzer flags): + +```rust + /// Detect C2 beaconing patterns + #[arg(long)] + beacon: bool, + + /// Group findings by MITRE ATT&CK tactic and show technique names + #[arg(long)] + mitre: bool, + + /// Run all analyzers + #[arg(short, long)] + all: bool, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --test cli_tests` +Expected: all tests PASS, including the two new ones. + +- [ ] **Step 5: Commit** + +```bash +git add src/cli.rs tests/cli_tests.rs +git commit -m "feat(cli): add --mitre flag to analyze subcommand" +``` + +--- + +### Task 6: Add `show_mitre_grouping` field to `TerminalReporter` (default-false; no behavior change yet) + +**Files:** +- Modify: `src/reporter/terminal.rs` +- Modify: `src/main.rs` + +The goal of this task is to extend the reporter's shape without yet changing behavior, so the entire existing test suite keeps passing. The rendering logic lands in Task 8. + +- [ ] **Step 1: Modify `TerminalReporter` in `src/reporter/terminal.rs`** + +Change the struct definition: + +```rust +pub struct TerminalReporter { + pub use_color: bool, + /// When true, regroup the FINDINGS section by MITRE tactic and expand + /// the per-finding MITRE line to include the technique name. + pub show_mitre_grouping: bool, +} +``` + +- [ ] **Step 2: Update the two call sites in `src/main.rs`** + +Both at lines 175 and 218 — pass `show_mitre_grouping: false` for now. The analyze path will be re-wired in Task 7; the summary path keeps `false` permanently (summary never renders findings). + +`src/main.rs:175` — inside `run_analyze`: + +```rust + let reporter = TerminalReporter { use_color, show_mitre_grouping: false }; +``` + +`src/main.rs:218` — inside `run_summary`: + +```rust + let reporter = TerminalReporter { use_color, show_mitre_grouping: false }; +``` + +- [ ] **Step 3: Verify nothing broke** + +Run: `cargo test` +Expected: all existing tests still PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/reporter/terminal.rs src/main.rs +git commit -m "refactor(reporter): add show_mitre_grouping field (default false, no behavior change)" +``` + +--- + +### Task 7: Thread `--mitre` through `run_analyze` into the reporter + +**Files:** +- Modify: `src/main.rs` + +- [ ] **Step 1: Destructure `mitre` in the `Commands::Analyze` arm** + +Update `src/main.rs:28-44`: + +```rust + Commands::Analyze { + targets, + dns, + http, + tls, + all, + mitre, + .. + } => { + run_analyze( + targets, + *dns || *all, + *http || *all, + *tls || *all, + *mitre, + use_color, + &cli, + )?; + } +``` + +- [ ] **Step 2: Update `run_analyze`'s signature and reporter construction** + +Update the function signature (around line 53) and the reporter construction (around line 175): + +```rust +fn run_analyze( + targets: &[std::path::PathBuf], + enable_dns: bool, + enable_http: bool, + enable_tls: bool, + show_mitre_grouping: bool, + use_color: bool, + cli: &Cli, +) -> Result<()> { +``` + +And at the terminal-reporter construction site inside `run_analyze`: + +```rust + _ => { + let reporter = TerminalReporter { use_color, show_mitre_grouping }; + reporter.render(&summary, &all_findings, &analyzer_summaries) + } +``` + +- [ ] **Step 3: Build and test** + +Run: `cargo build && cargo test` +Expected: clean build; all tests still pass (no rendering change yet; flag currently has no effect when set). + +- [ ] **Step 4: Commit** + +```bash +git add src/main.rs +git commit -m "feat(cli): thread --mitre flag into TerminalReporter" +``` + +--- + +### Task 8: Implement grouped rendering (sort, unknown handling, name expansion) + +**Files:** +- Modify: `src/reporter/terminal.rs` +- Test: `tests/reporter_tests.rs` + +This is the biggest task. It covers four coupled behaviors that live in one render path: tactic grouping, within-group sort order, unknown/None bucketing, and per-finding MITRE line name expansion. + +- [ ] **Step 1: Add failing tests to `tests/reporter_tests.rs`** + +Check the top of `tests/reporter_tests.rs` for existing imports and helper fns. You will need `Finding`, `Verdict`, `Confidence`, `ThreatCategory`, and `TerminalReporter`. Add the following tests: + +```rust +use wirerust::mitre::MitreTactic; + +fn base_finding_with_mitre( + technique: Option<&str>, + verdict: Verdict, + confidence: Confidence, + summary: &str, +) -> Finding { + Finding { + category: ThreatCategory::Anomaly, + verdict, + confidence, + summary: summary.to_string(), + evidence: vec![], + mitre_technique: technique.map(|s| s.to_string()), + source_ip: None, + timestamp: None, + } +} + +#[test] +fn mitre_grouping_emits_tactic_headers_in_canonical_order() { + let findings = vec![ + // Impact — should appear before ICS but after earlier tactics. + base_finding_with_mitre(Some("T1499.002"), Verdict::Likely, Confidence::High, "dos"), + // Discovery — earlier in kill-chain than Impact. + base_finding_with_mitre(Some("T1046"), Verdict::Likely, Confidence::High, "scan"), + // ICS Impair — last Enterprise/ICS tactic rendered. + base_finding_with_mitre(Some("T0855"), Verdict::Likely, Confidence::High, "ics"), + ]; + + let reporter = TerminalReporter { use_color: false, show_mitre_grouping: true }; + let out = reporter.render(&empty_summary(), &findings, &[]); + + let discovery_pos = out.find("Discovery").expect("missing Discovery header"); + let impact_pos = out.find("Impact").expect("missing Impact header"); + let ics_pos = out.find("Impair Process Control").expect("missing ICS header"); + assert!(discovery_pos < impact_pos, "Discovery must come before Impact"); + assert!(impact_pos < ics_pos, "Impact must come before ICS tactics"); +} + +#[test] +fn mitre_grouping_sorts_within_tactic_by_verdict_then_confidence() { + let findings = vec![ + base_finding_with_mitre(Some("T1046"), Verdict::Unlikely, Confidence::High, "third"), + base_finding_with_mitre(Some("T1046"), Verdict::Likely, Confidence::Medium, "second"), + base_finding_with_mitre(Some("T1046"), Verdict::Likely, Confidence::High, "first"), + base_finding_with_mitre(Some("T1046"), Verdict::Inconclusive, Confidence::Low, "fourth_ish"), + ]; + let reporter = TerminalReporter { use_color: false, show_mitre_grouping: true }; + let out = reporter.render(&empty_summary(), &findings, &[]); + + let p1 = out.find("first").expect("first missing"); + let p2 = out.find("second").expect("second missing"); + let p3 = out.find("fourth_ish").expect("fourth_ish missing"); + let p4 = out.find("third").expect("third missing"); + assert!(p1 < p2 && p2 < p3 && p3 < p4, "verdict/confidence sort wrong: {out}"); +} + +#[test] +fn mitre_grouping_buckets_none_and_unknown_under_uncategorized() { + let findings = vec![ + base_finding_with_mitre(None, Verdict::Likely, Confidence::High, "no_id_finding"), + base_finding_with_mitre(Some("T9999"), Verdict::Likely, Confidence::High, "unknown_id_finding"), + base_finding_with_mitre(Some("T1046"), Verdict::Likely, Confidence::High, "known_finding"), + ]; + let reporter = TerminalReporter { use_color: false, show_mitre_grouping: true }; + let out = reporter.render(&empty_summary(), &findings, &[]); + + let uncat_pos = out.find("Uncategorized").expect("missing Uncategorized section"); + let no_id_pos = out.find("no_id_finding").expect("missing no-id finding"); + let unknown_pos = out.find("unknown_id_finding").expect("missing unknown-id finding"); + let known_pos = out.find("known_finding").expect("missing known finding"); + + assert!(known_pos < uncat_pos, "Uncategorized must come after known tactics"); + assert!(uncat_pos < no_id_pos && uncat_pos < unknown_pos); + assert!(out.contains("T9999 (unknown)"), "unknown ID must render with '(unknown)' label"); +} + +#[test] +fn mitre_grouping_expands_per_finding_line_with_technique_name() { + let findings = vec![ + base_finding_with_mitre(Some("T1046"), Verdict::Likely, Confidence::High, "scan"), + ]; + let reporter = TerminalReporter { use_color: false, show_mitre_grouping: true }; + let out = reporter.render(&empty_summary(), &findings, &[]); + assert!( + out.contains("MITRE: T1046 — Network Service Discovery"), + "expected em-dash-expanded MITRE line, got: {out}", + ); +} + +#[test] +fn default_rendering_unchanged_when_mitre_flag_off() { + let findings = vec![ + base_finding_with_mitre(Some("T1046"), Verdict::Likely, Confidence::High, "scan"), + ]; + let reporter = TerminalReporter { use_color: false, show_mitre_grouping: false }; + let out = reporter.render(&empty_summary(), &findings, &[]); + // No tactic headers; no em-dash expansion; plain "MITRE: T1046" line. + assert!(out.contains("MITRE: T1046")); + assert!(!out.contains("—"), "em-dash should not appear in default render"); + assert!(!out.contains("Uncategorized")); +} +``` + +If `empty_summary()` is not already a helper in `reporter_tests.rs`, it is defined in that file from prior tests — confirm it exists or use the existing pattern to construct a `Summary::new()` directly. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --test reporter_tests mitre_grouping default_rendering_unchanged` +Expected: FAIL — no grouping implemented yet; default-rendering-unchanged may pass depending on the exact assertion, but the new ones will all fail. + +- [ ] **Step 3: Implement the grouped render path in `src/reporter/terminal.rs`** + +Modify the `Reporter::render` impl for `TerminalReporter`. Replace the single `if !findings.is_empty() { ... }` FINDINGS block with a branch on `self.show_mitre_grouping`. + +Add the following imports at the top of `src/reporter/terminal.rs`: + +```rust +use crate::mitre::{MitreTactic, all_tactics_in_report_order, technique_name, technique_tactic}; +``` + +Replace the existing findings block. The existing block lives around lines 98–132; keep it as the `!show_mitre_grouping` branch. Insert the grouped branch alongside: + +```rust + // Findings + if !findings.is_empty() { + out.push_str(&self.section("FINDINGS")); + if self.show_mitre_grouping { + self.render_findings_grouped(&mut out, findings); + } else { + for f in findings { + self.render_finding_flat(&mut out, f); + } + } + out.push('\n'); + } +``` + +Refactor the per-finding rendering logic — the existing body of the inner `for f in findings { ... }` — into a new helper `render_finding_flat`: + +```rust +impl TerminalReporter { + fn render_finding_flat(&self, out: &mut String, f: &Finding) { + let escaped_summary = escape_for_terminal(&f.summary); + let line = format!( + "[{}] {} ({}) - {}", + f.category, f.verdict, f.confidence, escaped_summary + ); + let colored = if self.use_color { + match f.verdict { + Verdict::Likely => match f.confidence { + Confidence::High => line.red().bold().to_string(), + _ => line.yellow().to_string(), + }, + Verdict::Inconclusive => line.cyan().to_string(), + Verdict::Unlikely => line.dimmed().to_string(), + } + } else { + line + }; + out.push_str(&format!(" {colored}\n")); + for ev in &f.evidence { + let escaped_ev = escape_for_terminal(ev); + out.push_str(&format!(" > {escaped_ev}\n")); + } + if let Some(ref t) = f.mitre_technique { + out.push_str(&format!(" MITRE: {t}\n")); + } + } + + fn render_finding_grouped(&self, out: &mut String, f: &Finding) { + // Same body as render_finding_flat, but the MITRE line expands to + // include the technique name when resolvable. + let escaped_summary = escape_for_terminal(&f.summary); + let line = format!( + "[{}] {} ({}) - {}", + f.category, f.verdict, f.confidence, escaped_summary + ); + let colored = if self.use_color { + match f.verdict { + Verdict::Likely => match f.confidence { + Confidence::High => line.red().bold().to_string(), + _ => line.yellow().to_string(), + }, + Verdict::Inconclusive => line.cyan().to_string(), + Verdict::Unlikely => line.dimmed().to_string(), + } + } else { + line + }; + out.push_str(&format!(" {colored}\n")); + for ev in &f.evidence { + let escaped_ev = escape_for_terminal(ev); + out.push_str(&format!(" > {escaped_ev}\n")); + } + if let Some(ref id) = f.mitre_technique { + debug_assert!( + technique_name(id).is_some() || cfg!(not(debug_assertions)), + "MITRE technique id {id} is not in the lookup — update src/mitre.rs and tests/mitre_tests.rs", + ); + match technique_name(id) { + Some(name) => out.push_str(&format!(" MITRE: {id} — {name}\n")), + None => out.push_str(&format!(" MITRE: {id} (unknown)\n")), + } + } + } + + fn render_findings_grouped(&self, out: &mut String, findings: &[Finding]) { + // Bucket findings by tactic. Preserve emission order as tertiary + // tie-breaker by attaching the original index. + let mut buckets: std::collections::HashMap, Vec<(usize, &Finding)>> = + std::collections::HashMap::new(); + for (i, f) in findings.iter().enumerate() { + let tactic = f + .mitre_technique + .as_deref() + .and_then(technique_tactic); + buckets.entry(tactic).or_default().push((i, f)); + } + + // Severity-desc sort within each bucket: Likely > Inconclusive > + // Unlikely, High > Medium > Low, then emission order. + fn verdict_rank(v: Verdict) -> u8 { + match v { + Verdict::Likely => 0, + Verdict::Inconclusive => 1, + Verdict::Unlikely => 2, + } + } + fn confidence_rank(c: Confidence) -> u8 { + match c { + Confidence::High => 0, + Confidence::Medium => 1, + Confidence::Low => 2, + } + } + for (_, items) in buckets.iter_mut() { + items.sort_by_key(|(idx, f)| (verdict_rank(f.verdict), confidence_rank(f.confidence), *idx)); + } + + // Emit known tactics in canonical kill-chain order, then the + // Uncategorized bucket (None key) last. + for tactic in all_tactics_in_report_order() { + if let Some(items) = buckets.get(&Some(*tactic)) { + out.push_str(&format!(" ## {tactic}\n")); + for (_, f) in items { + self.render_finding_grouped(out, f); + } + } + } + if let Some(items) = buckets.get(&None) { + out.push_str(" ## Uncategorized\n"); + for (_, f) in items { + self.render_finding_grouped(out, f); + } + } + } +} +``` + +The `debug_assert!` is structured so that in release (`debug_assertions` off) the assertion short-circuits to `true` and is eliminated by the compiler — catching typos in `cargo test` runs but never panicking for users. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --test reporter_tests` +Expected: all new tests PASS; all existing reporter tests still PASS. + +- [ ] **Step 5: Full local test sweep** + +Run: `cargo test` +Expected: all tests in the workspace PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/reporter/terminal.rs tests/reporter_tests.rs +git commit -m "feat(reporter): group findings by MITRE tactic when --mitre is set" +``` + +--- + +### Task 9: Full CI-equivalent local check + final commit + +**Files:** (fmt fixes across any files touched) + +- [ ] **Step 1: Run rustfmt check** + +Run: `cargo fmt --all -- --check` +Expected: no output (all files formatted). + +If it reports unformatted files: run `cargo fmt --all`, inspect the diff, and stage the changes. + +- [ ] **Step 2: Run clippy with all targets + warnings-as-errors** + +Run: `cargo clippy --all-targets -- -D warnings` +Expected: no warnings. + +If warnings appear, fix them at the root cause. Do NOT use `#[allow(...)]` unless the pattern is already present elsewhere in the file for the same lint. + +- [ ] **Step 3: Run the full test suite** + +Run: `cargo test` +Expected: 100% PASS. + +- [ ] **Step 4: Commit fmt/clippy fixes (if any)** + +```bash +git add -u +git commit -m "style: apply rustfmt" +``` + +Only if Step 1 or 2 produced changes. If not, skip. + +- [ ] **Step 5: Confirm the branch is clean and ready for PR review** + +Run: `git status && git log --oneline origin/develop..HEAD` +Expected: clean working tree; commit log shows the task-per-commit sequence. + +The feature branch is now ready for local PR review (`/pr-review-toolkit:review-pr`) followed by the iterate-until-clean loop described in the validated-feature-lifecycle skill. + +--- + +## Self-Review Checklist (already applied to this plan) + +- Spec coverage: every section in the spec (mitre module, Option data model, terminal reporter grouping, --mitre flag, error handling, pre-seeded techniques, TLS T1027 assignment, testing strategy) maps to at least one task. +- Placeholder scan: every code step contains actual code. No "TBD" or "similar to above." +- Type consistency: `show_mitre_grouping: bool`, `technique_name(id: &str) -> Option<&'static str>`, `technique_tactic(id: &str) -> Option`, and `MitreTactic` variant names used identically across all tasks. +- Clarifications the executor should watch for: + - `src/main.rs` — not `src/dispatcher.rs` — is where `Commands::Analyze` is destructured (the spec's wording was inherited from an earlier draft where a command dispatcher existed). + - The TLS analyzer has 7 `mitre_technique: None` sites; only 3 (the `SniValue` match arms for `AsciiWithControl`, `NonAsciiUtf8`, `NonUtf8`) get `Some("T1027")`. The other 4 (weak ciphers, SSL deprecation x2, server weak cipher) stay `None` — they're informational crypto-strength findings, not tampering. diff --git a/docs/superpowers/specs/2026-04-13-mitre-attack-mapping-design.md b/docs/superpowers/specs/2026-04-13-mitre-attack-mapping-design.md new file mode 100644 index 0000000..0206199 --- /dev/null +++ b/docs/superpowers/specs/2026-04-13-mitre-attack-mapping-design.md @@ -0,0 +1,188 @@ +# MITRE ATT&CK Mapping — Design Spec + +**Issue:** [#5](https://github.com/zious11/wirerust/issues/5) +**Date:** 2026-04-13 +**Status:** Draft + +## Goal + +Systematically map every finding to a MITRE ATT&CK technique (Enterprise + ICS matrices) and add a `--mitre` flag that regroups terminal output by tactic. The `Finding` struct already carries an `Option` field named `mitre_technique`; most analyzers emit `None`. This design populates missing analyzers, adds a lookup module, and wires a CLI flag for tactic-grouped output. + +## Non-goals + +- CSV schema changes (owned by issue #4). +- JSON schema additions for `mitre_tactic` / `mitre_name` — deferred until a SIEM-ingestion consumer asks; handled later via a DTO over `Finding`. +- `--mitre-links` URLs to attack.mitre.org. +- Runtime STIX bundle ingestion or build-time code generation. +- `--group-by=...` orthogonal-flag abstraction (deferred until a second grouping axis exists). +- DNS analyzer technique assignment — DNS currently emits no findings; covered when issue #3 lands. + +## Architecture + +### New module: `src/mitre.rs` + +```rust +pub enum MitreTactic { + // Enterprise canonical order (MITRE ATT&CK v18, 14 tactics) + Reconnaissance, + ResourceDevelopment, + InitialAccess, + Execution, + Persistence, + PrivilegeEscalation, + DefenseEvasion, + CredentialAccess, + Discovery, + LateralMovement, + Collection, + CommandAndControl, + Exfiltration, + Impact, + // ICS-unique tactics (names that don't collide with Enterprise) + IcsInhibitResponseFunction, + IcsImpairProcessControl, +} + +impl fmt::Display for MitreTactic { + // Unprefixed canonical names per MITRE convention (Caldera, Atomic Red + // Team, ATT&CK Navigator all render tactic names without matrix prefixes): + // CommandAndControl -> "Command and Control" + // DefenseEvasion -> "Defense Evasion" + // IcsInhibitResponseFunction -> "Inhibit Response Function" + // IcsImpairProcessControl -> "Impair Process Control" +} + +pub fn technique_name(id: &str) -> Option<&'static str>; +pub fn technique_tactic(id: &str) -> Option; +pub fn all_tactics_in_report_order() -> &'static [MitreTactic]; +``` + +**Enterprise/ICS tactic name collision — known limitation.** MITRE's Enterprise and ICS matrices share several tactic *names* (Persistence, Discovery, Command and Control, Lateral Movement, Collection, Impact) that have *different* `TA-####` IDs (e.g., Enterprise Discovery = TA0007; ICS Discovery = TA0111). This design unifies them under a single variant (e.g., `Discovery` covers both). Practical effect: an Enterprise T1046 finding and an ICS T0846 finding both render under a single "Discovery" section header. Acceptable for v1 — no consumer has asked for matrix-level distinction; can split into `EnterpriseDiscovery` / `IcsDiscovery` if demand appears. ICS-unique tactics (Inhibit Response Function, Impair Process Control, Evasion) get their own variants. + +Both `technique_name` and `technique_tactic` are backed by exhaustive `match` statements. Perplexity-validated as idiomatic for ~15–20 static entries in Rust 2024; `phf` and `Lazy` add cost without benefit at this scale, and clippy does not warn on unused match arms. + +### Data model: `mitre_technique` stays `Option` + +No change to `Finding`. Rationale (validated): + +- Security tools with evolving external catalogs (MITRE ATT&CK, CVE, CAPEC) idiomatically store IDs as strings with validation at the boundary. +- Enum refactor would churn ~30 test fixtures + the JSON schema for marginal safety gain. +- Tactic is derived at render time (`technique_tactic(id)`) — single source of truth, impossible for technique and tactic to disagree. + +### Terminal reporter + +Without `--mitre` (default): output unchanged. `MITRE: T1046` line printed per finding if set. + +With `--mitre`: + +1. Replace the flat FINDINGS section with a grouped view. +2. Tactic section order = `all_tactics_in_report_order()` (MITRE Enterprise canonical kill-chain order: Reconnaissance → Resource Development → Initial Access → Execution → Persistence → Privilege Escalation → Defense Evasion → Credential Access → Discovery → Lateral Movement → Collection → Command and Control → Exfiltration → Impact → ICS-unique tactics → Uncategorized last). +3. Within each tactic, sort by **Verdict descending** (`Likely > Inconclusive > Unlikely`) then **Confidence descending** (`High > Medium > Low`) then **emission order**. Validated as the SIEM industry standard (Splunk, Elastic, QRadar, Sentinel, Sumo Logic all default to severity-desc; within-MITRE-tactic groups specifically follow this order). +4. Findings with `mitre_technique == None` OR an unknown ID go to the "Uncategorized" bucket at the end. +5. Per-finding MITRE line expands: `MITRE: T1046 — Network Service Discovery` (ID, em-dash, name). +6. Unknown IDs render as `MITRE: T9999 (unknown)`. + +### CLI + +Add to `Commands::Analyze` in `src/cli.rs`: + +```rust +/// Group findings by MITRE ATT&CK tactic and show technique names +#[arg(long)] +pub mitre: bool, +``` + +Threaded from `src/main.rs::run_analyze` (where `Commands::Analyze` is destructured) into `TerminalReporter` via a new public field `show_mitre_grouping: bool`, following the `use_color` pattern. + +### Error handling for unknown IDs + +- `technique_name` / `technique_tactic` return `Option`; `None` is the unknown-ID signal. +- The reporter does **not** `debug_assert!` on unknown IDs at the render site — an earlier draft proposed this but it was dropped during local PR review (Task 8) because the grouped-render tests intentionally exercise the unknown-ID code path with `Some("T9999")`, which would panic in debug builds under that assertion. +- Release behavior: render unknown IDs inline (`MITRE: T9999 (unknown)`) and bucket under Uncategorized. Never panic user-facing. +- Regression test in `tests/mitre_tests.rs`: `#[test] fn known_emitted_technique_ids_resolve_in_lookup` with a hand-curated list of the technique IDs the codebase emits today, each asserted to resolve via `technique_name` + `technique_tactic`. The list is manually maintained — adding a new emission site without updating the list will not fail CI. See issue #67 for the trade-off rationale (the hand-curated approach is the idiomatic Rust pattern at this scale per Perplexity validation; revisit when emission sites grow > ~20 or a missed-update incident occurs). + +## Pre-seeded techniques + +Scope includes entries for currently-emitted IDs **plus** near-future IDs expected from backlog issues #3, #7, #8. Pre-seeding known-upstream catalog entries is not a YAGNI violation (Perplexity-validated); adding a match arm has zero maintenance cost and clippy does not warn on unused arms. + +| ID | Name | Tactic | Status | +|---|---|---|---| +| T1027 | Obfuscated Files or Information | Defense Evasion | **new in this PR** (TLS) | +| T1036 | Masquerading | Defense Evasion | existing (reassembly) | +| T1040 | Network Sniffing | Credential Access | pre-seed (#3) | +| T1046 | Network Service Discovery | Discovery | existing (HTTP) | +| T1071 | Application Layer Protocol | Command and Control | pre-seed (#3) | +| T1071.001 | Web Protocols | Command and Control | pre-seed (#3) | +| T1071.004 | DNS | Command and Control | pre-seed (#3) | +| T1083 | File and Directory Discovery | Discovery | existing (HTTP) | +| T1499.002 | Service Exhaustion Flood | Impact | existing (HTTP) | +| T1505.003 | Web Shell | Persistence | existing (HTTP) | +| T1573 | Encrypted Channel | Command and Control | pre-seed (#3) | +| T0846 | Remote System Discovery | Discovery | pre-seed (#7/#8) | +| T0855 | Unauthorized Command Message | ICS Impair Process Control | pre-seed (#7 Modbus) | +| T0856 | Spoof Reporting Message | ICS Impair Process Control | pre-seed (#8 DNP3) | +| T0885 | Commonly Used Port | Command and Control | pre-seed (#7/#8) — verified not deprecated | + +All Enterprise mappings verified against current MITRE ATT&CK (no revisions in 2024-2025). T0885 explicitly verified not deprecated (Enterprise's equivalent T1043 was deprecated in 2020; ICS retained T0885 separately, and a 2025 MITRE detection strategy DET0736 was added for it). + +## TLS analyzer MITRE assignments + +| Finding | Technique | Rationale | +|---|---|---| +| SNI contains ASCII control characters | **T1027** | Obfuscation via protocol field tampering. | +| SNI is ASCII but non-UTF-8 | **T1027** | Same. | +| SNI is valid UTF-8 but non-ASCII (control-char-free) | **T1027** | Same — RFC 6066 §3 requires ASCII. | +| Empty SNI | None (informational) | Not inherently malicious; benign scanners produce it. | +| SNI contains IP literal | None (informational) | Defer until correlated with C2 behavior. | +| Punycode / IDN SNI | None | IDN homograph detection is future work. | + +T1027 over T1036 (Masquerading) is deliberate. Perplexity-validated: T1036 "requires an attacker-controlled element attempting to impersonate a legitimate one, not direct tampering with protocol payloads." SNI with control bytes does not impersonate — it corrupts. Reassembly's existing T1036 usage (segment overlap with differing replacement content) is correct for masquerading; SNI tampering is correctly T1027. + +T1027 over T1071.001 is also deliberate. T1071.001 would overstate our detection — we see a malformed protocol field, not evidence of active C2 over HTTPS. Keep the technique aligned with what we actually detect. + +## Testing strategy + +- **Unit + regression (`tests/mitre_tests.rs`)**: every seeded ID round-trips through `technique_name` and `technique_tactic`; `all_tactics_in_report_order` contains every enum variant exactly once; `MitreTactic::Display` matches expected human names; a hand-curated, non-exhaustive list of known-emitted IDs is asserted to resolve in the lookup. This fails CI if a listed known-emitted ID is missing from the lookup, but it does **not** automatically catch every newly emitted analyzer ID unless that ID is also added to the curated list (see issue #67 for the trade-off rationale). +- **Reporter (`tests/reporter_tests.rs`)**: with `show_mitre_grouping = true`, findings are grouped by tactic; within-group sort is verdict-desc → confidence-desc; unknown IDs render as `(unknown)` and bucket under Uncategorized; `None` techniques bucket under Uncategorized; name expansion includes the em-dash. +- **CLI integration (`tests/integration_tests.rs` or equivalent)**: `wirerust analyze --mitre FIXTURE.pcap` produces grouped output; `wirerust analyze FIXTURE.pcap` matches baseline (no MITRE grouping). +- **TLS analyzer (`tests/tls_analyzer_tests.rs`)**: three malformed-SNI cases now assert `mitre_technique == Some("T1027")`. + +## Blast radius + +**New files:** +- `src/mitre.rs` (~200 lines) +- `tests/mitre_tests.rs` (unit + regression coverage in one file, per repo convention) + +**Modified files:** +- `src/lib.rs` — add `pub mod mitre;` +- `src/cli.rs` — add `mitre: bool` flag to `Commands::Analyze` +- `src/main.rs` — destructure `mitre` in the `Commands::Analyze` arm; thread into `run_analyze`; pass to `TerminalReporter` (`src/dispatcher.rs` is the *stream* dispatcher for HTTP/TLS routing, not the command dispatcher — the spec's earlier wording was inherited from a misread of the codebase) +- `src/reporter/terminal.rs` — `show_mitre_grouping` field on `TerminalReporter`; grouping code path with shared `render_finding_prefix` helper; em-dash name expansion; `(unknown)` fallback for IDs absent from the lookup +- `src/analyzer/tls.rs` — 3 of 7 `mitre_technique: None` sites become `Some("T1027")` +- `tests/cli_tests.rs` — assert `--mitre` flag parses +- `tests/reporter_tests.rs` — add grouping-path tests +- `tests/tls_analyzer_tests.rs` — assert T1027 on the three findings + +## Out of scope / follow-up issues (if demand appears) + +- JSON schema DTO with computed `mitre_tactic`/`mitre_name` — file when a consumer asks. +- `--mitre-links` (attack.mitre.org URLs per finding). +- `--group-by=tactic|severity|...` orthogonal flag refactor. +- DNS technique mapping (waits on #3 beaconing). +- Build-time STIX bundle codegen for the match statements — unnecessary at current scale. + +## Validation trail + +Every substantive design decision in this spec was validated against Perplexity (and Context7 where a library was involved) before being written here: + +- **Data model** (`Option` vs typed enum): Perplexity recommends strings for security tooling with evolving external catalogs. +- **Tactic as derived vs stored**: Perplexity recommends derived / DTO pattern; STIX 2.1 uses normalized relationships (derived), Suricata EVE denormalizes. We pick derived; can add a JSON DTO later. +- **`--mitre` flag scope**: Perplexity leans orthogonal flags; we defer that abstraction (YAGNI — single grouping axis today) and document the intent. +- **TLS technique**: T1027 validated over T1036 and T1071.001. +- **Pre-seeding**: Perplexity says pre-populating known-upstream catalogs is not a YAGNI violation; clippy does not warn on unused match arms. +- **Error handling**: `debug_assert!` in reporter + `Option` return from lookup is the Perplexity-recommended pattern for internal-invariant static tables. +- **Grouped output layout**: replace flat (our case) matches opt-in semantics of `--mitre`. +- **Within-group sort order**: severity descending matches every major SIEM (Splunk, Elastic, QRadar, Sentinel, Sumo Logic). +- **MITRE tactic assignments**: all Enterprise techniques verified current as of 2024-2025; ICS T0885 verified not deprecated; ICS tactic names verified. +- **Enterprise canonical tactic ordering**: 14 tactics in kill-chain order (Reconnaissance → Resource Development → Initial Access → Execution → Persistence → Privilege Escalation → Defense Evasion → Credential Access → Discovery → Lateral Movement → Collection → Command and Control → Exfiltration → Impact) validated against MITRE ATT&CK v18. +- **Display convention**: unprefixed tactic names per MITRE convention (Caldera, Atomic Red Team, ATT&CK Navigator); disambiguation via tactic IDs is the standard, not name prefixes. Enterprise/ICS tactic name collision (e.g., Discovery exists in both matrices with different TA-IDs) treated as a v1 limitation documented inline. diff --git a/src/analyzer/tls.rs b/src/analyzer/tls.rs index a6da174..504e678 100644 --- a/src/analyzer/tls.rs +++ b/src/analyzer/tls.rs @@ -375,6 +375,12 @@ impl TlsAnalyzer { }; Self::increment(&mut self.sni_counts, key, MAX_MAP_ENTRIES); + // SNI encoding violations (control chars, non-ASCII UTF-8, + // non-UTF-8 bytes) map to MITRE T1027 (Obfuscated Files or + // Information): the technique is corrupting a protocol field + // to evade inspection, not impersonating a legitimate + // hostname (which would be T1036 Masquerading) or proving C2 + // abuse over the channel (T1071.001). match sni { SniValue::Ascii(_) => {} // No C0/DEL detected; no finding emitted at this layer. SniValue::AsciiWithControl { hostname, hex } => { @@ -394,7 +400,7 @@ impl TlsAnalyzer { digits, and hyphens): {hostname}" ), evidence: vec![format!("hex: {hex}")], - mitre_technique: None, + mitre_technique: Some("T1027".to_string()), source_ip: None, timestamp: None, }); @@ -413,7 +419,7 @@ impl TlsAnalyzer { A-labels per RFC 5890): {hostname}" ), evidence: vec![format!("hex: {hex}")], - mitre_technique: None, + mitre_technique: Some("T1027".to_string()), source_ip: None, timestamp: None, }); @@ -432,7 +438,7 @@ impl TlsAnalyzer { "TLS SNI contains non-UTF-8 bytes (RFC 6066 violation): {lossy}" ), evidence: vec![format!("hex: {hex}")], - mitre_technique: None, + mitre_technique: Some("T1027".to_string()), source_ip: None, timestamp: None, }); diff --git a/src/cli.rs b/src/cli.rs index 44a2dd1..46f85e6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -83,6 +83,10 @@ pub enum Commands { #[arg(long)] beacon: bool, + /// Group findings by MITRE ATT&CK tactic and show technique names + #[arg(long)] + mitre: bool, + /// Run all analyzers #[arg(short, long)] all: bool, diff --git a/src/lib.rs b/src/lib.rs index a65375b..e39ed39 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod cli; pub mod decoder; pub mod dispatcher; pub mod findings; +pub mod mitre; pub mod reader; pub mod reassembly; pub mod reporter; diff --git a/src/main.rs b/src/main.rs index d4e2875..e32f8b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ fn main() -> Result<()> { http, tls, all, + mitre, .. } => { run_analyze( @@ -38,6 +39,7 @@ fn main() -> Result<()> { *dns || *all, *http || *all, *tls || *all, + *mitre, use_color, &cli, )?; @@ -55,6 +57,7 @@ fn run_analyze( enable_dns: bool, enable_http: bool, enable_tls: bool, + show_mitre_grouping: bool, use_color: bool, cli: &Cli, ) -> Result<()> { @@ -172,7 +175,10 @@ fn run_analyze( reporter.render(&summary, &all_findings, &analyzer_summaries) } _ => { - let reporter = TerminalReporter { use_color }; + let reporter = TerminalReporter { + use_color, + show_mitre_grouping, + }; reporter.render(&summary, &all_findings, &analyzer_summaries) } }; @@ -215,7 +221,10 @@ fn run_summary(targets: &[std::path::PathBuf], use_color: bool, cli: &Cli) -> Re reporter.render(&summary, &[], &[]) } _ => { - let reporter = TerminalReporter { use_color }; + let reporter = TerminalReporter { + use_color, + show_mitre_grouping: false, + }; reporter.render(&summary, &[], &[]) } }; diff --git a/src/mitre.rs b/src/mitre.rs new file mode 100644 index 0000000..e3c3024 --- /dev/null +++ b/src/mitre.rs @@ -0,0 +1,144 @@ +//! MITRE ATT&CK technique-ID → name / tactic lookup module. +//! +//! Backed by a single exhaustive `match` statement (see [`technique_info`]); +//! zero runtime dependencies. See the design spec under +//! `docs/superpowers/specs/` for the full rationale. +//! +//! ## ID format +//! +//! Callers pass technique IDs in MITRE's canonical form: `TXXXX` for parent +//! techniques (e.g., `T1046`) and `TXXXX.NNN` for sub-techniques (period +//! separator, three-digit suffix — e.g., `T1071.001`). This format is used +//! across ATT&CK matrices and STIX 2.1 bundles. Inputs that don't match a +//! seeded ID return `None` from the lookup functions. + +use std::fmt; + +// MITRE ATT&CK is an evolving external standard — new tactics are added in +// new ATT&CK versions (e.g., v18 added Resource Development). Mark the enum +// `#[non_exhaustive]` so adding a variant later is non-breaking for any +// downstream crate that matches on `MitreTactic`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum MitreTactic { + // Enterprise canonical kill-chain order. + Reconnaissance, + ResourceDevelopment, + InitialAccess, + Execution, + Persistence, + PrivilegeEscalation, + DefenseEvasion, + CredentialAccess, + Discovery, + LateralMovement, + Collection, + CommandAndControl, + Exfiltration, + Impact, + // ICS-unique tactics (names that don't collide with Enterprise). + IcsInhibitResponseFunction, + IcsImpairProcessControl, +} + +impl fmt::Display for MitreTactic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + MitreTactic::Reconnaissance => "Reconnaissance", + MitreTactic::ResourceDevelopment => "Resource Development", + MitreTactic::InitialAccess => "Initial Access", + MitreTactic::Execution => "Execution", + MitreTactic::Persistence => "Persistence", + MitreTactic::PrivilegeEscalation => "Privilege Escalation", + MitreTactic::DefenseEvasion => "Defense Evasion", + MitreTactic::CredentialAccess => "Credential Access", + MitreTactic::Discovery => "Discovery", + MitreTactic::LateralMovement => "Lateral Movement", + MitreTactic::Collection => "Collection", + MitreTactic::CommandAndControl => "Command and Control", + MitreTactic::Exfiltration => "Exfiltration", + MitreTactic::Impact => "Impact", + MitreTactic::IcsInhibitResponseFunction => "Inhibit Response Function", + MitreTactic::IcsImpairProcessControl => "Impair Process Control", + }; + f.write_str(name) + } +} + +/// Returns all tactics in canonical kill-chain order, with ICS-unique +/// tactics appended last. Intended as a stable iteration order for any +/// consumer that needs to present findings grouped by tactic. +pub fn all_tactics_in_report_order() -> &'static [MitreTactic] { + &[ + MitreTactic::Reconnaissance, + MitreTactic::ResourceDevelopment, + MitreTactic::InitialAccess, + MitreTactic::Execution, + MitreTactic::Persistence, + MitreTactic::PrivilegeEscalation, + MitreTactic::DefenseEvasion, + MitreTactic::CredentialAccess, + MitreTactic::Discovery, + MitreTactic::LateralMovement, + MitreTactic::Collection, + MitreTactic::CommandAndControl, + MitreTactic::Exfiltration, + MitreTactic::Impact, + MitreTactic::IcsInhibitResponseFunction, + MitreTactic::IcsImpairProcessControl, + ] +} + +/// Resolves a technique ID to its `(name, tactic)` pair. The single source +/// of truth for every seeded technique — [`technique_name`] and +/// [`technique_tactic`] are thin projections over this function, which +/// makes it impossible to add one facet without the other. +/// +/// Returns `None` for IDs not in the seeded set. +pub fn technique_info(id: &str) -> Option<(&'static str, MitreTactic)> { + let info = match id { + // Enterprise. + "T1027" => ( + "Obfuscated Files or Information", + MitreTactic::DefenseEvasion, + ), + "T1036" => ("Masquerading", MitreTactic::DefenseEvasion), + "T1040" => ("Network Sniffing", MitreTactic::CredentialAccess), + "T1046" => ("Network Service Discovery", MitreTactic::Discovery), + "T1071" => ("Application Layer Protocol", MitreTactic::CommandAndControl), + "T1071.001" => ("Web Protocols", MitreTactic::CommandAndControl), + "T1071.004" => ("DNS", MitreTactic::CommandAndControl), + "T1083" => ("File and Directory Discovery", MitreTactic::Discovery), + "T1499.002" => ("Service Exhaustion Flood", MitreTactic::Impact), + "T1505.003" => ("Web Shell", MitreTactic::Persistence), + "T1573" => ("Encrypted Channel", MitreTactic::CommandAndControl), + // ICS. MITRE assigns distinct TA-IDs per matrix (e.g., Enterprise + // Discovery TA0007 vs ICS Discovery TA0111); we intentionally + // merge by name so a single grouped report has one section per + // tactic name regardless of source matrix. + "T0846" => ("Remote System Discovery", MitreTactic::Discovery), + "T0855" => ( + "Unauthorized Command Message", + MitreTactic::IcsImpairProcessControl, + ), + "T0856" => ( + "Spoof Reporting Message", + MitreTactic::IcsImpairProcessControl, + ), + "T0885" => ("Commonly Used Port", MitreTactic::CommandAndControl), + _ => return None, + }; + Some(info) +} + +/// Resolves a technique ID to its human-readable name. Returns `None` for +/// unknown IDs. +pub fn technique_name(id: &str) -> Option<&'static str> { + technique_info(id).map(|(name, _)| name) +} + +/// Resolves a technique ID to its parent tactic. Returns `None` for +/// unknown IDs. +pub fn technique_tactic(id: &str) -> Option { + technique_info(id).map(|(_, tactic)| tactic) +} diff --git a/src/reporter/terminal.rs b/src/reporter/terminal.rs index 06664e6..a6a1e06 100644 --- a/src/reporter/terminal.rs +++ b/src/reporter/terminal.rs @@ -2,6 +2,7 @@ use owo_colors::OwoColorize; use crate::analyzer::AnalysisSummary; use crate::findings::{Confidence, Finding, Verdict}; +use crate::mitre::{MitreTactic, all_tactics_in_report_order, technique_name, technique_tactic}; use crate::reporter::Reporter; use crate::summary::Summary; @@ -46,6 +47,9 @@ fn escape_for_terminal(s: &str) -> String { pub struct TerminalReporter { pub use_color: bool, + /// When true, regroup the FINDINGS section by MITRE tactic and expand + /// the per-finding MITRE line to include the technique name. + pub show_mitre_grouping: bool, } impl Reporter for TerminalReporter { @@ -98,34 +102,14 @@ impl Reporter for TerminalReporter { // Findings if !findings.is_empty() { out.push_str(&self.section("FINDINGS")); - for f in findings { + if self.show_mitre_grouping { + self.render_findings_grouped(&mut out, findings); + } else { // Per ADR 0003: the Finding struct stores raw bytes; the // terminal reporter is responsible for escaping untrusted // content (summary + evidence) before writing to a TTY. - let escaped_summary = escape_for_terminal(&f.summary); - let line = format!( - "[{}] {} ({}) - {}", - f.category, f.verdict, f.confidence, escaped_summary - ); - let colored = if self.use_color { - match f.verdict { - Verdict::Likely => match f.confidence { - Confidence::High => line.red().bold().to_string(), - _ => line.yellow().to_string(), - }, - Verdict::Inconclusive => line.cyan().to_string(), - Verdict::Unlikely => line.dimmed().to_string(), - } - } else { - line - }; - out.push_str(&format!(" {colored}\n")); - for ev in &f.evidence { - let escaped_ev = escape_for_terminal(ev); - out.push_str(&format!(" > {escaped_ev}\n")); - } - if let Some(ref t) = f.mitre_technique { - out.push_str(&format!(" MITRE: {t}\n")); + for f in findings { + self.render_finding_flat(&mut out, f); } } out.push('\n'); @@ -164,6 +148,114 @@ impl TerminalReporter { format!("{title}\n{}\n", "─".repeat(40)) } } + + /// Emits the shared per-finding prefix: the colored `[category] verdict + /// (confidence) - summary` header line followed by each evidence line. + /// Summary and evidence strings are escaped per ADR 0003 before being + /// written to the TTY. Callers own the trailing MITRE line — see + /// [`render_finding_flat`] and [`render_finding_grouped`]. + fn render_finding_prefix(&self, out: &mut String, f: &Finding) { + let escaped_summary = escape_for_terminal(&f.summary); + let line = format!( + "[{}] {} ({}) - {}", + f.category, f.verdict, f.confidence, escaped_summary + ); + let colored = if self.use_color { + match f.verdict { + Verdict::Likely => match f.confidence { + Confidence::High => line.red().bold().to_string(), + _ => line.yellow().to_string(), + }, + Verdict::Inconclusive => line.cyan().to_string(), + Verdict::Unlikely => line.dimmed().to_string(), + } + } else { + line + }; + out.push_str(&format!(" {colored}\n")); + for ev in &f.evidence { + let escaped_ev = escape_for_terminal(ev); + out.push_str(&format!(" > {escaped_ev}\n")); + } + } + + /// Renders a single finding in the default flat view. MITRE line, if + /// present, shows just the technique ID. + fn render_finding_flat(&self, out: &mut String, f: &Finding) { + self.render_finding_prefix(out, f); + if let Some(ref t) = f.mitre_technique { + out.push_str(&format!(" MITRE: {t}\n")); + } + } + + /// Renders a single finding in the `--mitre` grouped view. MITRE line, + /// if present, expands to `ID — Name` for known IDs and `ID (unknown)` + /// for IDs absent from [`crate::mitre::technique_name`]. Unknown IDs + /// still render so they surface in audit trails; the regression test + /// `known_emitted_technique_ids_resolve_in_lookup` in + /// `tests/mitre_tests.rs` covers the hand-curated set of IDs we + /// currently emit (see issue #67 for the trade-off rationale). + fn render_finding_grouped(&self, out: &mut String, f: &Finding) { + self.render_finding_prefix(out, f); + if let Some(ref id) = f.mitre_technique { + match technique_name(id) { + Some(name) => out.push_str(&format!(" MITRE: {id} \u{2014} {name}\n")), + None => out.push_str(&format!(" MITRE: {id} (unknown)\n")), + } + } + } + + /// Renders the FINDINGS section grouped by MITRE tactic. Each tactic + /// header is `## Tactic Name`; sections appear in + /// [`all_tactics_in_report_order`] order with an Uncategorized bucket + /// last that holds findings with no technique or an unknown ID. + /// Within each bucket, findings sort by verdict-desc, then + /// confidence-desc, then original emission order (stable). + fn render_findings_grouped(&self, out: &mut String, findings: &[Finding]) { + // Bucket by tactic. Attach original index for stable tertiary sort. + let mut buckets: std::collections::HashMap, Vec<(usize, &Finding)>> = + std::collections::HashMap::new(); + for (i, f) in findings.iter().enumerate() { + let tactic = f.mitre_technique.as_deref().and_then(technique_tactic); + buckets.entry(tactic).or_default().push((i, f)); + } + + fn verdict_rank(v: Verdict) -> u8 { + match v { + Verdict::Likely => 0, + Verdict::Inconclusive => 1, + Verdict::Unlikely => 2, + } + } + fn confidence_rank(c: Confidence) -> u8 { + match c { + Confidence::High => 0, + Confidence::Medium => 1, + Confidence::Low => 2, + } + } + + for (_, items) in buckets.iter_mut() { + items.sort_by_key(|(idx, f)| { + (verdict_rank(f.verdict), confidence_rank(f.confidence), *idx) + }); + } + + for tactic in all_tactics_in_report_order() { + if let Some(items) = buckets.get(&Some(*tactic)) { + out.push_str(&format!(" ## {tactic}\n")); + for (_, f) in items { + self.render_finding_grouped(out, f); + } + } + } + if let Some(items) = buckets.get(&None) { + out.push_str(" ## Uncategorized\n"); + for (_, f) in items { + self.render_finding_grouped(out, f); + } + } + } } #[cfg(test)] diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 4f5973a..ceabe0b 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -90,3 +90,21 @@ fn test_multiple_targets() { _ => panic!("Expected Analyze command"), } } + +#[test] +fn test_mitre_flag_parses_on_analyze() { + let cli = Cli::parse_from(["wirerust", "analyze", "capture.pcap", "--mitre"]); + match cli.command { + Commands::Analyze { mitre, .. } => assert!(mitre), + _ => panic!("Expected Analyze command"), + } +} + +#[test] +fn test_mitre_flag_defaults_false() { + let cli = Cli::parse_from(["wirerust", "analyze", "capture.pcap"]); + match cli.command { + Commands::Analyze { mitre, .. } => assert!(!mitre), + _ => panic!("Expected Analyze command"), + } +} diff --git a/tests/mitre_tests.rs b/tests/mitre_tests.rs new file mode 100644 index 0000000..26d2bee --- /dev/null +++ b/tests/mitre_tests.rs @@ -0,0 +1,217 @@ +use wirerust::mitre::{MitreTactic, all_tactics_in_report_order, technique_name, technique_tactic}; + +#[test] +fn display_renders_enterprise_tactics_with_canonical_spacing() { + assert_eq!( + MitreTactic::CommandAndControl.to_string(), + "Command and Control" + ); + assert_eq!(MitreTactic::DefenseEvasion.to_string(), "Defense Evasion"); + assert_eq!( + MitreTactic::CredentialAccess.to_string(), + "Credential Access" + ); + assert_eq!(MitreTactic::LateralMovement.to_string(), "Lateral Movement"); + assert_eq!( + MitreTactic::PrivilegeEscalation.to_string(), + "Privilege Escalation" + ); + assert_eq!(MitreTactic::InitialAccess.to_string(), "Initial Access"); + assert_eq!( + MitreTactic::ResourceDevelopment.to_string(), + "Resource Development" + ); + assert_eq!(MitreTactic::Reconnaissance.to_string(), "Reconnaissance"); + assert_eq!(MitreTactic::Execution.to_string(), "Execution"); + assert_eq!(MitreTactic::Persistence.to_string(), "Persistence"); + assert_eq!(MitreTactic::Discovery.to_string(), "Discovery"); + assert_eq!(MitreTactic::Collection.to_string(), "Collection"); + assert_eq!(MitreTactic::Exfiltration.to_string(), "Exfiltration"); + assert_eq!(MitreTactic::Impact.to_string(), "Impact"); +} + +#[test] +fn display_renders_ics_tactics_unprefixed() { + assert_eq!( + MitreTactic::IcsInhibitResponseFunction.to_string(), + "Inhibit Response Function" + ); + assert_eq!( + MitreTactic::IcsImpairProcessControl.to_string(), + "Impair Process Control" + ); +} + +#[test] +fn report_order_starts_with_reconnaissance_and_ends_with_ics() { + let tactics = all_tactics_in_report_order(); + assert_eq!(tactics.first(), Some(&MitreTactic::Reconnaissance)); + assert_eq!(tactics.last(), Some(&MitreTactic::IcsImpairProcessControl)); +} + +#[test] +fn report_order_contains_every_variant_exactly_once() { + use std::collections::HashSet; + let tactics = all_tactics_in_report_order(); + // HashSet on MitreTactic uses the derived Eq + Hash — robust against + // any future change to the Debug impl. + let unique: HashSet = tactics.iter().copied().collect(); + assert_eq!( + unique.len(), + tactics.len(), + "duplicate variant in report order" + ); + assert_eq!( + tactics.len(), + 16, + "expected 14 Enterprise + 2 ICS-unique = 16 variants" + ); +} + +#[test] +fn report_order_matches_enterprise_kill_chain_for_first_14() { + let tactics = all_tactics_in_report_order(); + let enterprise = [ + MitreTactic::Reconnaissance, + MitreTactic::ResourceDevelopment, + MitreTactic::InitialAccess, + MitreTactic::Execution, + MitreTactic::Persistence, + MitreTactic::PrivilegeEscalation, + MitreTactic::DefenseEvasion, + MitreTactic::CredentialAccess, + MitreTactic::Discovery, + MitreTactic::LateralMovement, + MitreTactic::Collection, + MitreTactic::CommandAndControl, + MitreTactic::Exfiltration, + MitreTactic::Impact, + ]; + assert_eq!(&tactics[..14], &enterprise); +} + +#[test] +fn technique_name_resolves_every_seeded_id() { + assert_eq!( + technique_name("T1027"), + Some("Obfuscated Files or Information") + ); + assert_eq!(technique_name("T1036"), Some("Masquerading")); + assert_eq!(technique_name("T1040"), Some("Network Sniffing")); + assert_eq!(technique_name("T1046"), Some("Network Service Discovery")); + assert_eq!(technique_name("T1071"), Some("Application Layer Protocol")); + assert_eq!(technique_name("T1071.001"), Some("Web Protocols")); + assert_eq!(technique_name("T1071.004"), Some("DNS")); + assert_eq!( + technique_name("T1083"), + Some("File and Directory Discovery") + ); + assert_eq!( + technique_name("T1499.002"), + Some("Service Exhaustion Flood") + ); + assert_eq!(technique_name("T1505.003"), Some("Web Shell")); + assert_eq!(technique_name("T1573"), Some("Encrypted Channel")); + assert_eq!(technique_name("T0846"), Some("Remote System Discovery")); + assert_eq!( + technique_name("T0855"), + Some("Unauthorized Command Message") + ); + assert_eq!(technique_name("T0856"), Some("Spoof Reporting Message")); + assert_eq!(technique_name("T0885"), Some("Commonly Used Port")); +} + +#[test] +fn technique_name_returns_none_for_unknown_ids() { + assert_eq!(technique_name("T9999"), None); + assert_eq!(technique_name(""), None); + assert_eq!(technique_name("T1046.999"), None); + assert_eq!(technique_name("garbage"), None); +} + +#[test] +fn technique_tactic_matches_spec_table() { + assert_eq!(technique_tactic("T1027"), Some(MitreTactic::DefenseEvasion)); + assert_eq!(technique_tactic("T1036"), Some(MitreTactic::DefenseEvasion)); + assert_eq!( + technique_tactic("T1040"), + Some(MitreTactic::CredentialAccess) + ); + assert_eq!(technique_tactic("T1046"), Some(MitreTactic::Discovery)); + assert_eq!( + technique_tactic("T1071"), + Some(MitreTactic::CommandAndControl) + ); + assert_eq!( + technique_tactic("T1071.001"), + Some(MitreTactic::CommandAndControl) + ); + assert_eq!( + technique_tactic("T1071.004"), + Some(MitreTactic::CommandAndControl) + ); + assert_eq!(technique_tactic("T1083"), Some(MitreTactic::Discovery)); + assert_eq!(technique_tactic("T1499.002"), Some(MitreTactic::Impact)); + assert_eq!( + technique_tactic("T1505.003"), + Some(MitreTactic::Persistence) + ); + assert_eq!( + technique_tactic("T1573"), + Some(MitreTactic::CommandAndControl) + ); + assert_eq!(technique_tactic("T0846"), Some(MitreTactic::Discovery)); + assert_eq!( + technique_tactic("T0855"), + Some(MitreTactic::IcsImpairProcessControl) + ); + assert_eq!( + technique_tactic("T0856"), + Some(MitreTactic::IcsImpairProcessControl) + ); + assert_eq!( + technique_tactic("T0885"), + Some(MitreTactic::CommandAndControl) + ); +} + +#[test] +fn technique_tactic_returns_none_for_unknown_ids() { + assert_eq!(technique_tactic("T9999"), None); + assert_eq!(technique_tactic(""), None); +} + +#[test] +fn known_emitted_technique_ids_resolve_in_lookup() { + // Sanity check on a hand-curated list of the technique IDs the codebase + // emits today via `mitre_technique: Some("...")`. This is not an + // exhaustive cross-check — adding a new emission site without also + // adding the ID here will not fail this test. See issue #67 for the + // tracked discussion of the trade-off (the hand-curated approach is + // the idiomatic Rust pattern at this scale; revisit when emission + // sites grow > ~20 or a missed-update incident occurs). The + // convention is to update this list in the same commit as a new + // emission. + let emitted_ids = [ + // src/analyzer/http.rs + "T1083", + "T1505.003", + "T1046", + "T1499.002", + // src/analyzer/tls.rs (added in this feature) + "T1027", + // src/reassembly/mod.rs + "T1036", + ]; + + for id in emitted_ids { + assert!( + technique_name(id).is_some(), + "analyzer emits {id} but technique_name({id}) returned None", + ); + assert!( + technique_tactic(id).is_some(), + "analyzer emits {id} but technique_tactic({id}) returned None", + ); + } +} diff --git a/tests/reporter_tests.rs b/tests/reporter_tests.rs index 01f2ab9..c595595 100644 --- a/tests/reporter_tests.rs +++ b/tests/reporter_tests.rs @@ -59,7 +59,10 @@ fn test_json_reporter_skipped_packets_zero_by_default() { #[test] fn test_terminal_reporter_shows_skipped_when_nonzero() { - let reporter = TerminalReporter { use_color: false }; + let reporter = TerminalReporter { + use_color: false, + show_mitre_grouping: false, + }; let mut summary = Summary::new(); summary.skipped_packets = 5; @@ -72,7 +75,10 @@ fn test_terminal_reporter_shows_skipped_when_nonzero() { #[test] fn test_terminal_reporter_hides_skipped_when_zero() { - let reporter = TerminalReporter { use_color: false }; + let reporter = TerminalReporter { + use_color: false, + show_mitre_grouping: false, + }; let summary = Summary::new(); let output = reporter.render(&summary, &[], &[]); @@ -88,7 +94,10 @@ fn test_terminal_reporter_escapes_esc_bytes_in_summary() { // propagate the raw byte to terminal output, where it would be // interpreted as an ANSI escape sequence. Per ADR 0003, the terminal // reporter is responsible for this escaping. - let reporter = TerminalReporter { use_color: false }; + let reporter = TerminalReporter { + use_color: false, + show_mitre_grouping: false, + }; let summary = Summary::new(); let findings = vec![Finding { category: ThreatCategory::Anomaly, @@ -155,11 +164,11 @@ fn test_output_sanitization_layering_contract() { ); // Layer 2: terminal reporter escapes on display. - let terminal_output = TerminalReporter { use_color: false }.render( - &Summary::new(), - std::slice::from_ref(&finding), - &[], - ); + let terminal_output = TerminalReporter { + use_color: false, + show_mitre_grouping: false, + } + .render(&Summary::new(), std::slice::from_ref(&finding), &[]); assert!( !terminal_output.as_bytes().contains(&0x1b), "terminal reporter must not emit raw ESC bytes, got: {terminal_output:?}" @@ -258,7 +267,11 @@ fn test_terminal_reporter_escapes_control_bytes_in_analyzer_summaries() { detail, }; - let output = TerminalReporter { use_color: false }.render( + let output = TerminalReporter { + use_color: false, + show_mitre_grouping: false, + } + .render( &Summary::new(), &[], std::slice::from_ref(&analyzer_summary), @@ -359,7 +372,11 @@ fn test_http_finding_c1_csi_escaped_by_terminal_reporter() { ); // Render through terminal reporter — no raw C1 bytes in output. - let output = TerminalReporter { use_color: false }.render(&Summary::new(), &findings, &[]); + let output = TerminalReporter { + use_color: false, + show_mitre_grouping: false, + } + .render(&Summary::new(), &findings, &[]); assert!( !output.as_bytes().windows(2).any(|w| w == [0xC2, 0x9B]), "terminal output must not contain raw C1 CSI (0xC2 0x9B), got: {output:?}" @@ -437,7 +454,11 @@ fn test_http_analyzer_summary_c1_csi_escaped_by_terminal_reporter() { ); // Render through terminal reporter — no raw C1 bytes in output. - let output = TerminalReporter { use_color: false }.render( + let output = TerminalReporter { + use_color: false, + show_mitre_grouping: false, + } + .render( &Summary::new(), &[], std::slice::from_ref(&analyzer_summary), @@ -451,3 +472,230 @@ fn test_http_analyzer_summary_c1_csi_escaped_by_terminal_reporter() { "terminal output should contain the escaped form of C1 CSI in analyzer summary, got: {output}" ); } + +// --------------------------------------------------------------------------- +// MITRE grouping tests +// --------------------------------------------------------------------------- + +fn base_finding_with_mitre( + technique: Option<&str>, + verdict: Verdict, + confidence: Confidence, + summary: &str, +) -> Finding { + Finding { + category: ThreatCategory::Anomaly, + verdict, + confidence, + summary: summary.to_string(), + evidence: vec![], + mitre_technique: technique.map(|s| s.to_string()), + source_ip: None, + timestamp: None, + } +} + +#[test] +fn mitre_grouping_emits_tactic_headers_in_canonical_order() { + let findings = vec![ + base_finding_with_mitre(Some("T1499.002"), Verdict::Likely, Confidence::High, "dos"), + base_finding_with_mitre(Some("T1046"), Verdict::Likely, Confidence::High, "scan"), + base_finding_with_mitre(Some("T0855"), Verdict::Likely, Confidence::High, "ics"), + ]; + let reporter = TerminalReporter { + use_color: false, + show_mitre_grouping: true, + }; + let out = reporter.render(&Summary::new(), &findings, &[]); + // Anchor on the `## ` header prefix so future summary/evidence text + // containing the tactic word cannot confuse the ordering check. + let discovery_pos = out.find("## Discovery").expect("missing Discovery header"); + let impact_pos = out.find("## Impact").expect("missing Impact header"); + let ics_pos = out + .find("## Impair Process Control") + .expect("missing ICS header"); + assert!( + discovery_pos < impact_pos, + "Discovery must come before Impact" + ); + assert!(impact_pos < ics_pos, "Impact must come before ICS tactics"); +} + +#[test] +fn mitre_grouping_sorts_within_tactic_by_verdict_then_confidence() { + let findings = vec![ + base_finding_with_mitre(Some("T1046"), Verdict::Unlikely, Confidence::High, "third"), + base_finding_with_mitre(Some("T1046"), Verdict::Likely, Confidence::Medium, "second"), + base_finding_with_mitre(Some("T1046"), Verdict::Likely, Confidence::High, "first"), + base_finding_with_mitre( + Some("T1046"), + Verdict::Inconclusive, + Confidence::Low, + "fourth_ish", + ), + ]; + let reporter = TerminalReporter { + use_color: false, + show_mitre_grouping: true, + }; + let out = reporter.render(&Summary::new(), &findings, &[]); + let p1 = out.find("first").expect("first missing"); + let p2 = out.find("second").expect("second missing"); + let p3 = out.find("fourth_ish").expect("fourth_ish missing"); + let p4 = out.find("third").expect("third missing"); + assert!( + p1 < p2 && p2 < p3 && p3 < p4, + "verdict/confidence sort wrong: {out}" + ); +} + +#[test] +fn mitre_grouping_buckets_none_and_unknown_under_uncategorized() { + let findings = vec![ + base_finding_with_mitre(None, Verdict::Likely, Confidence::High, "no_id_finding"), + base_finding_with_mitre( + Some("T9999"), + Verdict::Likely, + Confidence::High, + "unknown_id_finding", + ), + base_finding_with_mitre( + Some("T1046"), + Verdict::Likely, + Confidence::High, + "known_finding", + ), + ]; + let reporter = TerminalReporter { + use_color: false, + show_mitre_grouping: true, + }; + let out = reporter.render(&Summary::new(), &findings, &[]); + let uncat_pos = out + .find("## Uncategorized") + .expect("missing Uncategorized section"); + let no_id_pos = out.find("no_id_finding").expect("missing no-id finding"); + let unknown_pos = out + .find("unknown_id_finding") + .expect("missing unknown-id finding"); + let known_pos = out.find("known_finding").expect("missing known finding"); + assert!( + known_pos < uncat_pos, + "Uncategorized must come after known tactics" + ); + assert!(uncat_pos < no_id_pos && uncat_pos < unknown_pos); + assert!( + out.contains("T9999 (unknown)"), + "unknown ID must render with '(unknown)' label" + ); +} + +#[test] +fn mitre_grouping_expands_per_finding_line_with_technique_name() { + let findings = vec![base_finding_with_mitre( + Some("T1046"), + Verdict::Likely, + Confidence::High, + "scan", + )]; + let reporter = TerminalReporter { + use_color: false, + show_mitre_grouping: true, + }; + let out = reporter.render(&Summary::new(), &findings, &[]); + assert!( + out.contains("MITRE: T1046 \u{2014} Network Service Discovery"), + "expected em-dash-expanded MITRE line, got: {out}", + ); +} + +#[test] +fn default_rendering_unchanged_when_mitre_flag_off() { + let findings = vec![base_finding_with_mitre( + Some("T1046"), + Verdict::Likely, + Confidence::High, + "scan", + )]; + let reporter = TerminalReporter { + use_color: false, + show_mitre_grouping: false, + }; + let out = reporter.render(&Summary::new(), &findings, &[]); + assert!(out.contains("MITRE: T1046")); + assert!( + !out.contains("\u{2014}"), + "em-dash should not appear in default render" + ); + assert!(!out.contains("## Uncategorized")); +} + +#[test] +fn mitre_grouping_preserves_emission_order_when_verdict_and_confidence_tie() { + // All four findings are in the same tactic with identical verdict + + // confidence — the tertiary emission-order key is the only thing + // that can distinguish them. + let findings = vec![ + base_finding_with_mitre(Some("T1046"), Verdict::Likely, Confidence::High, "alpha"), + base_finding_with_mitre(Some("T1046"), Verdict::Likely, Confidence::High, "bravo"), + base_finding_with_mitre(Some("T1046"), Verdict::Likely, Confidence::High, "charlie"), + base_finding_with_mitre(Some("T1046"), Verdict::Likely, Confidence::High, "delta"), + ]; + let reporter = TerminalReporter { + use_color: false, + show_mitre_grouping: true, + }; + let out = reporter.render(&Summary::new(), &findings, &[]); + let pa = out.find("alpha").expect("alpha missing"); + let pb = out.find("bravo").expect("bravo missing"); + let pc = out.find("charlie").expect("charlie missing"); + let pd = out.find("delta").expect("delta missing"); + assert!( + pa < pb && pb < pc && pc < pd, + "stable tiebreaker should preserve emission order: {out}", + ); +} + +#[test] +fn mitre_grouping_keeps_known_and_unknown_ids_in_separate_buckets() { + // Enterprise T1046 is Discovery; T1046.999 isn't seeded and so falls + // to Uncategorized. A finding with a known ID and one with a typo'd + // variant of the same family must not end up together. + let findings = vec![ + base_finding_with_mitre( + Some("T1046"), + Verdict::Likely, + Confidence::High, + "known_discovery", + ), + base_finding_with_mitre( + Some("T1046.999"), + Verdict::Likely, + Confidence::High, + "typo_variant", + ), + ]; + let reporter = TerminalReporter { + use_color: false, + show_mitre_grouping: true, + }; + let out = reporter.render(&Summary::new(), &findings, &[]); + let discovery_pos = out.find("## Discovery").expect("Discovery header missing"); + let uncat_pos = out + .find("## Uncategorized") + .expect("Uncategorized header missing"); + let known_pos = out.find("known_discovery").expect("known finding missing"); + let typo_pos = out.find("typo_variant").expect("typo finding missing"); + assert!( + discovery_pos < known_pos && known_pos < uncat_pos, + "known_discovery must render under the Discovery header", + ); + assert!( + uncat_pos < typo_pos, + "typo_variant must render under Uncategorized", + ); + assert!( + out.contains("T1046.999 (unknown)"), + "typo variant must render with '(unknown)' label", + ); +} diff --git a/tests/tls_analyzer_tests.rs b/tests/tls_analyzer_tests.rs index 3cd1d8b..30eb145 100644 --- a/tests/tls_analyzer_tests.rs +++ b/tests/tls_analyzer_tests.rs @@ -1398,3 +1398,58 @@ fn test_multiple_control_bytes_in_sni_produces_single_finding() { f.evidence ); } + +#[test] +fn ascii_control_sni_finding_sets_mitre_t1027() { + let esc_hostname = b"foo\x1bbar.example.com"; + let bytes = build_client_hello_ascii_bytes(esc_hostname, &[]); + let mut analyzer = TlsAnalyzer::new(); + analyzer.on_data(&test_flow_key(), Direction::ClientToServer, &bytes, 0); + + let findings = analyzer.findings(); + let control_finding = findings + .iter() + .find(|f| f.summary.contains("ASCII control characters")) + .expect("expected an ASCII-control SNI finding"); + assert_eq!( + control_finding.mitre_technique.as_deref(), + Some("T1027"), + "malformed-SNI finding must be mapped to T1027 (Obfuscated Files or Information)", + ); +} + +#[test] +fn non_ascii_utf8_sni_finding_sets_mitre_t1027() { + let bytes = build_client_hello("пример.рф", &[]); + let mut analyzer = TlsAnalyzer::new(); + analyzer.on_data(&test_flow_key(), Direction::ClientToServer, &bytes, 0); + + let findings = analyzer.findings(); + let finding = findings + .iter() + .find(|f| f.summary.contains("non-ASCII characters")) + .expect("expected a non-ASCII SNI finding"); + assert_eq!( + finding.mitre_technique.as_deref(), + Some("T1027"), + "malformed-SNI finding must be mapped to T1027 (Obfuscated Files or Information)", + ); +} + +#[test] +fn non_utf8_sni_finding_sets_mitre_t1027() { + let bytes = build_client_hello_raw_sni(&[b'f', b'o', b'o', 0xc3, b'.', b'c', b'o', b'm'], &[]); + let mut analyzer = TlsAnalyzer::new(); + analyzer.on_data(&test_flow_key(), Direction::ClientToServer, &bytes, 0); + + let findings = analyzer.findings(); + let finding = findings + .iter() + .find(|f| f.summary.contains("non-UTF-8 bytes")) + .expect("expected a non-UTF-8 SNI finding"); + assert_eq!( + finding.mitre_technique.as_deref(), + Some("T1027"), + "malformed-SNI finding must be mapped to T1027 (Obfuscated Files or Information)", + ); +}