diff --git a/.github/workflows/security-evidence.yml b/.github/workflows/security-evidence.yml new file mode 100644 index 000000000..7c376364d --- /dev/null +++ b/.github/workflows/security-evidence.yml @@ -0,0 +1,75 @@ +name: Security and Evidence + +on: + push: + branches: [main, "agent/**"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + sanitizers: + name: Clang / ASan + UBSan + runs-on: ubuntu-latest + env: + CC: clang + CXX: clang++ + ASAN_OPTIONS: detect_leaks=1:halt_on_error=1:strict_string_checks=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + steps: + - uses: actions/checkout@v4 + - name: Configure + run: >- + cmake -S . -B build-sanitizers + -DCMAKE_BUILD_TYPE=RelWithDebInfo + -DARIEC61850_ENABLE_SANITIZERS=ON + -DARIEC61850_WARNINGS_AS_ERRORS=ON + - name: Build + run: cmake --build build-sanitizers --parallel + - name: Test + run: ctest --test-dir build-sanitizers --output-on-failure + + fuzz-smoke: + name: Clang / libFuzzer corpus smoke + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CC: clang + CXX: clang++ + ASAN_OPTIONS: detect_leaks=1:halt_on_error=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + steps: + - uses: actions/checkout@v4 + - name: Configure fuzzers + run: >- + cmake -S . -B build-fuzz + -DCMAKE_BUILD_TYPE=RelWithDebInfo + -DARIEC61850_BUILD_TESTS=OFF + -DARIEC61850_BUILD_TOOLS=OFF + -DARIEC61850_BUILD_FUZZERS=ON + -DARIEC61850_ENABLE_SANITIZERS=ON + -DARIEC61850_WARNINGS_AS_ERRORS=ON + - name: Build fuzzers + run: cmake --build build-fuzz --parallel + - name: Run BER corpus + run: ./build-fuzz/ariec61850_fuzz_ber fuzz/corpus/ber -runs=1000 -timeout=5 + - name: Run GOOSE corpus + run: ./build-fuzz/ariec61850_fuzz_goose fuzz/corpus/goose -runs=1000 -timeout=5 + - name: Run Sampled Values corpus + run: ./build-fuzz/ariec61850_fuzz_sampled_values fuzz/corpus/sampled_values -runs=1000 -timeout=5 + - name: Run PCAP corpus + run: ./build-fuzz/ariec61850_fuzz_pcap fuzz/corpus/pcap -runs=1000 -timeout=5 + - name: Upload crash evidence + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-crash-evidence + if-no-files-found: ignore + path: | + crash-* + leak-* + timeout-* + oom-* + slow-unit-* diff --git a/.gitignore b/.gitignore index 29b777bd5..b879cb8f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ build/ +build-*/ cmake-build-*/ CMakeFiles/ CMakeCache.txt @@ -8,3 +9,10 @@ Testing/ *.vcxproj *.vcxproj.filters *.vcxproj.user +*.profraw +crash-* +leak-* +timeout-* +oom-* +slow-unit-* +*.interop.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 0bf704e30..5e843617a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,11 +1,14 @@ cmake_minimum_required(VERSION 3.20) project(ARIEC61850Cpp - VERSION 0.5.0 + VERSION 0.6.0 DESCRIPTION "Incremental C++20 port of the ARIEC61850 protocol stack" LANGUAGES CXX) option(ARIEC61850_BUILD_TESTS "Build ARIEC61850 C++ regression tests" ON) +option(ARIEC61850_BUILD_TOOLS "Build read-only evidence tools" ON) +option(ARIEC61850_BUILD_FUZZERS "Build LLVM libFuzzer targets" OFF) +option(ARIEC61850_ENABLE_SANITIZERS "Enable AddressSanitizer and UndefinedBehaviorSanitizer" OFF) option(ARIEC61850_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" ON) add_library(ariec61850_core @@ -25,7 +28,8 @@ add_library(ariec61850_core src/sampled_values/quality.cpp src/sampled_values/payload_inspector.cpp src/sampled_values/sample_counter.cpp - src/sampled_values/stream_supervisor.cpp) + src/sampled_values/stream_supervisor.cpp + src/evidence/pcap_equivalence.cpp) add_library(ARIEC61850::core ALIAS ariec61850_core) @@ -42,14 +46,37 @@ function(ariec61850_apply_warnings target) target_compile_options(${target} PRIVATE /WX) endif() else() - target_compile_options(${target} PRIVATE -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion) + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion) if(ARIEC61850_WARNINGS_AS_ERRORS) target_compile_options(${target} PRIVATE -Werror) endif() endif() endfunction() +function(ariec61850_apply_sanitizers target) + if(NOT ARIEC61850_ENABLE_SANITIZERS) + return() + endif() + if(MSVC) + message(FATAL_ERROR "ARIEC61850_ENABLE_SANITIZERS currently requires GCC or Clang.") + endif() + target_compile_options(${target} PRIVATE + -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(${target} PRIVATE + -fsanitize=address,undefined -fno-omit-frame-pointer) +endfunction() + ariec61850_apply_warnings(ariec61850_core) +ariec61850_apply_sanitizers(ariec61850_core) + +if(ARIEC61850_BUILD_TOOLS) + add_executable(ariec61850_pcap_interop_check tools/pcap_interop_check.cpp) + target_link_libraries(ariec61850_pcap_interop_check PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_pcap_interop_check PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_pcap_interop_check) + ariec61850_apply_sanitizers(ariec61850_pcap_interop_check) +endif() if(ARIEC61850_BUILD_TESTS) include(CTest) @@ -59,6 +86,7 @@ if(ARIEC61850_BUILD_TESTS) target_link_libraries(ariec61850_core_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_core_tests PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_core_tests) + ariec61850_apply_sanitizers(ariec61850_core_tests) add_test(NAME ariec61850_core_tests COMMAND ariec61850_core_tests) foreach(test_name IN ITEMS mms goose sampled_values) @@ -66,6 +94,7 @@ if(ARIEC61850_BUILD_TESTS) target_link_libraries(ariec61850_${test_name}_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_${test_name}_tests PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_${test_name}_tests) + ariec61850_apply_sanitizers(ariec61850_${test_name}_tests) add_test(NAME ariec61850_${test_name}_tests COMMAND ariec61850_${test_name}_tests) endforeach() @@ -73,5 +102,57 @@ if(ARIEC61850_BUILD_TESTS) target_link_libraries(ariec61850_goose_runtime_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_goose_runtime_tests PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_goose_runtime_tests) + ariec61850_apply_sanitizers(ariec61850_goose_runtime_tests) add_test(NAME ariec61850_goose_runtime_tests COMMAND ariec61850_goose_runtime_tests) + + add_executable(ariec61850_evidence_tests tests/test_evidence.cpp) + target_link_libraries(ariec61850_evidence_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_evidence_tests PRIVATE cxx_std_20) + target_compile_definitions(ariec61850_evidence_tests PRIVATE + ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + ariec61850_apply_warnings(ariec61850_evidence_tests) + ariec61850_apply_sanitizers(ariec61850_evidence_tests) + add_test(NAME ariec61850_evidence_tests COMMAND ariec61850_evidence_tests) + + add_executable(ariec61850_fuzz_smoke_tests tests/test_fuzz_smoke.cpp) + target_link_libraries(ariec61850_fuzz_smoke_tests PRIVATE ARIEC61850::core) + target_include_directories(ariec61850_fuzz_smoke_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/fuzz) + target_compile_definitions(ariec61850_fuzz_smoke_tests PRIVATE + ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + target_compile_features(ariec61850_fuzz_smoke_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_fuzz_smoke_tests) + ariec61850_apply_sanitizers(ariec61850_fuzz_smoke_tests) + add_test(NAME ariec61850_fuzz_smoke_tests COMMAND ariec61850_fuzz_smoke_tests) + + if(ARIEC61850_BUILD_TOOLS) + add_test( + NAME ariec61850_pcap_interop_smoke + COMMAND ariec61850_pcap_interop_check + ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/process_bus_csharp_vectors.pcap + --json) + endif() +endif() + +if(ARIEC61850_BUILD_FUZZERS) + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") + message(FATAL_ERROR "ARIEC61850_BUILD_FUZZERS requires Clang/libFuzzer.") + endif() + + function(ariec61850_add_fuzzer name source) + add_executable(${name} ${source}) + target_link_libraries(${name} PRIVATE ARIEC61850::core) + target_include_directories(${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/fuzz) + target_compile_features(${name} PRIVATE cxx_std_20) + target_compile_options(${name} PRIVATE + -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer) + target_link_options(${name} PRIVATE + -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer) + ariec61850_apply_warnings(${name}) + endfunction() + + ariec61850_add_fuzzer(ariec61850_fuzz_ber fuzz/fuzz_ber.cpp) + ariec61850_add_fuzzer(ariec61850_fuzz_goose fuzz/fuzz_goose.cpp) + ariec61850_add_fuzzer(ariec61850_fuzz_sampled_values fuzz/fuzz_sampled_values.cpp) + ariec61850_add_fuzzer(ariec61850_fuzz_pcap fuzz/fuzz_pcap.cpp) endif() diff --git a/LAB_INTEROP_CHECKLIST.md b/LAB_INTEROP_CHECKLIST.md new file mode 100644 index 000000000..7c7d6cfd9 --- /dev/null +++ b/LAB_INTEROP_CHECKLIST.md @@ -0,0 +1,70 @@ +# Isolated-Laboratory Interoperability Checklist + +This checklist is intentionally receive-first and read-only. The Phase 1E tooling does +not open Npcap, transmit Ethernet frames, or operate an IED. + +## 1. Safety boundary + +- [ ] Use an isolated laboratory switch or an isolated VLAN with no route to a production substation LAN. +- [ ] Confirm the engineering laptop has no bridge, Internet sharing, or second adapter forwarding traffic. +- [ ] Begin with a mirror/SPAN port or passive TAP; do not connect an active publisher output first. +- [ ] Record IED manufacturer, model, firmware, configuration revision, MAC addresses, APPIDs, VLAN IDs, and sampling mode. +- [ ] Synchronize clocks only through the laboratory timing source approved for the test. + +## 2. Receive-only capture + +- [ ] Capture at least one stable GOOSE state and one GOOSE state transition. +- [ ] Capture Sampled Values across at least two counter wraps or ten seconds, whichever is longer. +- [ ] Preserve the original PCAP; do not edit it in Wireshark before hashing. +- [ ] Calculate SHA-256 and record the capture hash in the report template. +- [ ] Keep a copy of the SCL/CID/SCD file used to configure the streams. + +## 3. Automated evidence check + +Windows: + +```powershell +./scripts/run-lab-check.ps1 -Pcap ./captures/device-session.pcap +``` + +Linux: + +```bash +./scripts/run-lab-check.sh ./captures/device-session.pcap +``` + +Acceptance conditions: + +- [ ] At least one GOOSE or Sampled Values packet is decoded. +- [ ] No process-bus packet is malformed. +- [ ] Every decoded GOOSE/SV frame re-encodes byte-for-byte to the captured frame. +- [ ] PCAP packet order, payload bytes, and microsecond-normalized timestamps round-trip. +- [ ] Stream identifiers, APPIDs, VLANs, and configuration revisions match the engineering files. + +## 4. Protocol observations + +GOOSE: + +- [ ] `stNum` increases only on state changes. +- [ ] `sqNum` behavior and retransmission intervals match the configured profile. +- [ ] `timeAllowedToLive` remains adequate for observed arrival gaps. +- [ ] Dataset values match the expected state transition. + +Sampled Values: + +- [ ] `smpCnt` progression and wrap policy match the configured sampling mode. +- [ ] `confRev`, `smpSynch`, `smpRate`, and optional `refrTm` are consistent. +- [ ] Missing, duplicate, and out-of-order sample counts are zero or explained. +- [ ] `seqOfData` channel mapping and scaling are validated against SCL, not inferred from byte position alone. + +## 5. Escalation and evidence retention + +- [ ] Save the PCAP, JSON report, SHA-256, SCL files, device configuration export, and test notes together. +- [ ] Record every mismatch before changing the parser or normalizing captured bytes. +- [ ] Do not enable active GOOSE/SV transmission from this stack until the receive-only evidence passes and a separate approved transmit test plan exists. + +## Current status + +The synthetic C#-derived corpus is automated in CI. Physical IED interoperability +remains **not yet passed** until a real capture and completed report are committed or +otherwise archived as controlled evidence. diff --git a/LAB_INTEROP_REPORT_TEMPLATE.md b/LAB_INTEROP_REPORT_TEMPLATE.md new file mode 100644 index 000000000..2e2fdaab7 --- /dev/null +++ b/LAB_INTEROP_REPORT_TEMPLATE.md @@ -0,0 +1,63 @@ +# ARIEC61850 Isolated-Lab Interoperability Report + +## Test identity + +- Date/time: +- Engineer: +- Laboratory/network diagram reference: +- IED manufacturer/model: +- Firmware: +- Engineering file and revision: +- Capture filename: +- Capture SHA-256: +- Capture duration: +- Tool commit SHA: + +## Network parameters + +| Stream | Protocol | Source MAC | Destination MAC | VLAN/PCP | APPID | Identifier | +|---|---|---|---|---|---|---| +| 1 | | | | | | | + +## Automated checker result + +- Overall result: PASS / FAIL +- Packet count: +- GOOSE count: +- Sampled Values count: +- Malformed count: +- Exact decode/re-encode matches: +- Re-encode mismatches: +- Canonical PCAP round trip: PASS / FAIL +- JSON report path/hash: + +## GOOSE observations + +- State transitions exercised: +- `stNum`/`sqNum` observations: +- Retransmission timing: +- TTL/arrival-gap result: +- Dataset/value result: + +## Sampled Values observations + +- Sampling mode/rate: +- Counter wrap policy: +- Missing/duplicate/out-of-order samples: +- Synchronization and reference-time result: +- SCL-backed channel/scaling result: + +## Deviations and evidence + +| ID | Packet/time | Observation | Expected | Disposition | +|---|---|---|---|---| +| 1 | | | | | + +## Acceptance + +- [ ] Receive-only GOOSE interoperability accepted. +- [ ] Receive-only Sampled Values interoperability accepted. +- [ ] All artifacts retained under controlled storage. +- [ ] Active transmission remains disabled unless separately approved. + +Approver/signature: diff --git a/MIGRATION_CHECKLIST.md b/MIGRATION_CHECKLIST.md index 079c966a5..30756d028 100644 --- a/MIGRATION_CHECKLIST.md +++ b/MIGRATION_CHECKLIST.md @@ -1,132 +1,100 @@ # ARIEC61850 C++ Migration Checklist -Status legend: `[x]` implemented and regression-tested, `[~]` partially implemented, -`[ ]` not started. The C# repository remains the behavioral oracle until laboratory -interoperability is complete. +Status legend: `[x]` implemented and regression-tested, `[~]` implemented but awaiting +external/CI evidence, `[ ]` not started. The C# repository remains the behavioral oracle +until laboratory interoperability is complete. ## Phase 0 — portable deterministic foundation - [x] C++20 static library and CMake build. - [x] Strict compiler warnings promoted to errors. - [x] GitHub Actions matrix: GCC, Clang, and Windows MSVC. -- [x] BER identifier, length, TLV, integer, boolean, float, string, and UTC-time primitives. -- [x] MAC address, VLAN, and Ethernet frame codec. -- [x] IEC 61850 process-bus header and APDU framing. -- [x] Classic PCAP Ethernet reader/writer. -- [x] GOOSE retransmission schedule. +- [x] BER primitives, Ethernet/VLAN/process-bus framing, and classic PCAP. -**Phase 0 status: complete.** - -## Phase 1 — deterministic process-bus codecs and runtime +## Phase 1 — deterministic process-bus core ### Phase 1A — MMS data foundation -- [x] IEC 61850 8-byte UTC time with quality byte. -- [x] Recursive MMS Data value model. -- [x] MMS AllData BER encoder/decoder. +- [x] IEC 61850 UTC time. +- [x] Recursive MMS Data model and AllData BER codec. ### Phase 1B — GOOSE wire codec -- [x] GOOSE PDU model and BER codec. -- [x] GOOSE Ethernet/VLAN/process-bus frame codec. -- [x] Dataset-count and malformed-input validation. -- [x] C#-compatible byte-for-byte golden vectors. +- [x] GOOSE PDU and Ethernet/VLAN/process-bus frame codecs. +- [x] Dataset-count validation and C#-derived golden vectors. -### Phase 1C — GOOSE supervision and publisher runtime +### Phase 1C — GOOSE runtime -- [x] Subscriber TTL, sequence, state, identity, and wraparound supervision. -- [x] Publisher session and exponential retransmission state machine. -- [x] Stable state timestamps and offline encoded-frame output. +- [x] Subscriber TTL/state/sequence/identity supervision. +- [x] Offline publisher session and retransmission runtime. ### Phase 1D — Sampled Values -- [x] Sampled Values ASDU model. -- [x] Multi-ASDU SAV PDU model. -- [x] Application-tag-0 BER encoder/decoder. -- [x] Exact `noASDU` consistency validation. -- [x] Optional `datSet`, `refrTm`, `smpRate`, and `smpMod` support. -- [x] Sampled Values Ethernet/VLAN/process-bus frame codec (`0x88BA`). -- [x] `smpCnt` policy and configurable-wrap tracker. -- [x] Continuous, normal-wrap, gap, duplicate, out-of-order, and restart classification. -- [x] Stream identity and configuration-revision supervision. -- [x] Aggregate sequence and missing-sample statistics. -- [x] IEC 61850 Sampled Values quality-word helper. -- [x] Vendor-neutral generic `seqOfData` word inspection. -- [x] Trailing-byte preservation for unknown payload shapes. +- [x] ASDU and multi-ASDU SAV PDU codec. +- [x] Ethernet frame codec for `0x88BA`. +- [x] Counter, stream, quality, and generic payload diagnostics. - [x] C#-derived PDU and complete Ethernet-frame golden vectors. -- [x] Malformed BER, UTC-time, EtherType, and length rejection tests. ### Phase 1E — evidence hardening -- [ ] Captured GOOSE and SV PCAP equivalence tests from real or simulated IED traffic. +- [x] Synthetic C#-derived GOOSE and SV PCAP corpus. +- [x] Read-only PCAP equivalence analyzer and JSON CLI. +- [x] Exact process-bus decode/re-encode comparison. +- [x] Canonical PCAP packet/timestamp round-trip comparison. +- [x] PCAP `snaplen` and packet-length allocation bounds. +- [x] Explicit regression test for malicious PCAP allocation lengths. +- [x] CMake sanitizer option and local ASan/UBSan pass. +- [x] Deterministic BER/GOOSE/SV/PCAP mutation smoke suite. +- [x] LLVM libFuzzer targets and seed corpora. +- [x] BER, GOOSE, Sampled Values, and PCAP corpus smoke: 1,000 runs each. +- [x] GitHub ASan/UBSan workflow passed. +- [x] Isolated-lab checklist, report template, and read-only runner scripts. +- [ ] Real IED or vendor-simulator GOOSE capture accepted. +- [ ] Real IED or vendor-simulator Sampled Values capture accepted. - [ ] C# and C++ executable-oracle comparison in one CI job. -- [ ] Sanitizer jobs. -- [ ] BER, GOOSE, and SV decoder fuzzing corpus. -- [ ] Isolated-laboratory GOOSE and SV interoperability. - [ ] Real-time SV publisher timing-health validation. -**Phase 1 status: MMS, GOOSE, and Sampled Values deterministic core layers complete; -evidence hardening and active-transport validation remain.** +**Phase 1 status: deterministic core and automated synthetic evidence are complete; +physical interoperability and active-transport validation remain.** ## Phase 2 — engineering file formats -- [ ] SCL object model. -- [ ] SCL parser and validation diagnostics. -- [ ] SCL workspace, profile extraction, and export. -- [ ] COMTRADE CFG/DAT reader. -- [ ] COMTRADE channel scaling and mapping. -- [ ] Expected-versus-observed engineering profiles. +- [ ] SCL object model, parser, validation, workspace, and export. +- [ ] COMTRADE CFG/DAT reader, scaling, and mapping. ## Phase 3 — MMS transport and association -- [ ] TPKT framing. -- [ ] COTP connection and data TPDU. -- [ ] Presentation layer. -- [ ] ACSE association and release. -- [ ] MMS confirmed/unconfirmed PDU envelopes. -- [ ] Invoke-ID routing with one receive pump. -- [ ] Name lists and variable-access attributes. -- [ ] MMS read/write services. +- [ ] TPKT and COTP. +- [ ] Presentation and ACSE. +- [ ] MMS envelopes, invoke routing, name lists, attributes, read, and write. ## Phase 4 — reporting, control, and file service -- [ ] DataSet and RCB inventory. -- [ ] Buffered and unbuffered report parsing. -- [ ] Report subscription, GI, and persistent monitoring. +- [ ] DataSet/RCB inventory and report monitoring. - [ ] Direct and SBO control state machines. -- [ ] Enhanced-security CommandTermination and LastApplError. -- [ ] Cancel, timeout, disconnect, and cleanup behavior. -- [ ] MMS file directory and fault-record transfer. +- [ ] Enhanced-security termination/error handling. +- [ ] MMS file and fault-record transfer. ## Phase 5 — simulator and applications -- [ ] Deterministic IED simulation runtime. -- [ ] Read-only MMS server baseline. -- [ ] CLI parity. -- [ ] Windows Npcap transport abstraction. -- [ ] Native desktop UI architecture. -- [ ] Engineering Workbench UI. -- [ ] IED Simulator UI. -- [ ] Discovery and SV Publisher UI. +- [ ] Deterministic IED simulation and read-only MMS server. +- [ ] CLI parity and Windows Npcap abstraction. +- [ ] Native Engineering Workbench, simulator, discovery, and SV publisher UI. ## Current acceptance evidence -- [x] GCC C++20 build with warnings as errors through Phase 1D. -- [x] Clang C++20 build with warnings as errors through Phase 1D. -- [x] Windows MSVC build and CTest through Phase 1D. -- [x] Full GitHub Actions matrix passed with 5/5 CTest executables. -- [x] MMS C#-derived golden vector. -- [x] GOOSE PDU and Ethernet-frame golden vectors. -- [x] Deterministic GOOSE subscriber/publisher runtime suite. -- [x] Sampled Values PDU and Ethernet-frame golden vectors. -- [x] Sampled Values counter, supervisor, quality, and payload-diagnostics suite. -- [ ] Captured process-bus PCAP equivalence. -- [ ] Fuzzing and sanitizers. -- [ ] Isolated-lab IED interoperability. +- [x] GCC C++20 warnings-as-errors build through Phase 1E local subset. +- [x] Clang C++20 warnings-as-errors build through Phase 1E local subset. +- [x] Clang ASan/UBSan local regression pass. +- [x] Synthetic GOOSE/SV PCAP exact equivalence. +- [x] Deterministic mutation smoke across more than 500 mutated inputs. +- [x] Full GCC/Clang/MSVC workflow passed with 8/8 CTest executables. +- [x] GitHub ASan/UBSan and libFuzzer security workflow passed. +- [ ] Controlled physical-lab capture evidence. ## Safety gate -GOOSE and Sampled Values publisher components return encoded bytes only. Active Npcap -transmission, real-time SV scheduling, MMS write/control, and IED operation remain -outside the enabled runtime surface until PCAP equivalence, timeout/cancellation, -timing, fuzzing, and isolated-laboratory tests pass. +All interoperability tooling is receive-only and operates on saved PCAP files. Active +Npcap transmission, real-time SV scheduling, MMS write/control, and IED operation +remain outside the enabled runtime surface until physical-lab evidence, timing, +cancellation, and explicit transmit authorization are complete. diff --git a/PORT_STATUS.md b/PORT_STATUS.md index 4a25f464f..fb617aee5 100644 --- a/PORT_STATUS.md +++ b/PORT_STATUS.md @@ -2,74 +2,73 @@ ## Current milestone -**Phase 1D — Sampled Values core codec and supervision is implemented and CI-validated.** +**Phase 1E — automated process-bus evidence and security hardening is implemented.** -The C++ stack now covers deterministic MMS data, GOOSE wire/runtime behavior, and the -core Sampled Values path from ASDU/APDU encoding through Ethernet framing and `smpCnt` -supervision. +The deterministic MMS, GOOSE, and Sampled Values layers now have synthetic PCAP +comparison, sanitizer coverage, mutation smoke tests, libFuzzer harnesses, and a +receive-only lab evidence workflow. ## Delivered modules | Area | Status | |---|---| -| BER primitives | Complete | -| Ethernet, VLAN, process-bus header | Complete | -| Classic PCAP reader/writer | Complete | -| IEC 61850 UTC time | Complete | +| BER, Ethernet, VLAN, process-bus, PCAP | Complete | | MMS Data / AllData codec | Complete | -| GOOSE PDU and Ethernet frame codecs | Complete | -| GOOSE subscriber and publisher runtime | Complete, offline frame output only | -| Sampled Values ASDU/APDU/frame codec | Complete | -| Sampled Values counter and stream supervision | Complete | -| Sampled Values quality and generic payload inspection | Complete | -| Captured SV PCAP equivalence | Pending | -| Decoder fuzzing | Pending | -| SCL and COMTRADE | Not started | -| TPKT/COTP/ACSE/MMS association | Not started | -| Reporting and control | Not started | -| Simulator and native UI | Not started | - -## Phase 1D behavior - -Sampled Values wire support includes: - -- application-tag-0 SAV PDU encoding and decoding; -- multi-ASDU sequence handling and exact `noASDU` consistency checks; -- optional dataset reference, reference time, sample rate, and sample mode fields; -- complete Ethernet/VLAN/process-bus frame handling for EtherType `0x88BA`; -- strict rejection of malformed BER, invalid UTC time, wrong EtherType, trailing APDU - data, and impossible process-bus length declarations; and -- C#-derived byte-for-byte APDU and complete Ethernet-frame vectors. - -Runtime diagnostics include: - -- sample-counter initialization, increment, configurable wrap, continuity, gaps, - duplicates, out-of-order traffic, and trusted restart classification; -- stream identity and configuration-revision checks with statistics; -- IEC 61850 quality-word encoding/decoding; and -- vendor-neutral big-endian 32-bit payload views without inventing channel semantics. - -## Validation - -The final Phase 1D branch passed the complete GitHub Actions matrix: - -- GNU C++ / Release / warnings as errors: passed; -- Clang / Release / warnings as errors: passed; -- Windows MSVC / Release / `/W4 /WX`: passed; and -- five CTest executables passed on every platform: - - core foundation; - - MMS values; - - GOOSE wire codec; - - GOOSE subscriber/publisher runtime; and - - Sampled Values codec and supervision. - -The initial MSVC run identified narrowing construction of `optional` inside -test fixtures. Explicit `uint16_t` values fixed the portability issue without changing -the library API or wire behavior. - -## Safety boundary - -Sampled Values support currently parses and returns encoded bytes only. No adapter is -opened, and no real-time publisher thread or raw Ethernet transmission is enabled. -PCAP equivalence, sanitizer/fuzz coverage, timing validation, and isolated-laboratory -interoperability remain required before active process-bus transmission. +| GOOSE wire codec and offline runtime | Complete | +| Sampled Values codec and supervision | Complete | +| Synthetic GOOSE/SV PCAP equivalence | Complete | +| Read-only PCAP interoperability checker | Complete | +| PCAP allocation-length hardening | Complete | +| ASan/UBSan build and regression suite | Complete; GitHub CI passed | +| Deterministic mutation smoke suite | Complete | +| LLVM libFuzzer targets and seed corpus | Complete; 1,000 runs per target passed | +| Isolated-lab runner/checklist/report | Complete | +| Real IED receive-only interoperability | Pending physical capture | +| C# executable-oracle CI comparison | Pending original C# executable/project integration | +| Active GOOSE/SV transmission | Disabled | + +## Phase 1E behavior + +The PCAP evidence analyzer: + +- reads classic Ethernet PCAP without opening a network adapter; +- identifies GOOSE and Sampled Values frames; +- decodes and re-encodes each process-bus frame; +- requires exact byte-for-byte equivalence; +- verifies packet order, frame bytes, and microsecond-normalized timestamps through a + canonical PCAP write/read cycle; and +- produces human-readable or JSON evidence suitable for a controlled lab report. + +Security hardening includes: + +- rejection of zero or excessive PCAP `snaplen`; +- validation that included packet length does not exceed `snaplen` or the original + packet length before allocation; +- ASan/UBSan instrumentation; +- deterministic decoder mutation tests on all compilers; and +- libFuzzer entry points for BER, GOOSE, Sampled Values, and PCAP. + +## Local validation + +- GNU C++ 14.2, Release, warnings as errors: passed. +- Clang 17, Release, warnings as errors: passed. +- Clang 17, ASan + UBSan: passed. +- Synthetic PCAP exact equivalence: passed. +- Deterministic mutation smoke: passed and found the PCAP allocation issue now covered + by an explicit regression test. + +## Remaining acceptance gates + +- GitHub sanitizer and libFuzzer jobs passed on the published branch. +- At least one controlled capture from a real or vendor-simulated IED must pass the + checker and be documented using `LAB_INTEROP_REPORT_TEMPLATE.md`. +- Real-time Sampled Values timing health and active transmission require a separate, + approved laboratory plan. + +## GitHub validation + +- GCC, Clang, and Windows MSVC: passed. +- Full regression matrix: 8/8 CTest executables passed on each platform. +- Clang ASan + UBSan: passed. +- libFuzzer BER, GOOSE, Sampled Values, and PCAP corpora: 1,000 runs each, passed. +- Physical isolated-lab GOOSE/SV capture evidence: pending. diff --git a/README.md b/README.md index 1148fd194..5176e885c 100644 --- a/README.md +++ b/README.md @@ -13,38 +13,34 @@ interoperability are complete. - MAC, VLAN, Ethernet, IEC 61850 process-bus, and classic PCAP codecs. - GOOSE retransmission schedule. -### Phase 1A — MMS data foundation +### Phase 1A–1C — MMS and GOOSE -- IEC 61850 8-byte UTC time and quality byte. -- Recursive MMS Data values and AllData BER codec. -- C#-derived byte-for-byte golden vectors. +- IEC 61850 UTC time and recursive MMS Data/AllData codec. +- Complete GOOSE PDU and Ethernet frame codecs. +- Subscriber TTL, state, sequence, identity, and wraparound supervision. +- Deterministic offline publisher runtime with exponential retransmission scheduling. -### Phase 1B — GOOSE wire codec +### Phase 1D — Sampled Values -- Complete GOOSE PDU encode/decode. -- Ethernet/VLAN/process-bus GOOSE frame encode/decode. -- Dataset count and malformed-frame validation. - -### Phase 1C — GOOSE runtime state machines +- Sampled Values ASDU and multi-ASDU SAV PDU codec. +- Ethernet/VLAN/process-bus frame codec for EtherType `0x88BA`. +- `smpCnt` continuity, wrap, gap, duplicate, out-of-order, and restart tracking. +- Stream identity/configuration supervision, quality-word support, and generic + `seqOfData` inspection. -- Subscriber TTL and `stNum`/`sqNum` supervision. -- Duplicate, gap, regression, state-change, and wraparound classification. -- Deterministic publisher runtime with exponential retransmission scheduling. -- Offline encoded-frame output without raw-network activation. +### Phase 1E — evidence hardening -### Phase 1D — Sampled Values core +- C#-derived synthetic PCAP containing one GOOSE and one Sampled Values frame. +- Read-only `ariec61850_pcap_interop_check` tool with JSON output. +- Exact captured-frame decode/re-encode comparison and canonical PCAP round-trip checks. +- PCAP allocation hardening using validated `snaplen`, included length, and original length. +- Clang AddressSanitizer and UndefinedBehaviorSanitizer workflow. +- LLVM libFuzzer targets and seed corpora for BER, GOOSE, Sampled Values, and PCAP. +- Deterministic mutation smoke tests that run on every compiler, including MSVC. +- Isolated-laboratory checklist, report template, and Windows/Linux runner scripts. -- Sampled Values ASDU and multi-ASDU APDU models. -- Application-tag-0 BER encoder/decoder with strict `noASDU` validation. -- Ethernet/VLAN/process-bus frame codec for EtherType `0x88BA`. -- Optional `datSet`, `refrTm`, `smpRate`, and `smpMod` field handling. -- `smpCnt` continuity, normal-wrap, gap, duplicate, out-of-order, and restart tracking. -- Stream identity and configuration-revision supervision with aggregate statistics. -- IEC 61850 quality-word helper. -- Vendor-neutral raw `seqOfData` 32-bit word inspection with trailing-byte preservation. -- C#-derived byte-for-byte PDU and complete Ethernet-frame golden vectors. - -No Phase 1 runtime opens Npcap, raw sockets, or a network interface. +No Phase 1 runtime opens Npcap, raw sockets, or a network interface. Physical IED +interoperability remains pending until real captures are collected and accepted. ## Build @@ -64,12 +60,21 @@ cmake --build build --parallel ctest --test-dir build --output-on-failure ``` -## Migration rule +### Sanitizers + +```bash +CC=clang CXX=clang++ cmake -S . -B build-sanitizers \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DARIEC61850_ENABLE_SANITIZERS=ON +cmake --build build-sanitizers --parallel +ctest --test-dir build-sanitizers --output-on-failure +``` + +### Read-only PCAP interoperability check -1. Capture deterministic C# behavior and byte vectors. -2. Port the same behavior to C++. -3. Run strict multi-compiler builds and semantic state-machine tests. -4. Compare real or simulated PCAP/SCL evidence. -5. Enable active transport only after safety and interoperability gates pass. +```powershell +./scripts/run-lab-check.ps1 -Pcap ./captures/device-session.pcap +``` -See `MIGRATION_CHECKLIST.md` for the detailed progress ledger. +See `LAB_INTEROP_CHECKLIST.md` before using a physical IED and +`MIGRATION_CHECKLIST.md` for the detailed progress ledger. diff --git a/VALIDATION.txt b/VALIDATION.txt index c5a66eb73..825ef56d5 100644 --- a/VALIDATION.txt +++ b/VALIDATION.txt @@ -1,38 +1,41 @@ -ARIEC61850 C++ Phase 1D validation +ARIEC61850 C++ Phase 1E validation Date: 2026-08-06 -GNU C++ / Release / warnings-as-errors / GitHub Actions: +GNU C++ 14.2 / Release / warnings-as-errors: Configure: PASS Build: PASS - CTest: PASS (5/5 test executables) + CTest: PASS -Clang / Release / warnings-as-errors / GitHub Actions: +Clang 17 / Release / warnings-as-errors: Configure: PASS Build: PASS - CTest: PASS (5/5 test executables) + CTest: PASS -Windows MSVC / Release / W4/WX / GitHub Actions: +Clang 17 / RelWithDebInfo / ASan + UBSan: Configure: PASS Build: PASS - CTest: PASS (5/5 test executables) + CTest: PASS -Test executables: - 1. ariec61850_core_tests - 2. ariec61850_mms_tests - 3. ariec61850_goose_tests - 4. ariec61850_goose_runtime_tests - 5. ariec61850_sampled_values_tests +Local Phase 1E checks: + - C#-derived synthetic PCAP with one GOOSE and one Sampled Values packet + - exact process-bus decode/re-encode equivalence + - canonical PCAP packet/timestamp round trip + - read-only JSON interoperability checker + - deterministic BER, GOOSE, SV, and PCAP mutation smoke (>500 cases) + - malicious snaplen/included-length allocation regression -Phase 1D Sampled Values checks: - - C#-derived SAV PDU byte-for-byte golden vector - - complete VLAN/process-bus Ethernet frame golden vector - - multi-ASDU encode/decode and exact noASDU validation - - malformed outer tag, sequence, UTC time, trailing bytes, EtherType, and length rejection - - smpCnt continuity, wrap, gap, duplicate, out-of-order, and restart tracking - - stream identity/configuration supervision and aggregate statistics - - quality-word round trip - - generic seqOfData word inspection and trailing-byte preservation +Local libFuzzer note: + The container's Swift-bundled Clang 17 libFuzzer runtime is ABI-incompatible with the + system C++ runtime. The actual libFuzzer corpus smoke is delegated to the standard + Ubuntu GitHub Actions Clang toolchain. -MSVC portability correction: - - explicit uint16_t construction for optional sample-rate, sample-mode, and wrap fixtures - - library API and encoded wire bytes unchanged +GitHub Actions: + GCC / Clang / Windows MSVC matrix: PASS (8/8 CTest each) + Clang ASan + UBSan: PASS + Clang libFuzzer corpus smoke: PASS (1,000 runs each target) + +Physical isolated-lab interoperability: + Tooling/checklist: READY + Real GOOSE capture: PENDING + Real Sampled Values capture: PENDING + Active transmission: DISABLED diff --git a/fuzz/corpus/ber/mms_all_data_seed b/fuzz/corpus/ber/mms_all_data_seed new file mode 100644 index 000000000..d20cdc5ff Binary files /dev/null and b/fuzz/corpus/ber/mms_all_data_seed differ diff --git a/fuzz/corpus/goose/goose_frame_seed b/fuzz/corpus/goose/goose_frame_seed new file mode 100644 index 000000000..862682ea7 Binary files /dev/null and b/fuzz/corpus/goose/goose_frame_seed differ diff --git a/fuzz/corpus/pcap/process_bus_seed b/fuzz/corpus/pcap/process_bus_seed new file mode 100644 index 000000000..c7e5b86ff Binary files /dev/null and b/fuzz/corpus/pcap/process_bus_seed differ diff --git a/fuzz/corpus/sampled_values/sv_frame_seed b/fuzz/corpus/sampled_values/sv_frame_seed new file mode 100644 index 000000000..4907ca808 Binary files /dev/null and b/fuzz/corpus/sampled_values/sv_frame_seed differ diff --git a/fuzz/fuzz_ber.cpp b/fuzz/fuzz_ber.cpp new file mode 100644 index 000000000..74582db1d --- /dev/null +++ b/fuzz/fuzz_ber.cpp @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "fuzz_targets.hpp" +#include +#include +#include +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, const std::size_t size) { + ar::iec61850::fuzzing::exercise_ber(std::span{data, size}); + return 0; +} diff --git a/fuzz/fuzz_goose.cpp b/fuzz/fuzz_goose.cpp new file mode 100644 index 000000000..cde2e6883 --- /dev/null +++ b/fuzz/fuzz_goose.cpp @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "fuzz_targets.hpp" +#include +#include +#include +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, const std::size_t size) { + ar::iec61850::fuzzing::exercise_goose(std::span{data, size}); + return 0; +} diff --git a/fuzz/fuzz_pcap.cpp b/fuzz/fuzz_pcap.cpp new file mode 100644 index 000000000..63ce1ba41 --- /dev/null +++ b/fuzz/fuzz_pcap.cpp @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "fuzz_targets.hpp" +#include +#include +#include +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, const std::size_t size) { + ar::iec61850::fuzzing::exercise_pcap(std::span{data, size}); + return 0; +} diff --git a/fuzz/fuzz_sampled_values.cpp b/fuzz/fuzz_sampled_values.cpp new file mode 100644 index 000000000..e5868605a --- /dev/null +++ b/fuzz/fuzz_sampled_values.cpp @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "fuzz_targets.hpp" +#include +#include +#include +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, const std::size_t size) { + ar::iec61850::fuzzing::exercise_sampled_values(std::span{data, size}); + return 0; +} diff --git a/fuzz/fuzz_targets.hpp b/fuzz/fuzz_targets.hpp new file mode 100644 index 000000000..ae3467688 --- /dev/null +++ b/fuzz/fuzz_targets.hpp @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/asn1/ber.hpp" +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/evidence/pcap_equivalence.hpp" +#include "ariec61850/goose/frame_codec.hpp" +#include "ariec61850/goose/pdu_codec.hpp" +#include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/sampled_values/pdu_codec.hpp" + +#include +#include +#include +#include +#include + +namespace ar::iec61850::fuzzing { + +inline void exercise_ber(const std::span bytes) { + std::size_t offset = 0U; + while (offset < bytes.size()) { + const auto previous = offset; + asn1::BerTlv tlv; + if (!asn1::BerReader::try_read_tlv(bytes, offset, tlv)) { + break; + } + static_cast(asn1::BerReader::read_boolean(tlv)); + static_cast(asn1::BerReader::read_signed_integer(tlv)); + static_cast(asn1::BerReader::read_unsigned_integer(tlv)); + if (tlv.constructed) { + try { + static_cast(asn1::BerReader::read_children(tlv.value)); + } catch (...) { + } + } + if (offset <= previous) { + break; + } + } + try { + static_cast(asn1::BerReader::read_children(bytes)); + } catch (...) { + } +} + +inline void exercise_goose(const std::span bytes) { + goose::GooseFrame frame; + if (goose::GooseFrameCodec::try_decode(bytes, frame)) { + const auto encoded = goose::GooseFrameCodec::encode(frame); + goose::GooseFrame second; + static_cast(goose::GooseFrameCodec::try_decode(encoded, second)); + } + goose::GoosePdu pdu; + if (goose::GoosePduCodec::try_decode(bytes, pdu)) { + const auto encoded = goose::GoosePduCodec::encode(pdu); + goose::GoosePdu second; + static_cast(goose::GoosePduCodec::try_decode(encoded, second)); + } +} + +inline void exercise_sampled_values(const std::span bytes) { + sampled_values::SampledValuesFrame frame; + if (sampled_values::SampledValuesFrameCodec::try_decode(bytes, frame)) { + const auto encoded = sampled_values::SampledValuesFrameCodec::encode(frame); + sampled_values::SampledValuesFrame second; + static_cast(sampled_values::SampledValuesFrameCodec::try_decode(encoded, second)); + } + sampled_values::SampledValuesPdu pdu; + if (sampled_values::SampledValuesPduCodec::try_decode(bytes, pdu)) { + const auto encoded = sampled_values::SampledValuesPduCodec::encode(pdu); + sampled_values::SampledValuesPdu second; + static_cast(sampled_values::SampledValuesPduCodec::try_decode(encoded, second)); + } +} + +inline void exercise_pcap(const std::span bytes) { + std::string input; + if (!bytes.empty()) { + input.assign(reinterpret_cast(bytes.data()), bytes.size()); + } + std::istringstream stream{input, std::ios::in | std::ios::binary}; + try { + const auto packets = capture::PcapReader::read_all(stream); + const auto report = evidence::PcapEquivalenceAnalyzer::analyze(packets); + static_cast(evidence::PcapEquivalenceAnalyzer::to_json(report)); + } catch (...) { + } +} + +} // namespace ar::iec61850::fuzzing diff --git a/include/ariec61850/evidence/pcap_equivalence.hpp b/include/ariec61850/evidence/pcap_equivalence.hpp new file mode 100644 index 000000000..f3db71b72 --- /dev/null +++ b/include/ariec61850/evidence/pcap_equivalence.hpp @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/capture/pcap.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::evidence { + +enum class ProcessBusProtocol : std::uint8_t { + goose, + sampled_values, + other_ethernet, + malformed +}; + +struct PacketEvidence final { + std::size_t packet_index{}; + ProcessBusProtocol protocol{ProcessBusProtocol::malformed}; + std::uint16_t ether_type{}; + std::optional app_id; + bool decoded{}; + bool exact_reencode_match{}; + std::string stream_identifier; + std::vector diagnostics; +}; + +struct PcapEquivalenceReport final { + std::size_t packet_count{}; + std::size_t process_bus_packet_count{}; + std::size_t goose_packet_count{}; + std::size_t sampled_values_packet_count{}; + std::size_t other_ethernet_packet_count{}; + std::size_t malformed_packet_count{}; + std::size_t exact_reencode_match_count{}; + std::size_t reencode_mismatch_count{}; + bool canonical_pcap_round_trip_match{}; + std::vector packets; + + [[nodiscard]] bool passed() const noexcept; +}; + +class PcapEquivalenceAnalyzer final { +public: + [[nodiscard]] static PcapEquivalenceReport analyze( + std::span packets); + [[nodiscard]] static PcapEquivalenceReport analyze( + const std::filesystem::path& pcap_path); + [[nodiscard]] static bool canonical_pcap_round_trip_matches( + std::span packets); + [[nodiscard]] static std::string to_json(const PcapEquivalenceReport& report); +}; + +[[nodiscard]] std::string protocol_name(ProcessBusProtocol protocol); + +} // namespace ar::iec61850::evidence diff --git a/scripts/run-lab-check.ps1 b/scripts/run-lab-check.ps1 new file mode 100644 index 000000000..5108b8fe9 --- /dev/null +++ b/scripts/run-lab-check.ps1 @@ -0,0 +1,35 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Pcap, + + [string]$BuildDirectory = "build-lab" +) + +$ErrorActionPreference = "Stop" + +if (-not (Test-Path -LiteralPath $Pcap -PathType Leaf)) { + throw "PCAP file not found: $Pcap" +} + +cmake -S . -B $BuildDirectory -A x64 ` + -DARIEC61850_BUILD_TESTS=ON ` + -DARIEC61850_BUILD_TOOLS=ON ` + -DARIEC61850_WARNINGS_AS_ERRORS=ON +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +cmake --build $BuildDirectory --config Release --parallel ` + --target ariec61850_pcap_interop_check +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$tool = Join-Path $BuildDirectory "Release/ariec61850_pcap_interop_check.exe" +$report = [System.IO.Path]::ChangeExtension( + (Resolve-Path -LiteralPath $Pcap).Path, + ".interop.json") + +Write-Host "Capture SHA-256:" +Get-FileHash -LiteralPath $Pcap -Algorithm SHA256 | Format-List + +& $tool $Pcap --json | Tee-Object -FilePath $report +$exitCode = $LASTEXITCODE +Write-Host "Evidence report: $report" +exit $exitCode diff --git a/scripts/run-lab-check.sh b/scripts/run-lab-check.sh new file mode 100755 index 000000000..2c691709e --- /dev/null +++ b/scripts/run-lab-check.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "Usage: $0 [build-directory]" >&2 + exit 64 +fi + +pcap=$1 +build_dir=${2:-build-lab} + +if [[ ! -f "$pcap" ]]; then + echo "PCAP file not found: $pcap" >&2 + exit 66 +fi + +cmake -S . -B "$build_dir" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DARIEC61850_BUILD_TESTS=ON \ + -DARIEC61850_BUILD_TOOLS=ON \ + -DARIEC61850_WARNINGS_AS_ERRORS=ON +cmake --build "$build_dir" --parallel --target ariec61850_pcap_interop_check + +report="${pcap%.*}.interop.json" +sha256sum "$pcap" +"$build_dir/ariec61850_pcap_interop_check" "$pcap" --json | tee "$report" +echo "Evidence report: $report" diff --git a/src/capture/pcap.cpp b/src/capture/pcap.cpp index 7256268a9..b8f130006 100644 --- a/src/capture/pcap.cpp +++ b/src/capture/pcap.cpp @@ -15,6 +15,7 @@ constexpr std::uint32_t magic_number_nanoseconds = 0xA1B23C4DU; constexpr std::uint32_t link_type_ethernet = 1U; constexpr std::size_t global_header_length = 24U; constexpr std::size_t packet_header_length = 16U; +constexpr std::uint32_t maximum_supported_snap_length = 16U * 1024U * 1024U; enum class ByteOrder { little_endian, big_endian }; enum class TimestampResolution { microseconds, nanoseconds }; @@ -83,13 +84,17 @@ std::chrono::system_clock::time_point to_timestamp( const std::uint32_t seconds, const std::uint32_t fractional, const TimestampResolution resolution) { - const auto fractional_duration = resolution == TimestampResolution::nanoseconds - ? std::chrono::duration_cast( - std::chrono::nanoseconds{fractional}) - : std::chrono::duration_cast( - std::chrono::microseconds{fractional}); - return std::chrono::system_clock::time_point{std::chrono::seconds{seconds}} + - fractional_duration; + auto timestamp = std::chrono::system_clock::time_point{std::chrono::seconds{seconds}}; + if (resolution == TimestampResolution::nanoseconds) { + const auto duration = std::chrono::duration_cast( + std::chrono::nanoseconds{fractional}); + timestamp += duration; + } else { + const auto duration = std::chrono::duration_cast( + std::chrono::microseconds{fractional}); + timestamp += duration; + } + return timestamp; } } // namespace @@ -112,7 +117,10 @@ std::vector PcapReader::read_all(std::istream& stream) { const auto [byte_order, resolution] = resolve_byte_order(magic_span); const auto version_major = read_u16(std::span{global_header}.subspan(4U, 2U), byte_order); const auto version_minor = read_u16(std::span{global_header}.subspan(6U, 2U), byte_order); - const auto link_type = read_u32(std::span{global_header}.subspan(20U, 4U), byte_order); + const auto snap_length = read_u32( + std::span{global_header}.subspan(16U, 4U), byte_order); + const auto link_type = read_u32( + std::span{global_header}.subspan(20U, 4U), byte_order); if (version_major != 2U || version_minor != 4U) { throw PcapFormatError("Unsupported PCAP version " + std::to_string(version_major) + "." + @@ -122,6 +130,10 @@ std::vector PcapReader::read_all(std::istream& stream) { throw PcapFormatError("Unsupported PCAP link type " + std::to_string(link_type) + "; only Ethernet is supported."); } + if (snap_length == 0U || snap_length > maximum_supported_snap_length) { + throw PcapFormatError("Unsupported PCAP snap length " + + std::to_string(snap_length) + "."); + } std::vector packets; while (true) { @@ -140,8 +152,16 @@ std::vector PcapReader::read_all(std::istream& stream) { const auto seconds = read_u32(header_span.subspan(0U, 4U), byte_order); const auto fractional = read_u32(header_span.subspan(4U, 4U), byte_order); const auto included_length = read_u32(header_span.subspan(8U, 4U), byte_order); - if (included_length > static_cast(std::numeric_limits::max())) { - throw PcapFormatError("PCAP packet is too large: " + std::to_string(included_length) + " bytes."); + const auto original_length = read_u32(header_span.subspan(12U, 4U), byte_order); + if (included_length > snap_length || + included_length > maximum_supported_snap_length || + original_length < included_length || + included_length > static_cast( + std::numeric_limits::max())) { + throw PcapFormatError("Invalid PCAP packet lengths: included=" + + std::to_string(included_length) + + ", original=" + std::to_string(original_length) + + ", snaplen=" + std::to_string(snap_length) + "."); } std::vector frame(included_length); diff --git a/src/evidence/pcap_equivalence.cpp b/src/evidence/pcap_equivalence.cpp new file mode 100644 index 000000000..d08855473 --- /dev/null +++ b/src/evidence/pcap_equivalence.cpp @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/evidence/pcap_equivalence.hpp" + +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/goose/frame_codec.hpp" +#include "ariec61850/sampled_values/frame_codec.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::evidence { +namespace { + +std::chrono::microseconds canonical_timestamp( + const std::chrono::system_clock::time_point timestamp) noexcept { + return std::chrono::duration_cast( + timestamp.time_since_epoch()); +} + +std::string json_escape(const std::string& value) { + std::ostringstream output; + for (const char raw_character : value) { + const auto character = static_cast(raw_character); + switch (character) { + case '"': output << "\\\""; break; + case '\\': output << "\\\\"; break; + case '\b': output << "\\b"; break; + case '\f': output << "\\f"; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: + if (character < 0x20U) { + output << "\\u" << std::hex << std::setw(4) << std::setfill('0') + << static_cast(character) << std::dec; + } else { + output << static_cast(character); + } + break; + } + } + return output.str(); +} + +PacketEvidence analyze_packet( + const std::size_t packet_index, + const capture::PcapPacket& packet) { + PacketEvidence result; + result.packet_index = packet_index; + + ethernet::EthernetFrame ethernet_frame; + if (!ethernet::EthernetFrameCodec::try_decode(packet.frame, ethernet_frame)) { + result.protocol = ProcessBusProtocol::malformed; + result.diagnostics.emplace_back("Ethernet frame decode failed."); + return result; + } + + result.ether_type = ethernet_frame.ether_type; + if (ethernet_frame.ether_type == ethernet::goose_ethertype) { + result.protocol = ProcessBusProtocol::goose; + goose::GooseFrame decoded; + if (!goose::GooseFrameCodec::try_decode(packet.frame, decoded)) { + result.diagnostics.emplace_back("GOOSE process-bus frame decode failed."); + return result; + } + result.decoded = true; + result.app_id = decoded.app_id; + result.stream_identifier = decoded.pdu.go_cb_ref; + const auto reencoded = goose::GooseFrameCodec::encode(decoded); + result.exact_reencode_match = reencoded == packet.frame; + if (!result.exact_reencode_match) { + result.diagnostics.emplace_back( + "GOOSE decode/re-encode bytes differ from the captured frame."); + } + return result; + } + + if (ethernet_frame.ether_type == ethernet::sampled_values_ethertype) { + result.protocol = ProcessBusProtocol::sampled_values; + sampled_values::SampledValuesFrame decoded; + if (!sampled_values::SampledValuesFrameCodec::try_decode(packet.frame, decoded)) { + result.diagnostics.emplace_back("Sampled Values process-bus frame decode failed."); + return result; + } + result.decoded = true; + result.app_id = decoded.app_id; + if (!decoded.pdu.asdus.empty()) { + result.stream_identifier = decoded.pdu.asdus.front().sv_id; + } + const auto reencoded = sampled_values::SampledValuesFrameCodec::encode(decoded); + result.exact_reencode_match = reencoded == packet.frame; + if (!result.exact_reencode_match) { + result.diagnostics.emplace_back( + "Sampled Values decode/re-encode bytes differ from the captured frame."); + } + return result; + } + + result.protocol = ProcessBusProtocol::other_ethernet; + result.decoded = true; + result.exact_reencode_match = + ethernet::EthernetFrameCodec::encode(ethernet_frame) == packet.frame; + if (!result.exact_reencode_match) { + result.diagnostics.emplace_back( + "Generic Ethernet decode/re-encode bytes differ from the captured frame."); + } + return result; +} + +} // namespace + +bool PcapEquivalenceReport::passed() const noexcept { + return process_bus_packet_count > 0U && malformed_packet_count == 0U && + reencode_mismatch_count == 0U && canonical_pcap_round_trip_match; +} + +PcapEquivalenceReport PcapEquivalenceAnalyzer::analyze( + const std::span packets) { + PcapEquivalenceReport report; + report.packet_count = packets.size(); + report.canonical_pcap_round_trip_match = canonical_pcap_round_trip_matches(packets); + report.packets.reserve(packets.size()); + + for (std::size_t index = 0U; index < packets.size(); ++index) { + auto packet = analyze_packet(index, packets[index]); + switch (packet.protocol) { + case ProcessBusProtocol::goose: + ++report.process_bus_packet_count; + ++report.goose_packet_count; + break; + case ProcessBusProtocol::sampled_values: + ++report.process_bus_packet_count; + ++report.sampled_values_packet_count; + break; + case ProcessBusProtocol::other_ethernet: + ++report.other_ethernet_packet_count; + break; + case ProcessBusProtocol::malformed: + ++report.malformed_packet_count; + break; + } + + if ((packet.protocol == ProcessBusProtocol::goose || + packet.protocol == ProcessBusProtocol::sampled_values) && + !packet.decoded) { + ++report.malformed_packet_count; + } else if (packet.decoded && packet.exact_reencode_match) { + ++report.exact_reencode_match_count; + } else if (packet.decoded) { + ++report.reencode_mismatch_count; + } + report.packets.push_back(std::move(packet)); + } + return report; +} + +PcapEquivalenceReport PcapEquivalenceAnalyzer::analyze( + const std::filesystem::path& pcap_path) { + return analyze(capture::PcapReader::read_all(pcap_path)); +} + +bool PcapEquivalenceAnalyzer::canonical_pcap_round_trip_matches( + const std::span packets) { + try { + std::stringstream stream{ + std::ios::in | std::ios::out | std::ios::binary}; + capture::PcapWriter writer{stream}; + for (const auto& packet : packets) { + writer.write_packet(packet.timestamp, packet.frame); + } + stream.seekg(0, std::ios::beg); + const auto decoded = capture::PcapReader::read_all(stream); + if (decoded.size() != packets.size()) { + return false; + } + for (std::size_t index = 0U; index < packets.size(); ++index) { + if (decoded[index].frame != packets[index].frame || + canonical_timestamp(decoded[index].timestamp) != + canonical_timestamp(packets[index].timestamp)) { + return false; + } + } + return true; + } catch (...) { + return false; + } +} + +std::string PcapEquivalenceAnalyzer::to_json( + const PcapEquivalenceReport& report) { + std::ostringstream output; + output << "{\n" + << " \"passed\": " << (report.passed() ? "true" : "false") << ",\n" + << " \"packet_count\": " << report.packet_count << ",\n" + << " \"process_bus_packet_count\": " << report.process_bus_packet_count << ",\n" + << " \"goose_packet_count\": " << report.goose_packet_count << ",\n" + << " \"sampled_values_packet_count\": " + << report.sampled_values_packet_count << ",\n" + << " \"other_ethernet_packet_count\": " + << report.other_ethernet_packet_count << ",\n" + << " \"malformed_packet_count\": " << report.malformed_packet_count << ",\n" + << " \"exact_reencode_match_count\": " + << report.exact_reencode_match_count << ",\n" + << " \"reencode_mismatch_count\": " + << report.reencode_mismatch_count << ",\n" + << " \"canonical_pcap_round_trip_match\": " + << (report.canonical_pcap_round_trip_match ? "true" : "false") << ",\n" + << " \"packets\": [\n"; + + for (std::size_t index = 0U; index < report.packets.size(); ++index) { + const auto& packet = report.packets[index]; + output << " {\"index\": " << packet.packet_index + << ", \"protocol\": \"" << protocol_name(packet.protocol) + << "\", \"ether_type\": " << packet.ether_type + << ", \"decoded\": " << (packet.decoded ? "true" : "false") + << ", \"exact_reencode_match\": " + << (packet.exact_reencode_match ? "true" : "false"); + if (packet.app_id.has_value()) { + output << ", \"app_id\": " << *packet.app_id; + } + if (!packet.stream_identifier.empty()) { + output << ", \"stream_identifier\": \"" + << json_escape(packet.stream_identifier) << "\""; + } + output << ", \"diagnostics\": ["; + for (std::size_t diagnostic = 0U; + diagnostic < packet.diagnostics.size(); ++diagnostic) { + if (diagnostic != 0U) { + output << ", "; + } + output << "\"" << json_escape(packet.diagnostics[diagnostic]) << "\""; + } + output << "]}"; + if (index + 1U != report.packets.size()) { + output << ','; + } + output << '\n'; + } + output << " ]\n}\n"; + return output.str(); +} + +std::string protocol_name(const ProcessBusProtocol protocol) { + switch (protocol) { + case ProcessBusProtocol::goose: return "GOOSE"; + case ProcessBusProtocol::sampled_values: return "Sampled Values"; + case ProcessBusProtocol::other_ethernet: return "Other Ethernet"; + case ProcessBusProtocol::malformed: return "Malformed"; + } + return "Unknown"; +} + +} // namespace ar::iec61850::evidence diff --git a/tests/fixtures/evidence_manifest.json b/tests/fixtures/evidence_manifest.json new file mode 100644 index 000000000..5a499d105 --- /dev/null +++ b/tests/fixtures/evidence_manifest.json @@ -0,0 +1,14 @@ +{ + "format_version": 1, + "origin": "C#-derived deterministic golden vectors assembled into a synthetic Ethernet PCAP", + "captures": [ + { + "file": "tests/fixtures/process_bus_csharp_vectors.pcap", + "packet_count": 2, + "goose_packets": 1, + "sampled_values_packets": 1, + "expected_exact_reencode_matches": 2, + "hardware_interoperability": false + } + ] +} diff --git a/tests/fixtures/process_bus_csharp_vectors.pcap b/tests/fixtures/process_bus_csharp_vectors.pcap new file mode 100644 index 000000000..c7e5b86ff Binary files /dev/null and b/tests/fixtures/process_bus_csharp_vectors.pcap differ diff --git a/tests/test_evidence.cpp b/tests/test_evidence.cpp new file mode 100644 index 000000000..db945a31f --- /dev/null +++ b/tests/test_evidence.cpp @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/evidence/pcap_equivalence.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using ByteVector = std::vector; + +#define CHECK(condition) do { \ + if (!(condition)) { \ + throw std::runtime_error(std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ +} while (false) + +ByteVector from_hex(const std::string& text) { + if ((text.size() % 2U) != 0U) { + throw std::invalid_argument("Hex text must contain an even number of characters."); + } + ByteVector result; + result.reserve(text.size() / 2U); + for (std::size_t index = 0U; index < text.size(); index += 2U) { + result.push_back(static_cast( + std::stoul(text.substr(index, 2U), nullptr, 16))); + } + return result; +} + + +ByteVector read_binary_file(const std::filesystem::path& path) { + std::ifstream stream(path, std::ios::binary); + if (!stream) { + throw std::runtime_error("Unable to read fixture: " + path.string()); + } + const std::string bytes{ + std::istreambuf_iterator{stream}, std::istreambuf_iterator{}}; + ByteVector result; + result.reserve(bytes.size()); + for (const char value : bytes) { + result.push_back(static_cast( + static_cast(value))); + } + return result; +} + +bool pcap_read_throws(const ByteVector& bytes) { + const std::string input{ + reinterpret_cast(bytes.data()), bytes.size()}; + std::istringstream stream{input, std::ios::in | std::ios::binary}; + try { + static_cast(ar::iec61850::capture::PcapReader::read_all(stream)); + return false; + } catch (const ar::iec61850::capture::PcapFormatError&) { + return true; + } +} + +ByteVector goose_frame() { + return from_hex( + "010CCD0100010200000000018100806488B81001005E00000000615480104C44302F4C4C4E30" + "24474F2467636231810203E8820C4C44302F4C4C4E30244453318306474F4F53453184086A2B" + "77254000000A8501038601098701008801028901018A0103AB0A8301018501FD8A024F4B"); +} + +ByteVector sampled_values_frame() { + return from_hex( + "010CCD040001020000000002810080C888BA4001006800000000" + "605E800101A259305780134D55303146312F4C4C4E30244D535643423031" + "81144D55303146312F4C4C4E30245068734D65617331820178830103" + "84086A2BDFE40000000085010286020FA087100000006400000001000000C8" + "00000003880101"); +} + +std::vector reference_packets() { + using Clock = std::chrono::system_clock; + return { + {Clock::time_point{std::chrono::seconds{1'781'233'445}} + + std::chrono::microseconds{250'000}, goose_frame()}, + {Clock::time_point{std::chrono::seconds{1'781'260'260}} + + std::chrono::microseconds{125'000}, sampled_values_frame()}}; +} + +void pcap_reader_writer_preserves_canonical_process_bus_evidence() { + using namespace ar::iec61850; + const auto packets = reference_packets(); + std::stringstream stream{std::ios::in | std::ios::out | std::ios::binary}; + capture::PcapWriter writer{stream}; + for (const auto& packet : packets) { + writer.write_packet(packet.timestamp, packet.frame); + } + stream.seekg(0, std::ios::beg); + const auto decoded = capture::PcapReader::read_all(stream); + CHECK(decoded.size() == packets.size()); + CHECK(decoded[0].frame == packets[0].frame); + CHECK(decoded[1].frame == packets[1].frame); + CHECK(evidence::PcapEquivalenceAnalyzer::canonical_pcap_round_trip_matches(decoded)); +} + +void pcap_analyzer_reports_exact_goose_and_sv_round_trips() { + using namespace ar::iec61850; + const auto report = evidence::PcapEquivalenceAnalyzer::analyze(reference_packets()); + CHECK(report.passed()); + CHECK(report.packet_count == 2U); + CHECK(report.process_bus_packet_count == 2U); + CHECK(report.goose_packet_count == 1U); + CHECK(report.sampled_values_packet_count == 1U); + CHECK(report.malformed_packet_count == 0U); + CHECK(report.reencode_mismatch_count == 0U); + CHECK(report.exact_reencode_match_count == 2U); + CHECK(report.canonical_pcap_round_trip_match); + CHECK(report.packets[0].stream_identifier == "LD0/LLN0$GO$gcb1"); + CHECK(report.packets[1].stream_identifier == "MU01F1/LLN0$MSVCB01"); + + const auto json = evidence::PcapEquivalenceAnalyzer::to_json(report); + CHECK(json.find("\"passed\": true") != std::string::npos); + CHECK(json.find("GOOSE") != std::string::npos); + CHECK(json.find("Sampled Values") != std::string::npos); +} + +void pcap_analyzer_rejects_malformed_process_bus_packets() { + using namespace ar::iec61850; + auto packets = reference_packets(); + packets[0].frame[17] = 0xBAU; + const auto report = evidence::PcapEquivalenceAnalyzer::analyze(packets); + CHECK(!report.passed()); + CHECK(report.sampled_values_packet_count == 2U); + CHECK(report.malformed_packet_count == 1U); + CHECK(!report.packets[0].decoded); + CHECK(!report.packets[0].diagnostics.empty()); +} + + +void pcap_reader_rejects_unbounded_allocation_lengths() { + const auto fixture_path = std::filesystem::path{ARIEC61850_SOURCE_DIR} / + "tests/fixtures/process_bus_csharp_vectors.pcap"; + const auto fixture = read_binary_file(fixture_path); + CHECK(fixture.size() > 40U); + + auto excessive_snap_length = fixture; + excessive_snap_length[16] = 0xFFU; + excessive_snap_length[17] = 0xFFU; + excessive_snap_length[18] = 0xFFU; + excessive_snap_length[19] = 0x7FU; + CHECK(pcap_read_throws(excessive_snap_length)); + + auto excessive_packet_length = fixture; + excessive_packet_length[32] = 0x00U; + excessive_packet_length[33] = 0x00U; + excessive_packet_length[34] = 0x01U; + excessive_packet_length[35] = 0x00U; + CHECK(pcap_read_throws(excessive_packet_length)); +} + +void pcap_analyzer_requires_at_least_one_process_bus_packet() { + using namespace ar::iec61850; + capture::PcapPacket packet; + packet.timestamp = std::chrono::system_clock::time_point{}; + packet.frame = from_hex("FFFFFFFFFFFF020000000003080001020304"); + const auto report = evidence::PcapEquivalenceAnalyzer::analyze( + std::vector{packet}); + CHECK(!report.passed()); + CHECK(report.other_ethernet_packet_count == 1U); + CHECK(report.process_bus_packet_count == 0U); +} + +} // namespace + +int main() { + const std::vector>> tests{ + {"PCAP canonical round trip", pcap_reader_writer_preserves_canonical_process_bus_evidence}, + {"GOOSE and SV PCAP equivalence", pcap_analyzer_reports_exact_goose_and_sv_round_trips}, + {"Malformed process-bus evidence", pcap_analyzer_rejects_malformed_process_bus_packets}, + {"PCAP allocation bounds", pcap_reader_rejects_unbounded_allocation_lengths}, + {"Require process-bus evidence", pcap_analyzer_requires_at_least_one_process_bus_packet}}; + + std::size_t passed = 0U; + for (const auto& [name, test] : tests) { + try { + test(); + ++passed; + std::cout << "[PASS] " << name << '\n'; + } catch (const std::exception& error) { + std::cerr << "[FAIL] " << name << ": " << error.what() << '\n'; + return 1; + } + } + std::cout << "Passed " << passed << '/' << tests.size() + << " evidence tests.\n"; + return 0; +} diff --git a/tests/test_fuzz_smoke.cpp b/tests/test_fuzz_smoke.cpp new file mode 100644 index 000000000..908097e7a --- /dev/null +++ b/tests/test_fuzz_smoke.cpp @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "fuzz_targets.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using ByteVector = std::vector; +using Exercise = std::function; + +ByteVector from_hex(const std::string& text) { + ByteVector result; + result.reserve(text.size() / 2U); + for (std::size_t index = 0U; index + 1U < text.size(); index += 2U) { + result.push_back(static_cast( + std::stoul(text.substr(index, 2U), nullptr, 16))); + } + return result; +} + +ByteVector read_file(const std::filesystem::path& path) { + std::ifstream stream(path, std::ios::binary); + if (!stream) { + throw std::runtime_error("Unable to read fuzz seed: " + path.string()); + } + const std::string bytes{ + std::istreambuf_iterator{stream}, std::istreambuf_iterator{}}; + ByteVector result; + result.reserve(bytes.size()); + for (const char value : bytes) { + result.push_back(static_cast( + static_cast(value))); + } + return result; +} + +std::size_t exercise_mutations(const ByteVector& seed, const Exercise& exercise) { + std::size_t cases = 0U; + exercise(seed); + ++cases; + exercise({}); + ++cases; + + const auto mutation_limit = std::min(seed.size(), 64U); + for (std::size_t index = 0U; index < mutation_limit; ++index) { + auto flipped = seed; + flipped[index] ^= 0xFFU; + exercise(flipped); + ++cases; + + auto zeroed = seed; + zeroed[index] = 0U; + exercise(zeroed); + ++cases; + + exercise(ByteVector{seed.begin(), seed.begin() + static_cast(index)}); + ++cases; + } + return cases; +} + +void deterministic_decoder_mutation_smoke() { + const auto ber = from_hex( + "8301018501FD86012A870508414800008A024F4B8C041234567891086A2B77254000000A"); + const auto goose = from_hex( + "010CCD0100010200000000018100806488B81001005E00000000615480104C44302F4C4C4E30" + "24474F2467636231810203E8820C4C44302F4C4C4E30244453318306474F4F53453184086A2B" + "77254000000A8501038601098701008801028901018A0103AB0A8301018501FD8A024F4B"); + const auto sampled_values = from_hex( + "010CCD040001020000000002810080C888BA4001006800000000" + "605E800101A259305780134D55303146312F4C4C4E30244D535643423031" + "81144D55303146312F4C4C4E30245068734D65617331820178830103" + "84086A2BDFE40000000085010286020FA087100000006400000001000000C8" + "00000003880101"); + const auto pcap = read_file( + std::filesystem::path{ARIEC61850_SOURCE_DIR} / + "tests/fixtures/process_bus_csharp_vectors.pcap"); + + std::size_t cases = 0U; + cases += exercise_mutations(ber, [](const ByteVector& bytes) { + ar::iec61850::fuzzing::exercise_ber(bytes); + }); + cases += exercise_mutations(goose, [](const ByteVector& bytes) { + ar::iec61850::fuzzing::exercise_goose(bytes); + }); + cases += exercise_mutations(sampled_values, [](const ByteVector& bytes) { + ar::iec61850::fuzzing::exercise_sampled_values(bytes); + }); + cases += exercise_mutations(pcap, [](const ByteVector& bytes) { + ar::iec61850::fuzzing::exercise_pcap(bytes); + }); + + if (cases < 500U) { + throw std::runtime_error("Mutation smoke corpus was unexpectedly small."); + } + std::cout << "Exercised " << cases + << " deterministic decoder mutation cases.\n"; +} + +} // namespace + +int main() { + try { + deterministic_decoder_mutation_smoke(); + std::cout << "[PASS] deterministic decoder mutation smoke\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "[FAIL] deterministic decoder mutation smoke: " + << error.what() << '\n'; + return 1; + } +} diff --git a/tools/pcap_interop_check.cpp b/tools/pcap_interop_check.cpp new file mode 100644 index 000000000..1f532e7f9 --- /dev/null +++ b/tools/pcap_interop_check.cpp @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/evidence/pcap_equivalence.hpp" + +#include +#include +#include +#include + +namespace { + +void print_usage(const char* program) { + std::cerr << "Usage: " << program << " [--json]\n"; +} + +} // namespace + +int main(const int argc, char** argv) { + if (argc < 2 || argc > 3) { + print_usage(argv[0]); + return 64; + } + + const bool json = argc == 3 && std::string{argv[2]} == "--json"; + if (argc == 3 && !json) { + print_usage(argv[0]); + return 64; + } + + try { + const auto report = ar::iec61850::evidence::PcapEquivalenceAnalyzer::analyze( + std::filesystem::path{argv[1]}); + if (json) { + std::cout << ar::iec61850::evidence::PcapEquivalenceAnalyzer::to_json(report); + } else { + std::cout << "PCAP packets: " << report.packet_count << '\n' + << "GOOSE: " << report.goose_packet_count << '\n' + << "Sampled Values: " << report.sampled_values_packet_count << '\n' + << "Other Ethernet: " << report.other_ethernet_packet_count << '\n' + << "Malformed process-bus/Ethernet: " + << report.malformed_packet_count << '\n' + << "Exact decode/re-encode matches: " + << report.exact_reencode_match_count << '\n' + << "Decode/re-encode mismatches: " + << report.reencode_mismatch_count << '\n' + << "Canonical PCAP round trip: " + << (report.canonical_pcap_round_trip_match ? "PASS" : "FAIL") << '\n' + << "Overall evidence result: " + << (report.passed() ? "PASS" : "FAIL") << '\n'; + } + return report.passed() ? 0 : 2; + } catch (const std::exception& error) { + std::cerr << "PCAP analysis failed: " << error.what() << '\n'; + return 1; + } +}