From 4494368cdb0c75ef4b783e1ad11189b2557add9c Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 00:31:12 -0500 Subject: [PATCH 01/20] docs: add MITRE ATT&CK mapping design spec (#5) Spec for wiring mitre_technique across all analyzers and adding a --mitre flag that regroups terminal output by tactic. Option field stays; new src/mitre.rs module provides technique_name and technique_tactic lookups backed by exhaustive match statements. Pre-seeds Enterprise + ICS entries for backlog issues #3, #7, #8. TLS malformed-SNI findings assigned T1027 (Obfuscated Files or Information). Every design decision validated against Perplexity. --- .../2026-04-13-mitre-attack-mapping-design.md | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-13-mitre-attack-mapping-design.md 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..efb1f1d --- /dev/null +++ b/docs/superpowers/specs/2026-04-13-mitre-attack-mapping-design.md @@ -0,0 +1,176 @@ +# 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 { + Reconnaissance, + InitialAccess, + Execution, + Persistence, + PrivilegeEscalation, + DefenseEvasion, + CredentialAccess, + Discovery, + LateralMovement, + Collection, + CommandAndControl, + Exfiltration, + Impact, + IcsInhibitResponseFunction, + IcsImpairProcessControl, +} + +impl fmt::Display for MitreTactic { /* human names: "Command and Control", "ICS: Inhibit Response Function", ... */ } + +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]; +``` + +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()` (kill-chain order: Reconnaissance → … → Impact → ICS 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 through `src/dispatcher.rs` into `TerminalReporter` via a new constructor parameter (or field on an existing config struct, following the `use_color` pattern). + +### Error handling for unknown IDs + +- `technique_name` / `technique_tactic` return `Option`; `None` is the unknown-ID signal. +- At the reporter call site: `debug_assert!(technique_name(id).is_some(), "unknown MITRE id: {id}")`. Fires in `cargo test` (debug build), zero cost in release. Catches analyzer typos at CI time. +- Release behavior: render unknown IDs inline (`MITRE: T9999 (unknown)`) and bucket under Uncategorized. Never panic user-facing. +- Regression test `tests/mitre_coverage.rs`: a canonical list of every ID the codebase intentionally emits, with each asserted to resolve via `technique_name` + `technique_tactic`. The list is manually maintained; growing it is a required step when any analyzer adds a new technique ID. + +## 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 (`src/mitre.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. +- **Regression (`tests/mitre_coverage.rs`)**: canonical list of every ID the codebase emits, each asserted to resolve. Fails CI if an analyzer emits an ID not in the lookup. +- **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_coverage.rs` +- `tests/mitre_tests.rs` + +**Modified files:** +- `src/lib.rs` — add `pub mod mitre;` +- `src/cli.rs` — add `mitre: bool` flag to `Commands::Analyze` +- `src/dispatcher.rs` — thread flag through to reporter +- `src/reporter/mod.rs` — constructor takes the flag (or a config struct) +- `src/reporter/terminal.rs` — grouping code path; `debug_assert!` at MITRE render site +- `src/analyzer/tls.rs` — 3 of 7 `mitre_technique: None` sites become `Some("T1027")` +- `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. From c667d205af7f6793f34bde9896c357377166dac5 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 00:35:12 -0500 Subject: [PATCH 02/20] =?UTF-8?q?docs:=20fix=20MITRE=20spec=20=E2=80=94=20?= =?UTF-8?q?add=20ResourceDevelopment,=20drop=20ICS=20prefix,=20document=20?= =?UTF-8?q?tactic=20collision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation flagged: (1) Enterprise has 14 tactics canonically; spec was missing ResourceDevelopment. (2) MITRE convention does not prefix tactic names with "ICS:" — Caldera/Atomic Red Team/Navigator all use unprefixed names. (3) Enterprise and ICS share several tactic names (Discovery, Command and Control, etc.) with different TA-IDs; unified variants are a documented v1 limitation, splittable later if needed. --- .../2026-04-13-mitre-attack-mapping-design.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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 index efb1f1d..328e470 100644 --- a/docs/superpowers/specs/2026-04-13-mitre-attack-mapping-design.md +++ b/docs/superpowers/specs/2026-04-13-mitre-attack-mapping-design.md @@ -23,7 +23,9 @@ Systematically map every finding to a MITRE ATT&CK technique (Enterprise + ICS m ```rust pub enum MitreTactic { + // Enterprise canonical order (MITRE ATT&CK v18, 14 tactics) Reconnaissance, + ResourceDevelopment, InitialAccess, Execution, Persistence, @@ -36,17 +38,27 @@ pub enum MitreTactic { CommandAndControl, Exfiltration, Impact, + // ICS-unique tactics (names that don't collide with Enterprise) IcsInhibitResponseFunction, IcsImpairProcessControl, } -impl fmt::Display for MitreTactic { /* human names: "Command and Control", "ICS: Inhibit Response Function", ... */ } +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` @@ -64,7 +76,7 @@ Without `--mitre` (default): output unchanged. `MITRE: T1046` line printed per f With `--mitre`: 1. Replace the flat FINDINGS section with a grouped view. -2. Tactic section order = `all_tactics_in_report_order()` (kill-chain order: Reconnaissance → … → Impact → ICS tactics → Uncategorized last). +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). @@ -174,3 +186,5 @@ Every substantive design decision in this spec was validated against Perplexity - **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. From 57ed8698a6d77a5258dcbcced20ec8e7bd93b7c4 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 00:37:44 -0500 Subject: [PATCH 03/20] docs: fold MITRE coverage test into mitre_tests.rs per repo convention All existing tests/*.rs files use _tests.rs suffix; tests/mitre_coverage.rs broke the pattern. Merging the regression test into tests/mitre_tests.rs as a #[test] fn keeps naming consistent and reduces file count. --- .../specs/2026-04-13-mitre-attack-mapping-design.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 index 328e470..ad873a5 100644 --- a/docs/superpowers/specs/2026-04-13-mitre-attack-mapping-design.md +++ b/docs/superpowers/specs/2026-04-13-mitre-attack-mapping-design.md @@ -99,7 +99,7 @@ Threaded through `src/dispatcher.rs` into `TerminalReporter` via a new construct - `technique_name` / `technique_tactic` return `Option`; `None` is the unknown-ID signal. - At the reporter call site: `debug_assert!(technique_name(id).is_some(), "unknown MITRE id: {id}")`. Fires in `cargo test` (debug build), zero cost in release. Catches analyzer typos at CI time. - Release behavior: render unknown IDs inline (`MITRE: T9999 (unknown)`) and bucket under Uncategorized. Never panic user-facing. -- Regression test `tests/mitre_coverage.rs`: a canonical list of every ID the codebase intentionally emits, with each asserted to resolve via `technique_name` + `technique_tactic`. The list is manually maintained; growing it is a required step when any analyzer adds a new technique ID. +- Regression test in `tests/mitre_tests.rs`: a `#[test] fn all_emitted_ids_are_known` with a canonical list of every ID the codebase intentionally emits, each asserted to resolve via `technique_name` + `technique_tactic`. The list is manually maintained; growing it is a required step when any analyzer adds a new technique ID. ## Pre-seeded techniques @@ -142,8 +142,7 @@ T1027 over T1071.001 is also deliberate. T1071.001 would overstate our detection ## Testing strategy -- **Unit (`src/mitre.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. -- **Regression (`tests/mitre_coverage.rs`)**: canonical list of every ID the codebase emits, each asserted to resolve. Fails CI if an analyzer emits an ID not in the lookup. +- **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 canonical list of every ID the codebase emits is asserted to resolve (fails CI if an analyzer emits an ID not in the lookup). - **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")`. @@ -152,8 +151,7 @@ T1027 over T1071.001 is also deliberate. T1071.001 would overstate our detection **New files:** - `src/mitre.rs` (~200 lines) -- `tests/mitre_coverage.rs` -- `tests/mitre_tests.rs` +- `tests/mitre_tests.rs` (unit + regression coverage in one file, per repo convention) **Modified files:** - `src/lib.rs` — add `pub mod mitre;` From 5417bc8594595fe87a81f6e913a59f1b946b5c7d Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 00:48:35 -0500 Subject: [PATCH 04/20] docs: add MITRE ATT&CK implementation plan (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9 tasks, TDD-per-task, small commits. Task 9 is the full CI-equivalent check (cargo fmt + clippy + test) — encodes the lesson from PR #61 where skipping fmt-check locally caused a CI Format regression on initial push. --- .../plans/2026-04-13-mitre-attack-mapping.md | 1084 +++++++++++++++++ 1 file changed, 1084 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-13-mitre-attack-mapping.md 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. From f843374abf02b57e90c5d07e21e124a76d906e2f Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 08:12:36 -0500 Subject: [PATCH 05/20] chore: ignore .claude/worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ea8c4bf..c8d4ce6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.claude/worktrees/ From de38b4442538452163aa38df5076b7c967989aa2 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 08:19:53 -0500 Subject: [PATCH 06/20] feat(mitre): add MitreTactic enum with canonical display + ordering --- src/lib.rs | 1 + src/mitre.rs | 77 ++++++++++++++++++++++++++++++++++++++++++++ tests/mitre_tests.rs | 74 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 src/mitre.rs create mode 100644 tests/mitre_tests.rs 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/mitre.rs b/src/mitre.rs new file mode 100644 index 0000000..1584e08 --- /dev/null +++ b/src/mitre.rs @@ -0,0 +1,77 @@ +//! 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, + ] +} diff --git a/tests/mitre_tests.rs b/tests/mitre_tests.rs new file mode 100644 index 0000000..faa5fa4 --- /dev/null +++ b/tests/mitre_tests.rs @@ -0,0 +1,74 @@ +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(); + 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); +} From 11831341453dc9a829e6ce4f6b8f37547981798c Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 08:27:41 -0500 Subject: [PATCH 07/20] feat(mitre): add technique_name and technique_tactic lookups --- src/mitre.rs | 55 ++++++++++++++++++++++++++++++++++++++++ tests/mitre_tests.rs | 60 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/mitre.rs b/src/mitre.rs index 1584e08..c708309 100644 --- a/src/mitre.rs +++ b/src/mitre.rs @@ -75,3 +75,58 @@ pub fn all_tactics_in_report_order() -> &'static [MitreTactic] { MitreTactic::IcsImpairProcessControl, ] } + +/// 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) +} diff --git a/tests/mitre_tests.rs b/tests/mitre_tests.rs index faa5fa4..cd7a6aa 100644 --- a/tests/mitre_tests.rs +++ b/tests/mitre_tests.rs @@ -1,4 +1,4 @@ -use wirerust::mitre::{MitreTactic, all_tactics_in_report_order}; +use wirerust::mitre::{MitreTactic, all_tactics_in_report_order, technique_name, technique_tactic}; #[test] fn display_renders_enterprise_tactics_with_canonical_spacing() { @@ -72,3 +72,61 @@ fn report_order_matches_enterprise_kill_chain_for_first_14() { ]; 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); +} From e313e6a4284e69f9a0976e02dec7430e0a727bda Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 08:30:37 -0500 Subject: [PATCH 08/20] test(mitre): add coverage regression test for emitted technique IDs --- tests/mitre_tests.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/mitre_tests.rs b/tests/mitre_tests.rs index cd7a6aa..7ff2756 100644 --- a/tests/mitre_tests.rs +++ b/tests/mitre_tests.rs @@ -130,3 +130,32 @@ fn technique_tactic_returns_none_for_unknown_ids() { assert_eq!(technique_tactic("T9999"), None); assert_eq!(technique_tactic(""), None); } + +#[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", + ); + } +} From 528b1c810eb1656e132e007b3c40d37cbd08b24b Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 08:37:00 -0500 Subject: [PATCH 09/20] feat(tls): map malformed SNI findings to MITRE T1027 (Obfuscated Files or Information) --- src/analyzer/tls.rs | 6 ++-- tests/tls_analyzer_tests.rs | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/analyzer/tls.rs b/src/analyzer/tls.rs index a6da174..acab95d 100644 --- a/src/analyzer/tls.rs +++ b/src/analyzer/tls.rs @@ -394,7 +394,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 +413,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 +432,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/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)", + ); +} From aa9ce3817b94d57986d88791724cc2efe0af7d37 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 08:39:28 -0500 Subject: [PATCH 10/20] feat(cli): add --mitre flag to analyze subcommand --- src/cli.rs | 4 ++++ tests/cli_tests.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) 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/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"), + } +} From adfbb57b8b758cd6dbe62185201e45df5be3ad68 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 08:42:22 -0500 Subject: [PATCH 11/20] refactor(reporter): add show_mitre_grouping field (default false, no behavior change) --- src/main.rs | 4 ++-- src/reporter/terminal.rs | 3 +++ tests/reporter_tests.rs | 14 +++++++------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index d4e2875..0165576 100644 --- a/src/main.rs +++ b/src/main.rs @@ -172,7 +172,7 @@ fn run_analyze( reporter.render(&summary, &all_findings, &analyzer_summaries) } _ => { - let reporter = TerminalReporter { use_color }; + let reporter = TerminalReporter { use_color, show_mitre_grouping: false }; reporter.render(&summary, &all_findings, &analyzer_summaries) } }; @@ -215,7 +215,7 @@ 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/reporter/terminal.rs b/src/reporter/terminal.rs index 06664e6..25f5780 100644 --- a/src/reporter/terminal.rs +++ b/src/reporter/terminal.rs @@ -46,6 +46,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 { diff --git a/tests/reporter_tests.rs b/tests/reporter_tests.rs index 01f2ab9..4cf2484 100644 --- a/tests/reporter_tests.rs +++ b/tests/reporter_tests.rs @@ -59,7 +59,7 @@ 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 +72,7 @@ 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 +88,7 @@ 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,7 +155,7 @@ fn test_output_sanitization_layering_contract() { ); // Layer 2: terminal reporter escapes on display. - let terminal_output = TerminalReporter { use_color: false }.render( + let terminal_output = TerminalReporter { use_color: false, show_mitre_grouping: false }.render( &Summary::new(), std::slice::from_ref(&finding), &[], @@ -258,7 +258,7 @@ 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 +359,7 @@ 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 +437,7 @@ 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), From 8888b2c3bd27c40c8608c7014c770eea5d1ef4fd Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 08:43:38 -0500 Subject: [PATCH 12/20] feat(cli): thread --mitre flag into TerminalReporter --- src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 0165576..a606206 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,7 @@ fn run_analyze( reporter.render(&summary, &all_findings, &analyzer_summaries) } _ => { - let reporter = TerminalReporter { use_color, show_mitre_grouping: false }; + let reporter = TerminalReporter { use_color, show_mitre_grouping }; reporter.render(&summary, &all_findings, &analyzer_summaries) } }; From b345e7b94cc4d6d6af72785b539800c2fa7335af Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 08:49:27 -0500 Subject: [PATCH 13/20] feat(reporter): group findings by MITRE tactic when --mitre is set Implements the grouped FINDINGS rendering path in TerminalReporter: buckets findings by tactic, sorts within each group by verdict then confidence then original index, emits sections in ATT&CK kill-chain order, appends Uncategorized last, and expands per-finding MITRE lines to include the technique name (or '(unknown)' for unrecognised IDs). --- src/reporter/terminal.rs | 136 ++++++++++++++++++++++++++++++++------- tests/reporter_tests.rs | 104 ++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+), 25 deletions(-) diff --git a/src/reporter/terminal.rs b/src/reporter/terminal.rs index 25f5780..ac4b75a 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::{all_tactics_in_report_order, technique_name, technique_tactic, MitreTactic}; use crate::reporter::Reporter; use crate::summary::Summary; @@ -101,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'); @@ -167,6 +148,111 @@ impl TerminalReporter { format!("{title}\n{}\n", "─".repeat(40)) } } + + 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) { + 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 { + 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")), + } + } + } + + 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/reporter_tests.rs b/tests/reporter_tests.rs index 4cf2484..9c88a4f 100644 --- a/tests/reporter_tests.rs +++ b/tests/reporter_tests.rs @@ -1,6 +1,7 @@ use std::net::IpAddr; use wirerust::analyzer::http::HttpAnalyzer; use wirerust::findings::{Confidence, Finding, ThreatCategory, Verdict}; +use wirerust::mitre::MitreTactic; use wirerust::reassembly::flow::FlowKey; use wirerust::reassembly::handler::{Direction, StreamAnalyzer, StreamHandler}; use wirerust::reporter::Reporter; @@ -451,3 +452,106 @@ 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, &[]); + 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")); +} + +// Ensure unused import doesn't trigger a warning — MitreTactic is used in +// grouping tests indirectly; this explicit reference silences the lint. +#[allow(dead_code)] +fn _use_mitre_tactic(_: MitreTactic) {} From d6bd53a468bee7c35201e2b6726c68825c822b67 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 08:51:59 -0500 Subject: [PATCH 14/20] refactor(reporter): drop unused MitreTactic import + dead-code shim from tests --- tests/reporter_tests.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/reporter_tests.rs b/tests/reporter_tests.rs index 9c88a4f..8d3e978 100644 --- a/tests/reporter_tests.rs +++ b/tests/reporter_tests.rs @@ -1,7 +1,6 @@ use std::net::IpAddr; use wirerust::analyzer::http::HttpAnalyzer; use wirerust::findings::{Confidence, Finding, ThreatCategory, Verdict}; -use wirerust::mitre::MitreTactic; use wirerust::reassembly::flow::FlowKey; use wirerust::reassembly::handler::{Direction, StreamAnalyzer, StreamHandler}; use wirerust::reporter::Reporter; @@ -551,7 +550,3 @@ fn default_rendering_unchanged_when_mitre_flag_off() { assert!(!out.contains("Uncategorized")); } -// Ensure unused import doesn't trigger a warning — MitreTactic is used in -// grouping tests indirectly; this explicit reference silences the lint. -#[allow(dead_code)] -fn _use_mitre_tactic(_: MitreTactic) {} From 355035c37816e270ec2be1a9e45b814a4d792f0c Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 08:52:56 -0500 Subject: [PATCH 15/20] style: apply rustfmt --- src/main.rs | 10 ++- src/reporter/terminal.rs | 2 +- tests/mitre_tests.rs | 85 +++++++++++++++++------ tests/reporter_tests.rs | 145 ++++++++++++++++++++++++++++++--------- 4 files changed, 185 insertions(+), 57 deletions(-) diff --git a/src/main.rs b/src/main.rs index a606206..e32f8b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -175,7 +175,10 @@ fn run_analyze( reporter.render(&summary, &all_findings, &analyzer_summaries) } _ => { - let reporter = TerminalReporter { use_color, show_mitre_grouping }; + let reporter = TerminalReporter { + use_color, + show_mitre_grouping, + }; reporter.render(&summary, &all_findings, &analyzer_summaries) } }; @@ -218,7 +221,10 @@ fn run_summary(targets: &[std::path::PathBuf], use_color: bool, cli: &Cli) -> Re reporter.render(&summary, &[], &[]) } _ => { - let reporter = TerminalReporter { use_color, show_mitre_grouping: false }; + let reporter = TerminalReporter { + use_color, + show_mitre_grouping: false, + }; reporter.render(&summary, &[], &[]) } }; diff --git a/src/reporter/terminal.rs b/src/reporter/terminal.rs index ac4b75a..cb2564d 100644 --- a/src/reporter/terminal.rs +++ b/src/reporter/terminal.rs @@ -2,7 +2,7 @@ use owo_colors::OwoColorize; use crate::analyzer::AnalysisSummary; use crate::findings::{Confidence, Finding, Verdict}; -use crate::mitre::{all_tactics_in_report_order, technique_name, technique_tactic, MitreTactic}; +use crate::mitre::{MitreTactic, all_tactics_in_report_order, technique_name, technique_tactic}; use crate::reporter::Reporter; use crate::summary::Summary; diff --git a/tests/mitre_tests.rs b/tests/mitre_tests.rs index 7ff2756..04b7846 100644 --- a/tests/mitre_tests.rs +++ b/tests/mitre_tests.rs @@ -2,13 +2,25 @@ use wirerust::mitre::{MitreTactic, all_tactics_in_report_order, technique_name, #[test] fn display_renders_enterprise_tactics_with_canonical_spacing() { - assert_eq!(MitreTactic::CommandAndControl.to_string(), "Command and Control"); + 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::CredentialAccess.to_string(), + "Credential Access" + ); assert_eq!(MitreTactic::LateralMovement.to_string(), "Lateral Movement"); - assert_eq!(MitreTactic::PrivilegeEscalation.to_string(), "Privilege Escalation"); + 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::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"); @@ -34,10 +46,7 @@ fn display_renders_ics_tactics_unprefixed() { 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) - ); + assert_eq!(tactics.last(), Some(&MitreTactic::IcsImpairProcessControl)); } #[test] @@ -48,7 +57,10 @@ fn report_order_contains_every_variant_exactly_once() { 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"); + assert_eq!( + before, 16, + "expected 14 Enterprise + 2 ICS-unique = 16 variants" + ); } #[test] @@ -75,19 +87,31 @@ fn report_order_matches_enterprise_kill_chain_for_first_14() { #[test] fn technique_name_resolves_every_seeded_id() { - assert_eq!(technique_name("T1027"), Some("Obfuscated Files or Information")); + 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("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("T0855"), + Some("Unauthorized Command Message") + ); assert_eq!(technique_name("T0856"), Some("Spoof Reporting Message")); assert_eq!(technique_name("T0885"), Some("Commonly Used Port")); } @@ -104,15 +128,33 @@ fn technique_name_returns_none_for_unknown_ids() { 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("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("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("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"), @@ -122,7 +164,10 @@ fn technique_tactic_matches_spec_table() { technique_tactic("T0856"), Some(MitreTactic::IcsImpairProcessControl) ); - assert_eq!(technique_tactic("T0885"), Some(MitreTactic::CommandAndControl)); + assert_eq!( + technique_tactic("T0885"), + Some(MitreTactic::CommandAndControl) + ); } #[test] diff --git a/tests/reporter_tests.rs b/tests/reporter_tests.rs index 8d3e978..18f77fe 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, show_mitre_grouping: 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, show_mitre_grouping: 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, show_mitre_grouping: 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, show_mitre_grouping: 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, show_mitre_grouping: 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, show_mitre_grouping: 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, show_mitre_grouping: false }.render( + let output = TerminalReporter { + use_color: false, + show_mitre_grouping: false, + } + .render( &Summary::new(), &[], std::slice::from_ref(&analyzer_summary), @@ -481,12 +502,20 @@ fn mitre_grouping_emits_tactic_headers_in_canonical_order() { 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 reporter = TerminalReporter { + use_color: false, + show_mitre_grouping: true, + }; let out = reporter.render(&Summary::new(), &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"); + 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"); } @@ -496,41 +525,81 @@ fn mitre_grouping_sorts_within_tactic_by_verdict_then_confidence() { 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"), + base_finding_with_mitre( + Some("T1046"), + Verdict::Inconclusive, + Confidence::Low, + "fourth_ish", + ), ]; - let reporter = TerminalReporter { use_color: false, show_mitre_grouping: true }; + 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}"); + 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"), + 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 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 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 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!( + 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"); + 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 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"), @@ -540,13 +609,21 @@ fn mitre_grouping_expands_per_finding_line_with_technique_name() { #[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 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("\u{2014}"), + "em-dash should not appear in default render" + ); assert!(!out.contains("Uncategorized")); } - From 9d8ef3f7bb931e577455b68cc1316ff943b5b2f8 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 09:07:49 -0500 Subject: [PATCH 16/20] =?UTF-8?q?fix:=20address=20local=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20consolidate=20lookups,=20tighten=20tests,=20trim=20?= =?UTF-8?q?docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local multi-agent review (code, tests, comments, silent failures, type design) converged on 12 actionable items in 3 buckets — substantive design, test hygiene, and doc rot. Substantive items validated against Perplexity before fixing. Substantive (Perplexity-validated): - src/mitre.rs: consolidate technique_name + technique_tactic into a single source of truth via technique_info(id) -> Option<(name, tactic)>; the existing public fns become thin projections. Eliminates the parallel-match drift class — adding an ID to one without the other is now structurally impossible. - src/reporter/terminal.rs: extract render_finding_prefix shared by flat and grouped per-finding render paths. Common escape/colorize/ evidence emission lives in one place; each MITRE-line variant owns only its trailing two lines. - tests/mitre_tests.rs: HashSet uniqueness check instead of format!("{:?}") roundtrip — robust against any future Debug impl change. Test hygiene: - tests/reporter_tests.rs: anchor tactic-header substring assertions on "## " prefix so future analyzer summaries containing the word "Discovery" or "Impact" cannot confuse the ordering checks. - tests/reporter_tests.rs: add test for sort tiebreaker (4 findings with identical verdict+confidence must preserve emission order). - tests/reporter_tests.rs: add test for typo'd-variant separation (T1046 known + T1046.999 unknown must end up in distinct buckets). Doc rot: - src/mitre.rs: drop "MITRE ATT&CK v18, 14 tactics" version pin; drop self-reference "Used by the terminal reporter" from all_tactics_in_report_order; drop orphan debug_assert! suggestion in technique_name doc (no caller does this and the design dropped it during Task 8 review); inline the ICS/Enterprise tactic-name collision rationale instead of "see spec / v1 limitation". - src/reporter/terminal.rs: doc comments on render_finding_prefix, render_finding_flat, render_finding_grouped, render_findings_grouped. - src/analyzer/tls.rs: shared block comment above the SNI match explaining why malformed-SNI maps to T1027 (Obfuscated Files or Information) rather than T1036 (Masquerading) or T1071.001 (Web Protocols). --- src/analyzer/tls.rs | 6 +++ src/mitre.rs | 114 +++++++++++++++++++++------------------ src/reporter/terminal.rs | 48 +++++++++-------- tests/mitre_tests.rs | 17 +++--- tests/reporter_tests.rs | 82 ++++++++++++++++++++++++++-- 5 files changed, 180 insertions(+), 87 deletions(-) diff --git a/src/analyzer/tls.rs b/src/analyzer/tls.rs index acab95d..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 } => { diff --git a/src/mitre.rs b/src/mitre.rs index c708309..b085241 100644 --- a/src/mitre.rs +++ b/src/mitre.rs @@ -1,14 +1,23 @@ //! 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. +//! 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`). The format is used +//! consistently across Enterprise, ICS, and Mobile matrices and in STIX 2.1 +//! bundles. Inputs that don't match a seeded ID return `None` from the +//! lookup functions. use std::fmt; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum MitreTactic { - // Enterprise canonical order (MITRE ATT&CK v18, 14 tactics). + // Enterprise canonical kill-chain order. Reconnaissance, ResourceDevelopment, InitialAccess, @@ -52,9 +61,9 @@ impl fmt::Display for MitreTactic { } } -/// 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. +/// 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, @@ -76,57 +85,56 @@ pub fn all_tactics_in_report_order() -> &'static [MitreTactic] { ] } -/// Resolves a MITRE ATT&CK technique ID to its human-readable name. +/// 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 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 { +/// 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", - "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", + "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. The shared-name tactics (Discovery, Command and Control) + // collapse into their Enterprise variants — Enterprise and ICS + // matrices list them as distinct TA-IDs, but for grouping we + // prefer one section per tactic name. + "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(name) + Some(info) } -/// 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. +/// 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 { - 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) + technique_info(id).map(|(_, tactic)| tactic) } diff --git a/src/reporter/terminal.rs b/src/reporter/terminal.rs index cb2564d..8568175 100644 --- a/src/reporter/terminal.rs +++ b/src/reporter/terminal.rs @@ -149,7 +149,12 @@ impl TerminalReporter { } } - fn render_finding_flat(&self, out: &mut String, f: &Finding) { + /// 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!( "[{}] {} ({}) - {}", @@ -172,34 +177,25 @@ impl TerminalReporter { 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 canonical + /// regression test in `tests/mitre_tests.rs` is the authoritative typo + /// gate at CI time. fn render_finding_grouped(&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")); - } + 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")), @@ -208,6 +204,12 @@ impl TerminalReporter { } } + /// 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)>> = diff --git a/tests/mitre_tests.rs b/tests/mitre_tests.rs index 04b7846..eba7fad 100644 --- a/tests/mitre_tests.rs +++ b/tests/mitre_tests.rs @@ -51,14 +51,19 @@ fn report_order_starts_with_reconnaissance_and_ends_with_ics() { #[test] fn report_order_contains_every_variant_exactly_once() { + use std::collections::HashSet; let tactics = all_tactics_in_report_order(); - 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"); + // 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!( - before, 16, + unique.len(), + tactics.len(), + "duplicate variant in report order" + ); + assert_eq!( + tactics.len(), + 16, "expected 14 Enterprise + 2 ICS-unique = 16 variants" ); } diff --git a/tests/reporter_tests.rs b/tests/reporter_tests.rs index 18f77fe..c595595 100644 --- a/tests/reporter_tests.rs +++ b/tests/reporter_tests.rs @@ -507,10 +507,12 @@ fn mitre_grouping_emits_tactic_headers_in_canonical_order() { show_mitre_grouping: true, }; let out = reporter.render(&Summary::new(), &findings, &[]); - let discovery_pos = out.find("Discovery").expect("missing Discovery header"); - let impact_pos = out.find("Impact").expect("missing Impact header"); + // 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") + .find("## Impair Process Control") .expect("missing ICS header"); assert!( discovery_pos < impact_pos, @@ -570,7 +572,7 @@ fn mitre_grouping_buckets_none_and_unknown_under_uncategorized() { }; let out = reporter.render(&Summary::new(), &findings, &[]); let uncat_pos = out - .find("Uncategorized") + .find("## Uncategorized") .expect("missing Uncategorized section"); let no_id_pos = out.find("no_id_finding").expect("missing no-id finding"); let unknown_pos = out @@ -625,5 +627,75 @@ fn default_rendering_unchanged_when_mitre_flag_off() { !out.contains("\u{2014}"), "em-dash should not appear in default render" ); - assert!(!out.contains("Uncategorized")); + 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", + ); } From f39ec156055829391770f4c7bb950f67751a9f8f Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 09:10:11 -0500 Subject: [PATCH 17/20] docs(mitre): tighten ID-format and ICS-collapse comments per round-2 review --- src/mitre.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/mitre.rs b/src/mitre.rs index b085241..976e1fb 100644 --- a/src/mitre.rs +++ b/src/mitre.rs @@ -8,10 +8,9 @@ //! //! 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`). The format is used -//! consistently across Enterprise, ICS, and Mobile matrices and in STIX 2.1 -//! bundles. Inputs that don't match a seeded ID return `None` from the -//! lookup functions. +//! 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; @@ -108,10 +107,10 @@ pub fn technique_info(id: &str) -> Option<(&'static str, MitreTactic)> { "T1499.002" => ("Service Exhaustion Flood", MitreTactic::Impact), "T1505.003" => ("Web Shell", MitreTactic::Persistence), "T1573" => ("Encrypted Channel", MitreTactic::CommandAndControl), - // ICS. The shared-name tactics (Discovery, Command and Control) - // collapse into their Enterprise variants — Enterprise and ICS - // matrices list them as distinct TA-IDs, but for grouping we - // prefer one section per tactic name. + // 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", From 2a08994e2dd8e1145bff742c5c3f768c7fc9c90d Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 09:36:32 -0500 Subject: [PATCH 18/20] docs: tighten regression-test claims per Copilot review (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Copilot comments hit the same theme — the canonical-list test is a hand-curated sanity check, not an exhaustive cross-check, and the docs overstated it as the "authoritative typo gate." Renaming the test to known_emitted_technique_ids_resolve_in_lookup and tightening the doc comments in tls.rs / terminal.rs accordingly. The hand-curated approach is preserved (Perplexity-validated as the idiomatic Rust pattern at this scale per #67); only the wording changes to match what the test actually does. Filed #67 as the tracked anchor for the trade-off rationale. --- src/reporter/terminal.rs | 7 ++++--- tests/mitre_tests.rs | 14 ++++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/reporter/terminal.rs b/src/reporter/terminal.rs index 8568175..a6a1e06 100644 --- a/src/reporter/terminal.rs +++ b/src/reporter/terminal.rs @@ -191,9 +191,10 @@ impl TerminalReporter { /// 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 canonical - /// regression test in `tests/mitre_tests.rs` is the authoritative typo - /// gate at CI time. + /// 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 { diff --git a/tests/mitre_tests.rs b/tests/mitre_tests.rs index eba7fad..26d2bee 100644 --- a/tests/mitre_tests.rs +++ b/tests/mitre_tests.rs @@ -182,10 +182,16 @@ fn technique_tactic_returns_none_for_unknown_ids() { } #[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. +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", From fd3a6808a9f1b2bd402f372165112b6ff431a799 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 09:48:30 -0500 Subject: [PATCH 19/20] docs: sync spec with implemented behavior per Copilot round 2 (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three spec sections drifted from reality during brainstorming/planning and were never reconciled: - CLI threading section claimed src/dispatcher.rs threads --mitre, but that's the stream dispatcher; actual threading is via src/main.rs (where Commands::Analyze is destructured). Updated. - Error-handling section described a debug_assert! at the render site that was deliberately dropped during local PR review (Task 8) because the grouped-render unknown-ID test would panic under it. Updated to document the actual lenient-render behavior. - Testing-strategy bullet claimed the canonical-IDs test "fails CI if an analyzer emits an ID not in the lookup" — overstated; the test is hand-curated and non-exhaustive (see issue #67). Updated wording. - Blast-radius modified-files list referenced src/dispatcher.rs and src/reporter/mod.rs and a debug_assert! that aren't in this PR. Updated to match the actual touched files. Implementation unchanged; only the spec is brought in sync. --- .../2026-04-13-mitre-attack-mapping-design.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 index ad873a5..0206199 100644 --- a/docs/superpowers/specs/2026-04-13-mitre-attack-mapping-design.md +++ b/docs/superpowers/specs/2026-04-13-mitre-attack-mapping-design.md @@ -92,14 +92,14 @@ Add to `Commands::Analyze` in `src/cli.rs`: pub mitre: bool, ``` -Threaded through `src/dispatcher.rs` into `TerminalReporter` via a new constructor parameter (or field on an existing config struct, following the `use_color` pattern). +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. -- At the reporter call site: `debug_assert!(technique_name(id).is_some(), "unknown MITRE id: {id}")`. Fires in `cargo test` (debug build), zero cost in release. Catches analyzer typos at CI time. +- 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`: a `#[test] fn all_emitted_ids_are_known` with a canonical list of every ID the codebase intentionally emits, each asserted to resolve via `technique_name` + `technique_tactic`. The list is manually maintained; growing it is a required step when any analyzer adds a new technique ID. +- 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 @@ -142,7 +142,7 @@ T1027 over T1071.001 is also deliberate. T1071.001 would overstate our detection ## 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 canonical list of every ID the codebase emits is asserted to resolve (fails CI if an analyzer emits an ID not in the lookup). +- **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")`. @@ -156,10 +156,10 @@ T1027 over T1071.001 is also deliberate. T1071.001 would overstate our detection **Modified files:** - `src/lib.rs` — add `pub mod mitre;` - `src/cli.rs` — add `mitre: bool` flag to `Commands::Analyze` -- `src/dispatcher.rs` — thread flag through to reporter -- `src/reporter/mod.rs` — constructor takes the flag (or a config struct) -- `src/reporter/terminal.rs` — grouping code path; `debug_assert!` at MITRE render site +- `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 From 2c3c6732e6873d681c0604d4e9b5519c58a70831 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 13 Apr 2026 10:00:49 -0500 Subject: [PATCH 20/20] feat(mitre): mark MitreTactic non_exhaustive (#66, closes #65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round-3 review revisited the suggestion I had filed as deferred issue #65: mark MitreTactic #[non_exhaustive] so adding a tactic when MITRE bumps a new ATT&CK version is non-breaking for downstream crates that match on the enum. The original deferral rationale ("no external consumers today") was weaker than Copilot's argument: the right time to add #[non_exhaustive] is BEFORE external consumers start exhaustive-matching, because adding it later would itself become breaking for them. In-crate matches are unaffected — same-crate matches don't need wildcard arms even with #[non_exhaustive]. Already Perplexity-validated as the canonical use case ("appropriate for closed-today, possibly-extended-later enums bound to external versioned standards") during the deferred-items review round; cited example was MITRE ATT&CK specifically. 213 tests pass, fmt + clippy clean. --- src/mitre.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/mitre.rs b/src/mitre.rs index 976e1fb..e3c3024 100644 --- a/src/mitre.rs +++ b/src/mitre.rs @@ -14,7 +14,12 @@ 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,