Repository navigation
feat(s7comm): add classic S7comm header parser and protocol_id dispatch (STORY-187) - #475
Conversation
Complete S7commFlowState with session/classification/malformed-header fields, add S7Protocol/Rosctr/S7commHeader data types, add parse_s7comm_header (todo!() pure-core stub), and extend on_data with the BC-2.21.002 four-way protocol_id dispatch skeleton. Only the Some(0x32) classic branch is wired to a todo!() stub (dispatch_classic_s7comm -> parse_s7comm_header); the CR/CC session branch, the None-from-parse_cotp_header branch, and the Some(0x72)/Some(other) DT branches remain todo!()-free structural no-ops per the story's own Tasks list, which also keeps STORY-186's existing frame-extraction tests green. No implementation logic added; tests are the next step.
…053 skeletons Adds the story_187 Red Gate test suite in tests/s7comm_analyzer_tests.rs against the STORY-187 stubs (parse_s7comm_header, dispatch_classic_s7comm todo!(); the todo!()-free classify_first_dt_frame and CR/CC session-tracking no-ops): - One or more tests per AC-187-001..011, using BC-2.21.001/002/004-009's canonical test vectors and edge cases (exact struct-equality assertions, not is_some()). - A #[cfg(kani)] VP-051 skeleton (verify_parse_s7comm_header_bounds_safety), located in this test file per the story's File Structure Requirements since parse_s7comm_header is pub. - A VP-053 proptest skeleton (proptest_vp053_protocol_id_dispatch_totality), partial per the story's VP-053 obligation note. - tests/fixtures/mk_s7comm_pcap.py (CREATE) + generated tests/fixtures/s7comm-setup-comm.pcap, synthetic CC0/MIT per ADR-014 Decision 7. Red Gate verified: all 16 pre-existing story_186 tests remain green; 20 of 25 new story_187 tests fail (18 via todo!() panic in parse_s7comm_header, 2 via assertion failure against the no-op classify_first_dt_frame/CR-CC stubs). 5 new tests legitimately pass against the stub (documented inline as NOTE (expected pass against the stub)): field-default/lazy-creation/never-touched checks for BC-2.21.001 and the CR/CC-only-never-classifies check for BC-2.21.002 -- none exercise todo!()-stubbed logic. cargo clippy --all-targets -- -D warnings and cargo fmt --check are clean.
…er T0814 dedup emission
…ser and dispatch skeleton
…erage gaps Adds STORY-187 v1.1 round-2 test coverage for the human-ratified rulings from per-story adversarial pass 1 (2026-09-24): F-01 opposite-direction CR/CC session-establishment matching, F-02 protocol_id:None-never-classifies, F-08 256-value ROSCTR totality, F-09 AC-187-004 rename plus malformed-on-Classic test, F-12 sticky-classification gates re-dissection, and F-14's bounded VP-051 Kani harness plus the s7comm_bounds_ok pure helper. Also fixes F-03's dt_frame_empty_payload helper (now a genuinely constructible LI=2 DT frame) and F-04's per-direction (s2c) malformed-header dedup coverage for AC-187-006/009/011. Minimal Stub Architect-scope additions to src/analyzer/s7comm.rs: adds S7commFlowState::cr_observed_dir (Option<Direction>, defaults None) and a todo!()-bodied pub fn s7comm_bounds_ok — no dispatch-logic behavior changes. Red Gate: 7 new tests fail against the current (pre-round-2) implementation, as expected — the F-01/F-02/F-12 dispatch gaps and the s7comm_bounds_ok stub. All 52 other story_187 tests and all 20 story_186 tests remain green; no regressions elsewhere in the workspace (cargo test --all-targets --no-fail-fast). clippy -D warnings and fmt --check clean; bin/check-green-doc-tense passes.
…t (F-10/F-17) - cotp_dt() now emits the standard ISO 8073 class-0 DT fixed part as three separate bytes (LI=0x02, code=0xF0, TPDU-NR+EOT=0x80|(tpdu_number&0x7F)) instead of merging the code and TPDU-NR+EOT into a single byte under a mislabeled LI=1. - Fixed the epoch-to-UTC docstring comment: 1_717_100_000 is 2024-05-30 20:13:20 UTC, not "~22:33:20 UTC". - Removed the duplicated "not yet consumed by any CLI/E2E test" paragraph (it appeared twice in the module docstring). - Corrected the mk_modbus_pcap.py precedent citation to mk_modbus_large_pcap.py, matching ADR-014 Decision 7's own citation. Regenerated tests/fixtures/s7comm-setup-comm.pcap (826 -> 830 bytes: the four DT frames in the fixture each gained one byte from the corrected COTP DT fixed-part shape). Verified byte-identical regeneration by running the generator twice and comparing SHA-256 digests.
…re into dispatch_classic_s7comm as single source of truth
…able! in parse_s7comm_header
…of dispatch_classic_s7comm
…ence; clippy collapsible_if fix in F-01 CC gate
…n_data, dispatch_cotp_frame, findings field)
…nt (exit-verification grep gate)
…curate Added description; cargo fmt
…e, reason-specific T0814, Ack paths, repeated CR, VP-051 bounds half
…ut ruling) Update the STORY-187 test suite for the 2026-09-24 human-ratified canonical-frame holdout ruling (DF-CANONICAL-FRAME-HOLDOUT-001): ROSCTR 0x02 (Ack) AND 0x03 (Ack_Data) both require the 12-byte S7comm header (error_class = data[10], error_code = data[11], header_len == 12); Job (0x01)/Userdata (0x07) remain 10-byte; a truncated Ack_Data frame (10 or 11 bytes) returns None, exactly like a truncated Ack. - Split the AC-187-013 canonical Job+Ack_Data on_data test into two per-BC tests: test_BC_2_21_006_canonical_setup_communication_job_frame_on_data (Job only) and the new test_BC_2_21_008_canonical_ack_data_setup_communication_response_on_data (Ack_Data only), asserting the corrected 12-byte shape as ordinary GREEN-gate assertions rather than a documented discrepancy. - Added test_BC_2_21_008_ack_data_12_byte_header_and_error_fields, test_BC_2_21_008_truncated_ack_data_returns_none, and test_BC_2_21_008_truncated_ack_data_on_data_emits_t0814_once, mirroring their existing Ack-specific counterparts. - Updated test_BC_2_21_006_common_header_field_extraction to drop the Ack_Data happy-path vector (moved to BC-2.21.008) and assert None for a 10-byte Ack_Data header instead. - Renamed test_BC_2_21_008_error_fields_none_for_non_ack_rosctr to test_BC_2_21_008_error_fields_none_for_job_and_userdata_rosctr and added the Userdata case, since Ack_Data no longer has None error fields. - Replaced the old 10/12-byte-only ROSCTR totality proptest with two distinct proptests: proptest_bc_2_21_007_rosctr_byte_totality_over_all_256_values (pure recognized-vs-unrecognized concern) and the new proptest_bc_2_21_006_008_some_iff_rosctr_and_length_conditional (the length-conditional joint totality formula). - Corrected the VP-051 Kani harness's non-vacuous assertion from error_class.is_some() == (rosctr == Ack) to (rosctr == Ack || rosctr == AckData), with matching kani::cover! additions. - Added an ack_data_header_bytes test helper mirroring ack_header_bytes. Red Gate: 6 of 68 tests fail against the pre-ruling implementation, all exercising the new Ack_Data-12-byte-header behavior; all other tests (including the full story_186/story_187 regression suite) pass. cargo fmt --check, cargo clippy --all-targets -D warnings, and bin/check-green-doc-tense all pass. No src/ changes -- no CHANGELOG entry required.
Per the 2026-09-24 canonical-frame holdout ruling (DF-CANONICAL-FRAME-HOLDOUT-001), ROSCTR 0x02 (Ack) AND 0x03 (Ack_Data) both require the 12-byte extended header (error_class=data[10], error_code=data[11], header_len=12); Job (0x01) and Userdata (0x07) remain 10 bytes with error fields None. Ack_Data at 10/11 bytes now returns None (truncated), matching Ack's truncation behavior. parse_s7comm_header: 0x03 joins 0x02's 12-byte match arm. classify_malformed_header_reason: adds a distinct "truncated Ack_Data" reason string (0x03), separate from the existing "truncated Ack" (0x02) reason. Refs: BC-2.21.006 v1.1, BC-2.21.008 v1.2, BC-2.21.004 v1.2, BC-2.21.009 v1.1, STORY-187 v1.3.
Adversary pass-2 finding F-20: several doc comments described pre-fix behavior. - classified_protocol field doc claimed it is set on the first DT frame "(any protocol_id value, including None)" -- wrong since F-02: a protocol_id: None DT frame never consumes first-DT-frame status. Now describes the actual Some(byte)-only trigger. - S7Protocol::Unclassified doc claimed protocol_id None classifies as Unclassified -- wrong; None never classifies. Now describes only the Some(byte)-other-than-0x32/0x72 case. - cr_observed_dir doc implied the field is cleared once a CR/CC match occurs -- the code never clears it (ConnectConfirm only sets session_established). Now describes the field as never reset, just overwritten by each new CR. - Module-level BC-2.21.006/008 summary, the Job/Userdata match-arm comment, S7commHeader field docs, and the Rosctr enum's Ack/Ack_Data variant docs still described the pre-ruling 10-byte-Ack_Data / Ack-only-error-fields shape; corrected to the 2026-09-24 canonical-frame holdout ruling (DF-CANONICAL-FRAME-HOLDOUT-001): Ack and Ack_Data both use the 12-byte header with error_class/ error_code. No behavior change; doc-only.
Update the [Unreleased] STORY-187 Added entry to reflect the 2026-09-24 canonical-frame holdout ruling (DF-CANONICAL-FRAME-HOLDOUT-001): Ack (0x02) AND Ack_Data (0x03) both use the 12-byte header with error_class/error_code; Job/Userdata remain the 10-byte common header. Also lists the new distinct "truncated Ack_Data" T0814 evidence reason alongside the existing "truncated Ack" reason.
… exercise the pcap (pass-3 F-25) mk_s7comm_pcap.py's s7comm_pdu() builder always emitted a 10-byte common header, so setup_communication_response and minimal_ack_data_pdu (both ROSCTR 0x03, Ack_Data) produced malformed frames under the 2026-09-24 canonical-frame holdout ruling (DF-CANONICAL-FRAME-HOLDOUT-001, BC-2.21.008): Ack (0x02) and Ack_Data (0x03) both require the 12-byte header (error_class/error_code at bytes 10/11, parameter block starting at byte 12); only Job (0x01)/Userdata (0x07) keep the 10-byte header. Fixes the builder to emit the correct 12-byte Ack_Data header, corrects the module docstring accordingly, and regenerates tests/fixtures/s7comm-setup-comm.pcap (verified byte-identical across two regenerations via sha256). Adds test_BC_2_21_002_setup_comm_fixture_pcap_well_formed_no_findings, which actually reads the committed pcap (via wirerust::reader::PcapSource::from_file plus a minimal stdlib-only Ethernet/IPv4/TCP walk), feeds each TCP payload through S7commAnalyzer::on_data in the correct direction, and asserts zero findings, session_established == true, and classified_protocol == Some(Classic) -- so the fixture is no longer generated-but-unread.
F-28: correct stale current/past-tense claims in mod story_187's doc comments -- "...once the implementation catches up" (implying pending work that is already done) becomes "...against the current implementation"; classic_header_bytes' doc wrongly listed Ack_Data among the 10-byte-header ROSCTRs, contradicting the 2026-09-24 canonical-frame holdout ruling (DF-CANONICAL-FRAME-HOLDOUT-001) that this same file documents elsewhere -- corrected to "Job/Userdata (10 bytes)". F-29: test_BC_2_21_008_truncated_ack_on_data_emits_t0814_once asserted only the "truncated Ack" substring, which is also a substring of "truncated Ack_Data header" -- so the assertion would not actually catch an implementation that mislabeled an Ack (0x02) failure with the Ack_Data reason text. Tightened both cases to assert the full "truncated Ack header" substring, matching classify_malformed_header_reason's exact output for data[1] == 0x02. Also (observation 4): test_BC_2_21_008_canonical_ack_data_setup_communication_response_on_data now feeds the canonical Ack_Data frame as Direction::ServerToClient (the actual response direction) instead of ClientToServer; classification and the findings assertion are direction-independent, so this does not change what the test proves.
…_malformed_header reasons F-28)
…ended (pass-4 F-36)
…tted as test vectors; VP-053 P0
…-field mapping (pass-6 F-47)
… (pass-6 F-46) Decision 9's canonical-frame reconciliation note grounded the Ack/Ack_Data 12-byte-header design decision in wire-capture sources (cnblogs, Yiqisoft, Inductive Automation KB), which the F-40 Decision 4 note restricts to TEST-VECTOR SOURCES ONLY. Re-ground the field-semantics claim in Decision 4's permitted prose sources and design references instead: Kleinmann & Wool 2014 (JDFSL 9(2), Fig. 2) for Ack_Data, and the permitted design references cisagov/icsnpp-s7comm and gimasi/python-snap7 for both Ack and Ack_Data plus the error_class/error_code byte split (kprovost/libs7comm noted as consistent), each cited by repo/file/struct or function name, "as reviewed 2026-09-24", with a reverse-engineered- implementation caveat. Recast cnblogs/Yiqisoft/Inductive Automation KB as the canonical-frame test vectors that surfaced and corroborate the defect, per the F-40 test-vector-only permission. Verified the F-40 Decision 4 "concrete usage" pointer (~452-453) still reads correctly against the rewritten note.
…abels) F-48: re-ground field-semantics provenance wording in tests/fixtures/mk_s7comm_pcap.py and tests/s7comm_analyzer_tests.rs to ADR-014 Decision 4's PROSE sources (Wireshark wiki, Kleinmann & Wool 2014, Orange-Cyberdefense catalog) *and* its PERMITTED DESIGN REFERENCES (cisagov/icsnpp-s7comm, kprovost/libs7comm, gijzelaerr/python-snap7) -- Kleinmann & Wool attests Ack_Data only; the Ack 12-byte layout and error_class/error_code byte split rest on icsnpp-s7comm + python-snap7. Wire-capture sources (cnblogs/Yiqisoft/Inductive Automation KB) are demoted to test-vector-only corroboration everywhere, and removed from the byte-level test-vector "Corroborating sources" list where they had been conflated with prose-source status. Regenerated the pcap fixture; sha256 unchanged (ff77cd60...78cd17d54). NIT: corrected VP-051 harness labels that called exact error-byte equality "BC-2.21.008 postcondition 3" -- exact values are postcondition 2, presence alone is postcondition 3. Also made the ROSCTR recognized-set citations explicit as BC-2.21.007 postcondition 1.
- N-1: relabel the VP-051 Kani harness slice-length assertion away from
BC-2.21.009 (the caller-side param_length/data_length bounds check) to
BC-2.21.004 postcondition 1 / BC-2.21.008 postcondition 1 -- the parser's
own length guards (>= 10 for Job/Userdata/unrecognized-ROSCTR, >= 12 for
Ack/Ack_Data) that this assertion actually exercises.
- N-3: qualify "Snap7" in mk_s7comm_pcap.py's provenance note as "Snap7 (the
C++ library)" to disambiguate it from the permitted gijzelaerr/python-snap7
binding named two lines earlier. Regenerated the fixture pcap; sha256
unchanged (ff77cd60969bda665bb0e728faeabb5bc54d497e9aa55649b5e32bf78cd17d54).
- Swept every BC-2.21.00{4,6,7,8,9} postcondition/PC label in mod story_187
and the vp051/vp053 modules against the BC files' actual postcondition
numbering; no further mislabels found beyond N-1.
…s, frame bounding, CR recency (pass-11/12) Adds 6 new tests and hardens 2 existing tests in tests/s7comm_analyzer_tests.rs mod story_187, all tests-only (src/ untouched): - BC-2.21.009 data_length coverage: prior tests only varied param_length with data_length always 0. New test_BC_2_21_009_data_length_overrun_on_data_emits_t0814 and test_BC_2_21_009_s7comm_bounds_ok_data_length_only_overrun isolate data_length's contribution to s7comm_bounds_ok's summed bound (kills dropping data_length from the sum). - BC-2.21.006 byte-asymmetric decode: test_BC_2_21_006_byte_asymmetric_big_endian_decode uses distinct hi/lo byte pairs per field to catch a from_be_bytes -> from_le_bytes mutation on pdu_reference/param_length/data_length. - BC-2.21.008 PC2: the Ack/Ack_Data 12-byte happy-path assertions now use distinct non-zero error_class/error_code (0x81/0x04) instead of Some(0)/ Some(0), catching a swapped-offset or hard-coded-Some(0) mutation. - Frame-bounding (BC-2.21.009/002): test_BC_2_21_009_dissection_bounded_to_own_tpkt_frame and test_BC_2_21_002_empty_dt_followed_by_frame_same_delivery_stays_unclassified verify dissection is bounded to the current TPKT frame's own payload, not leaking into a trailing frame in the same delivery (kills an unbounded tpkt_payload slice). - BC-2.21.001 CR recency: test_BC_2_21_001_most_recent_cr_direction_wins verifies cr_observed_dir reflects the MOST RECENT CR, not the first (kills a first-CR-wins mutation). - test_BC_2_21_001_cc_only_no_prior_cr_session_not_established now also asserts cr_observed_dir == None (EC-004). - proptest_vp053_protocol_id_dispatch_totality now asserts findings.is_empty() across every generated protocol_id, since the generator never produces a malformed classic frame. Each new/mutation-targeted assertion was verified to fail against its named mutation (applied to src/analyzer/s7comm.rs locally, then reverted -- src/ diff is empty). cargo test --test s7comm_analyzer_tests: 75 passed (was 69). cargo test --all-targets, clippy -D warnings, fmt --check, and bin/check-green-doc-tense all pass.
…go-mutants survivors at s7comm.rs:779)
…seable COTP, monotonic session, 9-byte boundary, Ack_Data EC-007, finding shape)
…le defensive branch (pass-13)
…-F-2/F-3, stale stub prose)
…mmary prefix (P23-F-1/F-2)
…g (P22-F-4, P24-F-1)
Zious11
left a comment
There was a problem hiding this comment.
Verdict: APPROVE (posted as COMMENT only because GitHub does not allow the PR author's account to approve its own PR; the reviewer's verdict is APPROVE, with 0 blocking findings)
PR Review — #475 (STORY-187): classic S7comm header parser + four-way protocol_id dispatch
Reviewer: pr-reviewer (fresh-eyes, diff + description + test evidence only)
Head reviewed: 31da9aff4a562ef269637d2808c0079f99c33919
Verdict: APPROVE — 0 blocking, 3 suggestion, 3 nit
What I checked
- Every changed file (26).
src/analyzer/s7comm.rsline by line;tests/s7comm_analyzer_tests.rs(the newstory_187module: helpers, the Kani harnesses, the VP-053 proptest, and the fixture-pcap test);CHANGELOG.md;docs/adr/0014-*.md;tests/fixtures/mk_s7comm_pcap.pyand the.pcapit generates;docs/demo-evidence/STORY-187/*. - Correctness of
parse_s7comm_header. The length check (< 10) runs before any index. Thedata[0]check runs before any other field read. Indices 4..9 are only read afterlen >= 10, and 10/11 only afterlen >= 12in the Ack/Ack_Data arm. There is no panic site. Unknown ROSCTR values hit_ => None, so nothing is forced to fit. s7comm_bounds_ok. It useschecked_addtwice.matches!returns false if either addition overflows. No slice is ever built from the declared lengths.- Dispatch. The slice
&tpkt_payload[header.payload_offset..]is sound.parse_cotp_headeronly returnsprotocol_id: Some(_)whentpkt_payload.len() > payload_offset(iso_on_tcp.rs:226-232). The CR/CC opposite-direction latch, the F-02 rule thatNonenever classifies, and the F-12 conjunction gate all match the PR description and AC-187-003/005/012. - Coherence. Every change traces to STORY-187: the analyzer, its tests, the fixture and its generator, the ADR-0014 reconciliation notes for this story's rulings, one CHANGELOG entry, and the demo evidence. No other analyzer, the dispatcher, or
iso_on_tcp.rsis touched. - AC coverage. I extracted all 52 test names the story spec cites for AC-187-001..013 and confirmed each exists as a
fnintests/s7comm_analyzer_tests.rs(comm -23gave an empty diff). Each AC has at least one named test. - CI. All 13 checks pass at this head: Test, Clippy, Format, CHANGELOG gate, Fuzz build, Deny, Audit, Action-pin, Semantic PR, Green-doc-tense, Help-provenance, Trust-boundary, and Bin selftest. The branch is mergeable (CLEAN).
- Dependencies. The branch is based on
develop47951b7a, which includes STORY-186 (#470) and its follow-up (#473). - Demo evidence.
evidence-report.mdis present, with one.gif+.webm+.tapefor each AC group (001-003, 004/005/012, 006-009, 010/011, 013) and an all-green run. No absolute paths or usernames leak into.md,.tape, or the fixture generator.
PG-W74-PRDESC-ROW-VERIFY: row and count verification
Rows checked against tests/s7comm_analyzer_tests.rs (7 checked, at least 3 required). All match:
| PR-body entry | Claimed line | Actual (grep -n "fn <name>") |
Match |
|---|---|---|---|
test_BC_2_21_001_flow_state_field_set |
1632 | 1632 | YES |
test_BC_2_21_002_classic_s7comm_dispatch_asserts_classified_protocol_classic |
2092 | 2092 | YES |
test_BC_2_21_008_canonical_ack_data_setup_communication_response_on_data |
2731 | 2731 | YES |
test_BC_2_21_009_bounds_check_before_parameter_data_slice |
3663 | 3663 | YES |
verify_parse_s7comm_header_bounds_safety (Kani) |
4303 | 4303 | YES |
verify_s7comm_bounds_ok_bounds_safety (Kani) |
4473 | 4473 | YES |
proptest_vp053_protocol_id_dispatch_totality |
exists | 4608 | YES |
Aggregate counts, re-run locally at 31da9aff:
| Claim | Command | Result | Match |
|---|---|---|---|
s7comm_analyzer_tests 83/83 |
cargo test --test s7comm_analyzer_tests |
83 passed; 0 failed |
YES |
story_187 module has 63 tests |
cargo test --test s7comm_analyzer_tests story_187 |
63 passed; 20 filtered out |
YES |
Rollback leaves the 20-test story_186 module |
... story_186 |
20 passed; 63 filtered out |
YES |
| Full suite 2,803/2,803 | cargo test --all-targets, summed over all test result: lines (96 binaries) |
passed=2803 failed=0 |
YES |
| Mutation 43 = 40 killed + 1 equivalent + 2 unviable | arithmetic only; not re-run | 40+1+2 = 43 | consistent |
| Kani VP-051 both harnesses successful | not re-run (the harnesses are #[cfg(kani)] and not part of CI) |
— | not independently reproduced |
Findings
| # | Severity | Category | Location | Finding | Suggestion |
|---|---|---|---|---|---|
| 1 | suggestion | coherence | src/analyzer/s7comm.rs:802-831, 876 |
Internal spec and policy IDs leak into the user-facing Finding.evidence, and the summary cites them twice. Each malformed-header reason ends with (BC-2.21.004), (BC-2.21.007), (BC-2.21.008, DF-CANONICAL-FRAME-HOLDOUT-001), or (BC-2.21.009). That string goes into evidence, and summary then wraps it and adds (T0814; BC-2.21.004/007/008/009). A rendered summary therefore reads ...10 required (BC-2.21.004) (T0814; BC-2.21.004/007/008/009). The other analyzers (the STORY-186 carry-overflow finding here, IEC-104, DNP3) put BC IDs only in summary and keep evidence to wire facts. A process-policy ID like DF-CANONICAL-FRAME-HOLDOUT-001 means nothing to an analyst reading a report. |
Drop the (BC-…) / DF-… suffixes from the reason strings, so evidence holds only facts (byte counts and the ROSCTR value), and keep the single BC citation in summary. The existing tests match substrings like "header too short: 9" and "unrecognized ROSCTR byte 0x00", so this should need little or no test churn. Fine as a follow-up (for example STORY-188, which touches this path next). |
| 2 | suggestion | coherence | src/analyzer/s7comm.rs:671 |
&tpkt_payload[header.payload_offset..] depends on an invariant of another module (SS-20 parse_cotp_header, VP-049), and nothing in this function re-checks it locally. It is correct today; the security review's LOW-01 flags the same point. The cost of a local guard is near zero. |
Optional: if let Some(payload) = tpkt_payload.get(header.payload_offset..) { … }. This makes the analyzer panic-free by construction if SS-20's contract changes. Accept as-is if you would rather avoid an unreachable or equivalent-mutant branch; the security review already documents the coupling. |
| 3 | suggestion | size | whole PR | +5,752 / -34 lines, well above the 500-line guideline. Production code is modest (s7comm.rs +576/-24). Most of the size is tests (+3,934), the fixture generator (+445), the ADR (+174), and the demo evidence report (+218). |
Non-blocking. The size comes from test and evidence depth, not scope creep, and splitting it now would not help review. Keep future SS-21 stories near this production-code footprint. |
| 4 | nit | description | PR body, Traceability table, row "BC-2.21.004/005/007" | The row lists test_BC_2_21_004_* and test_BC_2_21_007_* but leaves out BC-2.21.005's own test, test_BC_2_21_005_defensive_reject_wrong_protocol_id_byte. It also lists "Kani VP-051" as verification for the whole row, but the story's VP-051 source-BC set is {004, 006, 007, 008, 009}, which excludes 005. |
Add test_BC_2_21_005_* to the row, and scope the VP-051 cell to 004/007 (or note that 005 is covered by unit test only). |
| 5 | nit | coverage | tests/s7comm_analyzer_tests.rs:4503 |
In verify_s7comm_bounds_ok_bounds_safety, the second assertion (vec![0u8; data_len].get(header_len..end).is_some()) mostly re-proves Vec::get semantics once the exact-equality assertion above it holds. No production code slices the parameter/data blocks yet. It is fine as a skeleton, but it does not tie the proof to a real call site. |
When STORY-188 adds the actual parameter/data slicing, point this half of the harness at that call site (or a pure helper it uses), so VP-051 covers the slice that production code builds. |
| 6 | nit | coherence | commit history | Several commits use a wip(STORY-187): … prefix, and wip is not one of the repo's allowed conventional types. The Semantic-PR gate checks only the PR title, which is compliant. |
Squash-merge this PR, which the history suggests is the normal practice, so develop gets a single conventional commit. No action needed if squashing. |
Checklist summary
| # | Item | Result |
|---|---|---|
| 1 | Diff coherence | PASS. All changes trace to STORY-187; no unrelated files. |
| 2 | Description accuracy | PASS with nit #4. Counts, line numbers, the file list, the dependency status, and the scope of the 0x72/unclassified placeholders all match the diff. |
| 3 | Test coverage | PASS. All 52 spec-named tests exist; 63 new tests; the local mutation and Kani evidence are consistent. |
| 4 | Demo evidence | PASS. evidence-report.md is present, with a .gif and .webm per AC group, recorded against the test harness (library surface, no CLI yet). |
| 5 | Commit quality | PASS with nit #6. |
| 6 | Diff size | FLAGGED (suggestion #3). Justified by test and evidence volume. |
| 7 | Missing changes | None found. The S7commFlowState fields, S7Protocol, Rosctr, S7commHeader, parse_s7comm_header, the pub fn s7comm_bounds_ok, the dispatch, the T0814 dedup, the VP-051 and VP-053 skeletons, and the fixture with its generator are all present. |
| 8 | Dependency status | PASS. STORY-186 (#470, #473) is merged to develop. |
Verdict: APPROVE. No blocking findings. The suggestions and nits can be deferred to follow-up work, which is where they belong given the post-convergence state. Per standing arrangement PG-MERGE-CLASSIFIER-F4, the human carries out the merge.
| verdict: Verdict::Possible, | ||
| confidence: Confidence::Medium, | ||
| summary: format!( | ||
| "Malformed classic S7comm header: {reason} (T0814; \ |
There was a problem hiding this comment.
[SUGGESTION] (coherence) Internal spec IDs end up in user-facing output, and the summary cites them twice.
reason already ends in (BC-2.21.00x), and sometimes DF-CANONICAL-FRAME-HOLDOUT-001 as well. That string is pushed verbatim into evidence, then wrapped here with a second (T0814; BC-2.21.004/007/008/009). A rendered summary therefore reads like Malformed classic S7comm header: header too short: 9 byte(s) available, 10 required (BC-2.21.004) (T0814; BC-2.21.004/007/008/009).
The other analyzers (the STORY-186 carry-overflow finding in this file, IEC-104, DNP3) keep BC IDs in summary only and keep evidence to wire facts. A policy ID like DF-CANONICAL-FRAME-HOLDOUT-001 means nothing to an analyst reading a report.
Suggestion: remove the parenthesised BC/DF suffixes from the reason strings in classify_malformed_header_reason and the bounds-failure format!, and keep the single citation here. The existing tests assert substrings ("header too short: 9", "unrecognized ROSCTR byte 0x00", "exceed available bytes"), so the churn should be minimal. Non-blocking; fine as a STORY-188 follow-up.
| // frame (ADR-014 Decision 2 no-misattribution guarantee, | ||
| // F-12 ruling, human-ratified 2026-09-24). | ||
| if state.classified_protocol == Some(S7Protocol::Classic) { | ||
| let payload = &tpkt_payload[header.payload_offset..]; |
There was a problem hiding this comment.
[SUGGESTION] (coherence) This slice is sound today. parse_cotp_header only returns protocol_id: Some(_) when tpkt_payload.len() > payload_offset (iso_on_tcp.rs:226-232). But panic-freedom here now depends on another module's contract (SS-20 / VP-049), and nothing re-checks it locally. This is the same coupling the security review flagged as LOW-01.
Optional hardening at near-zero cost:
if state.classified_protocol == Some(S7Protocol::Classic)
&& let Some(payload) = tpkt_payload.get(header.payload_offset..)
{
Self::dispatch_classic_s7comm(state, payload, direction, ts, findings);
}Accepting as-is is reasonable if you want to avoid an unreachable branch (and the equivalent mutant it would produce). Non-blocking.
| // A symbolic buffer of length data_len -- the hypothetical full | ||
| // delivery. When the bounds check passes, the parameter/data | ||
| // sub-slice access must always succeed. | ||
| let full: Vec<u8> = vec![0u8; data_len]; |
There was a problem hiding this comment.
[NIT] (coverage) Once the exact-equality assert_eq! above holds, this vec![0u8; data_len].get(header_len..end).is_some() assertion mostly re-proves Vec::get semantics. No production code slices the parameter/data blocks yet, so this half of the proof is not tied to any real call site. That is fine for a skeleton. When STORY-188 adds the actual parameter/data slicing, please point this half of the harness at that call site (or at a pure helper it uses), so VP-051 covers the slice production code actually builds.
commented
Sep 25, 2026
Review Cycle 1 Triage (pr-manager)pr-reviewer verdict: APPROVE (0 blocking findings). Formal
Convergence: CONVERGED after 1 cycle — 0 blocking findings, 3 suggestions + 3 nits accepted as non-blocking residuals (full detail: Security review (separate pass): APPROVE, 0 critical/high findings (2 LOW/2 INFO, non-blocking) — see PR description Security Review section. |
[STORY-187] S7comm Flow State Completion, Four-Way protocol_id Dispatch Skeleton, and parse_s7comm_header Pure-Core Parser
Epic: E-23 — S7comm ISO-on-TCP protocol coverage
Mode: feature
Convergence: CONVERGED after 24 adversarial passes (closing trio P22 LOW/NIT -> P23 LOW fixed + NITs -> P24 NITPICK_ONLY); closed by human ruling 2026-09-25
Completes
S7commFlowStatewith classification state (session_established,cr_observed_dir,classified_protocol, malformed-header dedup flags), extendsS7commAnalyzer::on_datawith a four-way dispatch onCotpHeader::protocol_id(classic
0x32fully wired; S7comm-plus0x72and unclassified branches arestructural placeholders completed in STORY-190), and adds a bounds-safe
pure-core parser,
parse_s7comm_header, for the classic S7comm common header(ROSCTR, PDU reference, parameter/data length, and the Ack/Ack_Data 12-byte
error-class/error-code extension per the 2026-09-24 canonical-frame holdout
ruling). This is the foundation for function-code classification (STORY-188/189)
and MITRE technique emission (STORY-191/192).
Architecture Changes
graph TD TPKT["iso_on_tcp::parse_tpkt_header (STORY-184)"] -->|calls| COTP["iso_on_tcp::parse_cotp_header (STORY-185)"] COTP -->|CotpHeader.protocol_id| Dispatch["S7commAnalyzer::on_data four-way dispatch (STORY-187)"] Dispatch -->|Some(0x32) + sticky Classic| ParseHeader["parse_s7comm_header (new, STORY-187)"] Dispatch -.->|Some(0x72) placeholder| Plus["S7comm-plus branch (STORY-190)"] Dispatch -.->|other/None placeholder| Unclassified["Unclassified branch (STORY-190)"] ParseHeader -->|bounds check| BoundsOk["s7comm_bounds_ok (new, pub fn, STORY-187)"] ParseHeader -->|malformed| Finding["T0814 Finding (dedup per direction)"] style ParseHeader fill:#90EE90 style Dispatch fill:#90EE90 style BoundsOk fill:#90EE90Architecture Decision Record
ADR: Classic S7comm header parsing gated on sticky per-flow classification (ADR-014 Decisions 2/9)
Context:
S7commAnalyzer::on_datamust dispatch DT frames to protocol-specificdissection based on the COTP-extracted
protocol_idbyte, without evermisattributing bytes from one S7comm variant (Classic vs Plus) to the other's
parser once a flow has been observed.
Decision: Classification is sticky and first-write-wins per flow
(
classified_protocol: Option<S7Protocol>), set by the first DT frame carryingprotocol_id: Some(byte). Classic dissection (parse_s7comm_header) fires onlywhen BOTH the current frame's
protocol_id == Some(0x32)AND the flow's stickyclassified_protocol == Some(Classic)— a conjunction, not either conditionalone.
Rationale: Prevents a later
0x32-leading frame on an alreadyPlus/Unclassified-sticky flow from being dissected as Classic (no-misattribution
guarantee, ADR-014 Decision 2), and prevents a Classic-sticky flow from
dissecting a later non-
0x32frame.Alternatives Considered:
protocol_idalone — rejected:permits misattribution if a flow's variant changes mid-stream (which never
legitimately happens, but a spoofed/malformed stream could exploit this).
0x02) and Ack_Data (0x03) with distinct header lengths (10-byteAck_Data, matching the original v1.0/v1.1 assumption) — rejected 2026-09-24
after the canonical-frame holdout (DF-CANONICAL-FRAME-HOLDOUT-001) showed a
real-world Ack_Data/Setup-Communication-response parameter block only aligns
at byte 12, not byte 10; both Ack and Ack_Data now require the same 12-byte
header shape.
Consequences:
0x72(S7comm-plus) and unrecognized/Nonebranches remain structuralno-ops until STORY-190 — this PR does not implement their observable
behavior, only compiles a total four-way match.
Story Dependencies
graph LR S186["STORY-186<br/>MERGED (#470, #473)"] --> S187["STORY-187<br/>this PR"] S187 --> S188["STORY-188<br/>not yet started"] style S187 fill:#FFD700STORY-186 (S7comm ISO-on-TCP carry-buffer reassembly) is merged to
develop(PR #470, plus follow-up fix PR #473) — this branch is based on
develop47951b7a, which includes both. STORY-187 blocks STORY-188 (function-codeclassification), not yet started.
Spec Traceability
flowchart LR BC1[BC-2.21.001<br/>S7commFlowState fields] --> AC1[AC-187-001..003] BC2[BC-2.21.002<br/>four-way dispatch] --> AC2[AC-187-004/005/012] BC3[BC-2.21.004..007<br/>header length/ROSCTR] --> AC3[AC-187-006/007/008/009] BC4[BC-2.21.008<br/>Ack/Ack_Data 12-byte] --> AC4[AC-187-010] BC5[BC-2.21.009<br/>bounds check] --> AC5[AC-187-011] BC6[canonical-frame holdout] --> AC6[AC-187-013] AC1 --> T1[test_BC_2_21_001_flow_state_field_set] AC2 --> T2[test_BC_2_21_002_classic_s7comm_dispatch_asserts_classified_protocol_classic] AC4 --> T3[test_BC_2_21_008_canonical_ack_data_setup_communication_response_on_data] AC5 --> T4[test_BC_2_21_009_bounds_check_before_parameter_data_slice] T1 --> S1[src/analyzer/s7comm.rs] T2 --> S1 T3 --> S1 T4 --> S1Behavioral contracts: BC-2.21.001, BC-2.21.002, BC-2.21.004, BC-2.21.005,
BC-2.21.006, BC-2.21.007, BC-2.21.008, BC-2.21.009. Verification properties:
VP-051 (bounds-safety, Kani), VP-053 (dispatch totality, proptest).
Test Evidence
Coverage Summary
s7comm_analyzer_tests(story_186 + story_187)Test Flow
graph LR Unit["83 story_186+story_187 Unit/Property Tests"] Full["2803 Full-Suite Tests"] Mutation["43 Mutants (cargo-mutants 27.1.0)"] Kani["Kani VP-051 (2 harnesses)"] Unit -->|83/83| Pass1["PASS"] Full -->|2803/2803| Pass2["PASS"] Mutation -->|40 killed / 1 equivalent / 2 unviable| Pass3["PASS"] Kani -->|0/264 + 0/306 checks failed| Pass4["PASS"] style Pass1 fill:#90EE90 style Pass2 fill:#90EE90 style Pass3 fill:#90EE90 style Pass4 fill:#90EE90story_187module: 63 tests (AC-187-001..013 traceability, see evidence-report.md AC-coverage table)cargo test --all-targetsoutput this sessions7comm_analyzer_testsbinaryDetailed Test Results
Row-Verified Test Entries (PG-W74-PRDESC-ROW-VERIFY)
Per
.factory/maintenance/pr-description-row-verify-mandate.md, at least 3entries were row-verified against
tests/s7comm_analyzer_tests.rsthissession (grep-confirmed function existence at the cited line):
test_BC_2_21_001_flow_state_field_settests/s7comm_analyzer_tests.rs:1632test_BC_2_21_002_classic_s7comm_dispatch_asserts_classified_protocol_classictests/s7comm_analyzer_tests.rs:2092test_BC_2_21_008_canonical_ack_data_setup_communication_response_on_datatests/s7comm_analyzer_tests.rs:2731test_BC_2_21_009_bounds_check_before_parameter_data_slicetests/s7comm_analyzer_tests.rs:3663Aggregate-count cross-check: the claimed "2,803 passed / 0 failed" and
"83/83" figures were reproduced by running
cargo test --all-targetsandcargo test --test s7comm_analyzer_testslocally against this PR's HEAD(
31da9aff) this session — both match exactly (2,803 summed across alltest result: oklines; 83/83 for the s7comm binary).clippy --all-targets -D warnings,cargo fmt --check,python3 bin/check-green-doc-tense, andthe changelog-gate check (
git diff origin/develop...HEAD -- CHANGELOG.md | bin/changelog-gate-check) were also reproduced locally and confirmed clean.Mutation Testing
src/analyzer/s7comm.rs(STORY-187 diff)6705ed8b)Some(0x72)placeholder arm, deferred to STORY-190)Demo Evidence
docs/demo-evidence/STORY-187/evidence-report.md(committed on this branch).Recording tool: VHS 0.11.0, terminal recordings of
cargo test --test s7comm_analyzer_testsfiltered per behavior group. This is a pure-coreparser/effectful-shell library (no CLI subcommand or web UI surface for
S7comm yet — dispatcher wiring deferred to STORY-193), so the demonstration
vehicle is the story's own test harness,
tests/s7comm_analyzer_tests.rs mod story_187(63 tests).AC-ALL-story_187-green.gif/.webmstory_187module — 63/63 greenAC-001-003-flow-state-session.gif/.webmAC-004-005-012-dispatch-classification.gif/.webmAC-006-009-length-rosctr-parser.gif/.webmAC-010-011-ack-bounds-check.gif/.webmAC-013-canonical-frames.gif/.webmAll 13 acceptance criteria (AC-187-001..013) are covered by at least one
recorded artifact — see the full AC-to-test-to-artifact coverage map in
docs/demo-evidence/STORY-187/evidence-report.md.Holdout Evaluation
N/A — evaluated at wave gate (per-story evaluation not run for this F4 feature-mode story; convergence gate below substitutes for per-story holdout evaluation).
Adversarial Review
6705ed8b); NITs acceptedConvergence: 24 passes total; closing trio P22 (LOW/NIT) -> P23 (LOW fixed + NITs) -> P24 (NITPICK_ONLY). Closed by HUMAN RULING (2026-09-25): convergence accepted with residuals after the P23 fix batch, satisfying BC-5.39.001's 3-clean-pass criterion via this trio's disposition rather than three literally-zero-finding passes.
Key Human Rulings (2026-09-24/25)
session_establishedis set onlyby a CC observed opposite-direction from a prior CR on the same flow.
None-protocol_idDT frame never classifies; classificationremains deferred to a later
Some(byte)DT frame.classified_protocol, never on the current frame's rawprotocol_idbytealone.
here).
VERIFICATION SUCCESSFUL; full non-vacuous CI-wired run deferred to
STORY-194 per existing SS-20/21 pattern).
(2026-09-24): both ROSCTR variants share the same 12-byte header shape,
correcting the original 10-byte Ack_Data assumption.
permitted as test-vector sources only (not implementation-derivation
sources), satisfying DF-CANONICAL-FRAME-HOLDOUT-001 for AC-187-013's
canonical Job/Ack_Data frame pair.
Full pass-by-pass record:
.factory/cycles/feature-s7comm/STORY-187/convergence-report.mdand
adversary-convergence-state.json.Security Review
Verdict: APPROVE. No CRITICAL or HIGH findings. No panic, out-of-bounds
slice, or integer-overflow path reachable from adversarial network input.
2 LOW / 2 INFO observations, all non-blocking.
graph LR Critical["Critical: 0"] High["High: 0"] Medium["Medium: 0"] Low["Low: 2"] style Critical fill:#90EE90 style High fill:#90EE90 style Medium fill:#90EE90 style Low fill:#87CEEBSecurity Scan Details
Manual Security Review (parser of untrusted network input)
Scope:
parse_s7comm_header,s7comm_bounds_ok, the new four-way COTP-framedispatch,
dispatch_classic_s7comm,classify_first_dt_frame,classify_malformed_header_reason,report_malformed_header, and bothVP-051 Kani harnesses, diffed against
origin/develop.Both harnesses use bona fide fully-symbolic input (
[u8;16]withlen0..=16 for header extraction — a complete equivalence-class cover of the
real input space; independently-symbolic
data_lenup tou16::MAX*3forthe bounds-check harness, proven for exact two-directional equality).
kani::cover!non-vacuity checks present for bothNone/Someand bothbounds outcomes. VERIFICATION SUCCESSFUL is credible for the code as
written.
dispatch_cotp_frame'sSome(0x32)armslices
tpkt_payload[header.payload_offset..]without a local boundscheck; safety today depends on an upstream SS-20 (
parse_cotp_header)invariant (VP-049), not re-verified locally. Architecturally acceptable
per ADR-014 Decision 9's frozen SS-20/SS-21 boundary; flagged as a
cross-module coupling point for future changes to watch.
debug_assert_eq!self-check in
dispatch_classic_s7commcompiles out in release builds;not a real gap because
parse_s7comm_header's own always-ondata[0] != 0x32check (BC-2.21.005) is the actual defensive re-check.findings-Vec growth pattern(from STORY-186) extends to this PR's T0814 malformed-header findings,
deduplicated per flow-direction; not a regression introduced here, any
fix belongs at the flow-table/dispatcher level, out of scope.
Arithmetic/bounds-safety walkthrough confirmed:
parse_s7comm_headerislength-gated (10/12 bytes) before any indexing (CWE-125/CWE-20 mitigated);
s7comm_bounds_okuseschecked_addtwice, so no CWE-190 integer-overflowpath exists; malformed/oversized-declared-length paths route only to
report_malformed_header, never to slice construction (consumingparam_length/data_lengthto slice the payload is explicitly out ofthis story's scope).
Formal Verification
parse_s7comm_headernever panics or over-reads regardless of input bytesverify_parse_s7comm_header_bounds_safety,tests/s7comm_analyzer_tests.rs:4303)s7comm_bounds_okbounds-check helper is panic-free and overflow-free for all input combinationsverify_s7comm_bounds_ok_bounds_safety,tests/s7comm_analyzer_tests.rs:4473)protocol_iddispatch is total over allOption<u8>valuesproptest_vp053_protocol_id_dispatch_totality)cargo test)Risk Assessment & Deployment
Blast Radius
src/analyzer/s7comm.rsonly (new module logic; nochanges to other analyzers or the dispatch framework). New test fixture
tests/fixtures/s7comm-setup-comm.pcap(synthetic, generator-produced) andits generator
tests/fixtures/mk_s7comm_pcap.py.on port 102 could, in the worst case, fail to classify a flow or fail to
emit a T0814 finding it should have — bounded by the Kani-proven no-panic/
no-overread guarantee, so failure modes are limited to missed detections,
not crashes or memory-safety violations.
beyond in-memory per-flow tracking.
COTP/TPKT framing (STORY-184/185/186, already merged and in production
behavior on
develop); the0x72/unclassified branches are inert no-ops.Performance Impact
Not benchmarked separately for this PR;
parse_s7comm_headerands7comm_bounds_okare O(1) fixed-offset byte reads/comparisons with noallocation, consistent with the existing SS-20/21 pure-core parser design.
Rollback Instructions
Immediate rollback (< 5 min):
No feature flag — this is additive dispatch logic gated entirely on wire
bytes (
protocol_id), not a runtime toggle.Verification after rollback:
cargo test --test s7comm_analyzer_testsreverts to STORY-186's 20-teststory_186module only.Traceability
test_BC_2_21_001_flow_state_field_setet al.test_BC_2_21_002_classic_s7comm_dispatch_asserts_classified_protocol_classicet al.test_BC_2_21_004_*,test_BC_2_21_007_*test_BC_2_21_006_common_header_field_extractiontest_BC_2_21_008_canonical_ack_data_setup_communication_response_on_datatest_BC_2_21_009_bounds_check_before_parameter_data_slicetest_BC_2_21_006_canonical_setup_communication_job_frame_on_data,test_BC_2_21_008_canonical_ack_data_setup_communication_response_on_data,test_BC_2_21_002_setup_comm_fixture_pcap_well_formed_no_findingsFull AC-to-test-to-artifact traceability:
docs/demo-evidence/STORY-187/evidence-report.md.AI Pipeline Metadata
Pipeline Details
Pre-Merge Checklist
git revert, no feature flag)developbefore this PR opened