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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,62 @@ Version numbers follow [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [0.13.2] - 2026-07-25

### Changed

- **ENIP `on_data` PDU dispatch loop: unsafe `*mut EnipFlowState` split-borrow replaced
with safe take-remove-reinsert (STORY-181, SEC-001, wave-85).**

The PDU dispatch loop in `src/analyzer/enip.rs` `on_data` previously acquired a raw
`*mut EnipFlowState` pointer via `self.flows.get_mut(&flow_key)` and called
`self.process_pdu(unsafe { &mut *flow_ptr }, ...)`, relying on a multi-line SAFETY
comment to guarantee that `process_pdu` never accesses `self.flows`. This pattern was
sound but fragile — any future change to `process_pdu` touching `self.flows` would
silently break soundness.

The fix removes the raw pointer entirely. Before the dispatch loop,
`self.flows.remove(&flow_key)` produces an owned `EnipFlowState`; `process_pdu(&mut
self, &mut flow, ...)` is called with this local variable (structurally disjoint from
`self.flows`); after the loop, `self.flows.insert(flow_key, flow)` re-inserts the flow.
The compiler enforces disjointness — no convention required. No `unsafe` block, no
`#[allow(clippy::ptr_as_ptr)]`, no raw-pointer cast remains in `on_data`. Behavior is
identical; all 2667 tests pass unchanged.

Resolves SEC-001 from `.factory/tech-debt-register.md` (MEDIUM, carry-forward since
PR #334).

### Added

- **IEC-104 timed control command detection: TypeIDs 58–64 emit T1692.001 and T0836
(STORY-180, BC-2.19.029 + BC-2.19.030, wave-85).**

The IEC-104 passive analyzer detects the CP56Time2a time-tagged variants of control
command TypeIDs, closing the evasion gap documented in IEC104-TIMED-CMD-GAP-001 where
TypeIDs 58–64 fell silently through the `_` catch-all arm.

Two new match arms in `detect_iec104_threats` (`src/analyzer/iec104.rs`):

- `58..=60` (C_SC_TA_1 / C_DC_TA_1 / C_RC_TA_1 — timed switching commands): emits one
T1692.001 "Unauthorized Message: Command Message" Possible / Medium / Impact finding
with CASDU and conditional first_ioa evidence. No T0836 (binary switching control, not
parameter writes). Parity with untimed arm 45..=47 (BC-2.19.019); summary wording
distinguishes timed from untimed with "time-tagged" qualifier and C_SC_TA/C_DC_TA/C_RC_TA
mnemonics (BC-2.19.029).

- `61..=64` (C_SE_TA_1 / C_SE_TB_1 / C_SE_TC_1 / C_BO_TA_1 — timed set-point and
bitstring write commands): emits T1692.001 Possible then T0836 "Modify Parameter" Possible,
both with CASDU and conditional first_ioa evidence. T0836 is co-emitted because set-point
and bitstring TypeIDs modify ICS control parameters. Parity with untimed arm 48..=51
(BC-2.19.019); summaries name C_SE_TA/C_SE_TB/C_SE_TC/C_BO_TA mnemonics (BC-2.19.030).

The catch-all arm comment at `detect_iec104_threats` is narrowed from "52–99" to
"{52–57, 65–99}", noting that TypeIDs 58–64 are now handled by BC-2.19.029 and
BC-2.19.030 (AC-180-007; BC-2.19.022 v1.1). The existing post-emission `[TEST]` loop
covers the new arms automatically — no extra wiring required (BC-2.19.017 invariant 1).

`cargo test --test iec104_analyzer_tests`: 248 passed (221 prior + 27 new STORY-180 tests).

## [0.13.1] - 2026-07-21

### Added
Expand Down Expand Up @@ -1829,7 +1885,8 @@ Downstream consumers of wirerust JSON or CSV output must update for this release
- Output sanitization in the terminal reporter guards against C1 control bytes
in packet-derived strings.

[Unreleased]: https://github.com/Zious11/wirerust/compare/v0.13.1...HEAD
[Unreleased]: https://github.com/Zious11/wirerust/compare/v0.13.2...HEAD
[0.13.2]: https://github.com/Zious11/wirerust/compare/v0.13.1...v0.13.2
[0.13.1]: https://github.com/Zious11/wirerust/compare/v0.13.0...v0.13.1
[0.13.0]: https://github.com/Zious11/wirerust/compare/v0.12.1...v0.13.0
[0.12.1]: https://github.com/Zious11/wirerust/compare/v0.12.0...v0.12.1
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "wirerust"
version = "0.13.1"
version = "0.13.2"
edition = "2024"
rust-version = "1.91"
description = "Fast PCAP forensics and network triage CLI tool"
Expand Down
8 changes: 4 additions & 4 deletions bin/validate-citations
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@ def parse_line(raw: str) -> tuple[str, int, int | None, str | None] | None:
"""Parse one line from a citations table.

Returns (path, start_line, end_line_or_None, anchor_or_None) for a valid
citation line, or None if the line should be skipped (blank, comment, or
a non-blank/non-comment line that fails the citation regex match --
callers distinguish this MALFORMED case from the skip case by re-checking
the stripped/comment status of the raw line, per F-S164P1-002).
citation line, or None if the line should be skipped (blank or comment),
or None if the line fails the citation regex (caller should treat as
MALFORMED). Callers distinguish the skip case from the MALFORMED case by
re-checking the stripped/comment status of the raw line, per F-S164P1-002.
"""
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# AC-180-001 / AC-180-002 — TypeIDs 58–60 (C_SC_TA/C_DC_TA/C_RC_TA): T1692.001 Only, No T0836

**Story:** STORY-180: IEC-104 Timed Control Command Detection: TypeIDs 58–64
**ACs:** AC-180-001, AC-180-002
**Traces to:** BC-2.19.029 postconditions 1–3; invariant 2
**Wave:** 85

---

## Acceptance Criteria

**AC-180-001:** TypeIDs 58–60 emit exactly one T1692.001 Possible finding with CASDU and
first_ioa evidence — identical parity to untimed arm 45..=47.

**AC-180-002:** TypeIDs 58–60 do NOT emit T0836 (switching commands are binary control,
not parameter writes) — mirrors BC-2.19.019 Invariant 2.

---

## Test Suite Execution

Command:
```
cargo test --test iec104_analyzer_tests "BC_2_19_029"
```

Output:
```
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.08s
Running tests/iec104_analyzer_tests.rs (target/debug/deps/iec104_analyzer_tests-09617f6be29af6e9)

running 10 tests
test story_180::test_BC_2_19_029_type_id_58_count_zero_still_emits ... ok
test story_180::test_BC_2_19_029_timed_summary_contains_time_tagged_qualifier ... ok
test story_180::test_BC_2_19_029_type_id_58_emits_t1692_001_only ... ok
test story_180::test_BC_2_19_029_timed_summary_differs_from_untimed_twin ... ok
test story_180::test_BC_2_19_029_casdu_first_ioa_evidence ... ok
test story_180::test_BC_2_19_029_type_id_58_verdict_confidence_category ... ok
test story_180::test_BC_2_19_029_type_id_60_cot_test_suffix ... ok
test story_180::test_BC_2_19_029_type_id_59_first_ioa_none_no_first_ioa_evidence ... ok
test story_180::test_BC_2_19_029_type_id_59_emits_t1692_001_only ... ok
test story_180::test_BC_2_19_029_type_id_60_emits_t1692_001_only ... ok

test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 238 filtered out; finished in 0.00s
```

Result: **10/10 PASS**

---

## Test Coverage

### AC-180-001: Exactly One T1692.001 Possible Finding per TypeID (BC-2.19.029 PC1 + PC3)

| Test Name | TypeID / Condition | Assertion | Result |
|-----------|-------------------|-----------|--------|
| `test_BC_2_19_029_type_id_58_emits_t1692_001_only` | TypeID=58 (C_SC_TA_1) | exactly 1 finding; T1692.001 present | PASS |
| `test_BC_2_19_029_type_id_59_emits_t1692_001_only` | TypeID=59 (C_DC_TA_1) | exactly 1 finding; T1692.001 present | PASS |
| `test_BC_2_19_029_type_id_60_emits_t1692_001_only` | TypeID=60 (C_RC_TA_1) | exactly 1 finding; T1692.001 present | PASS |
| `test_BC_2_19_029_type_id_58_verdict_confidence_category` | TypeID=58 | Verdict::Possible, Confidence::Medium, ThreatCategory::Impact | PASS |
| `test_BC_2_19_029_casdu_first_ioa_evidence` | TypeID=58, casdu=1, first_ioa=Some(100) | evidence contains "CASDU=1" and "first_ioa=100" | PASS |
| `test_BC_2_19_029_type_id_59_first_ioa_none_no_first_ioa_evidence` | TypeID=59, first_ioa=None | evidence contains "CASDU=" but NOT "first_ioa=" (EC-008) | PASS |

### AC-180-002: No T0836 for Switching Commands (BC-2.19.029 invariant 2)

The `test_BC_2_19_029_type_id_58_emits_t1692_001_only`,
`test_BC_2_19_029_type_id_59_emits_t1692_001_only`, and
`test_BC_2_19_029_type_id_60_emits_t1692_001_only` tests each assert `findings.len() == 1`,
which proves T0836 is never emitted (exactly one finding, not two).

---

## Dispatch Behavior Summary

| TypeID | IEC-104 Name | Findings | MITRE Techniques | Verdict |
|--------|-------------|----------|-----------------|---------|
| 58 | C_SC_TA_1 (timed single-point switching) | 1 | T1692.001 | Possible |
| 59 | C_DC_TA_1 (timed double-point switching) | 1 | T1692.001 | Possible |
| 60 | C_RC_TA_1 (timed regulating step) | 1 | T1692.001 | Possible |

---

## Verdict

AC-180-001: **PASS** — All three timed switching TypeIDs (58, 59, 60) emit exactly one
T1692.001 Possible finding with CASDU and conditional first_ioa evidence.

AC-180-002: **PASS** — T0836 is never emitted for TypeIDs 58–60; single-finding assertion
in each test is the negative proof.
94 changes: 94 additions & 0 deletions docs/demo-evidence/STORY-180/AC-003-typeid-61-64-timed-setpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# AC-180-003 — TypeIDs 61–64 (C_SE_TA/C_SE_TB/C_SE_TC/C_BO_TA): T1692.001 + T0836 Both Possible

**Story:** STORY-180: IEC-104 Timed Control Command Detection: TypeIDs 58–64
**AC:** AC-180-003
**Traces to:** BC-2.19.030 postconditions 1–3
**Wave:** 85

---

## Acceptance Criterion

- Given an I-format ASDU with TypeID in {61, 62, 63, 64}
- Then exactly two findings are emitted:
1. T1692.001 "Unauthorized Message: Command Message" with Verdict::Possible, Confidence::Medium, ThreatCategory::Impact
2. T0836 "Modify Parameter" with Verdict::Possible, Confidence::Medium, ThreatCategory::Impact
- Both findings' evidence vectors include CASDU and, when present, first_ioa
- T0836 is co-emitted because TypeIDs 61–64 are ICS parameter writes (set-point and bitstring output register writes)

---

## Test Suite Execution

Command:
```
cargo test --test iec104_analyzer_tests "BC_2_19_030"
```

Output:
```
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.07s
Running tests/iec104_analyzer_tests.rs (target/debug/deps/iec104_analyzer_tests-09617f6be29af6e9)

running 11 tests
test story_180::test_BC_2_19_030_type_id_61_count_zero_still_emits_two_findings ... ok
test story_180::test_BC_2_19_030_timed_summaries_differ_from_untimed_twin ... ok
test story_180::test_BC_2_19_030_timed_summaries_contain_time_tagged_and_mnemonics ... ok
test story_180::test_BC_2_19_030_type_id_61_casdu_first_ioa_evidence_both_findings ... ok
test story_180::test_BC_2_19_030_type_id_64_emits_two_findings ... ok
test story_180::test_BC_2_19_030_type_id_62_emits_two_findings ... ok
test story_180::test_BC_2_19_030_type_id_61_verdict_confidence_category_both_findings ... ok
test story_180::test_BC_2_19_030_type_id_64_cot_test_both_findings_tagged ... ok
test story_180::test_BC_2_19_030_type_id_61_emits_two_findings ... ok
test story_180::test_BC_2_19_030_type_id_62_first_ioa_none_no_first_ioa_evidence ... ok
test story_180::test_BC_2_19_030_type_id_63_emits_two_findings ... ok

test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 237 filtered out; finished in 0.00s
```

Result: **11/11 PASS**

---

## Test Coverage

### Exactly Two Findings per TypeID — T1692.001 + T0836

| Test Name | TypeID / Condition | Assertion | Result |
|-----------|-------------------|-----------|--------|
| `test_BC_2_19_030_type_id_61_emits_two_findings` | TypeID=61 (C_SE_TA_1) | exactly 2 findings; T1692.001 + T0836 present | PASS |
| `test_BC_2_19_030_type_id_62_emits_two_findings` | TypeID=62 (C_SE_TB_1) | exactly 2 findings; T1692.001 + T0836 present | PASS |
| `test_BC_2_19_030_type_id_63_emits_two_findings` | TypeID=63 (C_SE_TC_1) | exactly 2 findings; T1692.001 + T0836 present | PASS |
| `test_BC_2_19_030_type_id_64_emits_two_findings` | TypeID=64 (C_BO_TA_1, bitstring) | exactly 2 findings; T1692.001 + T0836 present | PASS |

### Verdict, Confidence, Category (BC-2.19.030 PC1 + PC2)

| Test Name | Condition | Assertion | Result |
|-----------|-----------|-----------|--------|
| `test_BC_2_19_030_type_id_61_verdict_confidence_category_both_findings` | TypeID=61 | Both findings: Verdict::Possible, Confidence::Medium, ThreatCategory::Impact | PASS |

### CASDU / first_ioa Evidence in Both Findings (BC-2.19.030 PC3)

| Test Name | Input | Assertion | Result |
|-----------|-------|-----------|--------|
| `test_BC_2_19_030_type_id_61_casdu_first_ioa_evidence_both_findings` | TypeID=61, casdu=5, first_ioa=Some(200) | Both findings contain "CASDU=5" and "first_ioa=200" | PASS |
| `test_BC_2_19_030_type_id_62_first_ioa_none_no_first_ioa_evidence` | TypeID=62, first_ioa=None | Both findings contain "CASDU=" but NOT "first_ioa=" | PASS |

---

## Dispatch Behavior Summary

| TypeID | IEC-104 Name | Findings | MITRE Techniques | Verdict |
|--------|-------------|----------|-----------------|---------|
| 61 | C_SE_TA_1 (timed set-point normalized value) | 2 | T1692.001 + T0836 | Possible |
| 62 | C_SE_TB_1 (timed set-point scaled value) | 2 | T1692.001 + T0836 | Possible |
| 63 | C_SE_TC_1 (timed set-point short float) | 2 | T1692.001 + T0836 | Possible |
| 64 | C_BO_TA_1 (timed bitstring of 32 bits) | 2 | T1692.001 + T0836 | Possible |

---

## Verdict

AC-180-003: **PASS** — All four timed set-point/bitstring TypeIDs (61, 62, 63, 64) emit
exactly two findings (T1692.001 Possible + T0836 Possible) with CASDU and conditional
first_ioa evidence in both findings, matching BC-2.19.030 postconditions 1–3.
95 changes: 95 additions & 0 deletions docs/demo-evidence/STORY-180/AC-004-timed-summary-wording.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# AC-180-004 — Timed-Variant Summary Wording Distinguishes from Untimed Twin Summaries

**Story:** STORY-180: IEC-104 Timed Control Command Detection: TypeIDs 58–64
**AC:** AC-180-004
**Traces to:** BC-2.19.029 postcondition 4; BC-2.19.030 postconditions 4 and 5
**Wave:** 85

---

## Acceptance Criterion

- For TypeIDs 58–60: the T1692.001 finding `summary` field uses the "time-tagged" qualifier
and names the timed mnemonics (C_SC_TA/C_DC_TA/C_RC_TA)
- For TypeIDs 61–64: both the T1692.001 and T0836 finding `summary` fields name the timed
mnemonics (C_SE_TA/C_SE_TB/C_SE_TC/C_BO_TA)
- Neither timed summary string is identical to the corresponding untimed arm's summary —
analysts can distinguish timed from untimed findings in output

---

## Test Suite Execution

Command:
```
cargo test --test iec104_analyzer_tests "timed_summary"
```

Output:
```
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.07s
Running tests/iec104_analyzer_tests.rs (target/debug/deps/iec104_analyzer_tests-09617f6be29af6e9)

running 4 tests
test story_180::test_BC_2_19_029_timed_summary_contains_time_tagged_qualifier ... ok
test story_180::test_BC_2_19_029_timed_summary_differs_from_untimed_twin ... ok
test story_180::test_BC_2_19_030_timed_summaries_contain_time_tagged_and_mnemonics ... ok
test story_180::test_BC_2_19_030_timed_summaries_differ_from_untimed_twin ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 244 filtered out; finished in 0.00s
```

Result: **4/4 PASS**

---

## Test Coverage

| Test Name | Scope | Assertion | Result |
|-----------|-------|-----------|--------|
| `test_BC_2_19_029_timed_summary_contains_time_tagged_qualifier` | TypeID=58 T1692.001 summary | summary contains "time-tagged" and "C_SC_TA/C_DC_TA/C_RC_TA" | PASS |
| `test_BC_2_19_029_timed_summary_differs_from_untimed_twin` | TypeID=58 vs TypeID=45 T1692.001 summary | timed summary != untimed arm-45 summary | PASS |
| `test_BC_2_19_030_timed_summaries_contain_time_tagged_and_mnemonics` | TypeID=61 both findings | both summaries contain "time-tagged" and "C_SE_TA/C_SE_TB/C_SE_TC/C_BO_TA" | PASS |
| `test_BC_2_19_030_timed_summaries_differ_from_untimed_twin` | TypeID=61 vs TypeID=48 summaries | timed T1692.001 summary != untimed arm-48 T1692.001 summary | PASS |

---

## Source-Level Verification

Summary string for arm 58..=60 (src/analyzer/iec104.rs):
```
"IEC-104 time-tagged control command TypeID={type_id} \
(C_SC_TA/C_DC_TA/C_RC_TA): time-tagged switching control command \
observed on passive monitor \
(T1692.001 unauthorized command message; BC-2.19.029)"
```

Summary string for arm 61..=64 T1692.001 (src/analyzer/iec104.rs):
```
"IEC-104 time-tagged control command TypeID={type_id} \
(C_SE_TA/C_SE_TB/C_SE_TC/C_BO_TA): time-tagged set-point or bitstring \
write command observed on passive monitor \
(T1692.001 unauthorized command message; BC-2.19.030)"
```

Summary string for arm 61..=64 T0836 (src/analyzer/iec104.rs):
```
"IEC-104 time-tagged parameter modification TypeID={type_id} \
(C_SE_TA/C_SE_TB/C_SE_TC/C_BO_TA): time-tagged set-point or bitstring \
write modifying ICS control parameter on passive monitor \
(T0836 modify parameter; BC-2.19.030 postcondition 2)"
```

Confirmed by grep:
```
grep -n "C_SC_TA/C_DC_TA/C_RC_TA\|C_SE_TA/C_SE_TB" src/analyzer/iec104.rs
```
Returns lines 857–860 and 904–907, 920–923 — all contain "time-tagged".

---

## Verdict

AC-180-004: **PASS** — All four summary-wording tests pass. Timed summaries include
"time-tagged" qualifier and timed-arm mnemonics; diff tests confirm no string identity
with the untimed twin arm summaries.
Loading