Skip to content
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ jobs:
run: |
python .\ArIED61850Tester\scripts\test-maintenance-dependencies.py
if ($LASTEXITCODE -ne 0) { throw "Maintenance inventory tests failed." }
python .\ArIED61850Tester\scripts\test-verify-p10-ge-field-scl.py
if ($LASTEXITCODE -ne 0) { throw "P10 GE trusted-IID SCL field gate fixture regressions failed." }
python .\ArIED61850Tester\scripts\audit-maintenance-dependencies.py --root .\ArIED61850Tester --output "$env:RUNNER_TEMP\arsas-maintenance-dependencies.json"
if ($LASTEXITCODE -ne 0) { throw "Maintenance inventory generation failed." }

Expand Down
46 changes: 46 additions & 0 deletions docs/P10_GE_ED1_FIELD_ACCEPTANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# P10.3/P10.4 — GE F650 Edition 1 physical-evidence procedure

**Code authority:** ARSAS staging P10.2 commit \`2369c286da6d14dda2470a2854daf2d46d588dfe\` and engine lock \`00ad2819b99ffe6b3f885e59aa24a6afb2b4d8e1\`. No rebasing to old main and no substitution of engine PR #160. The artifact built by the exact staging [Build ARSAS #38105708427](https://github.com/masarray/arsas/actions/runs/38105708427) is the P10.3 regression-qualified **candidate**, not field-qualified release.

## Trust and safety gates

- Use **an independently supplied original** \`BCU_GE.iid\`, not an SCL generated by ARSAS and later renamed as engineering authority. Record its **actual file SHA-256** before opening, and preserve the source unchanged. The source and generated SCL must be separate files.
- Observe real MMS session **192.16.1.33:102** on the authorized substation network, from a qualified operator computer. Do not use direct device control operations or write data to create test traffic.
- In ARSAS, **Open SCL** original IID and select the actual \`BCUGE\` / AccessPoint. This establishes SHA-bound trusted identity. Then Discovery on the same endpoint, without clearing the source-association context. If live workflow creates a new card, ensure the trusted SCL remains bound to that card before deciding identity is high-confidence.
- Discovery must show IED \`BCUGE\`, \`LDevice.inst=F650\`, and observed *raw* MMS domain \`BCUGEF650\`. Without trusted exact SCL evidence, provisional \`IED_192_16_1_33\` is safer than heuristically guessing a device name.
- Select **Static DataSet** (not dynamic/polling), confirm 8 static DataSets, 124 ordered members, 67 selected runtime points and six concrete RCB streams with actual InformationReport traffic. GI and report-backed causality must be observed. **No cyclic MMS process-value reads** and no auto fallback.
- **Save SCL** in full-model Edition 1, then **Open SCL** the saved file and reconnect using the reloaded model; collect separate before/after diagnostics, SCL report files and PCAP (where permitted). The existing \`CanonicalSclReloadValidator\` checks internal reload and association structure; our offline check below independently compares to the original SHA-pinned IID.
- Test BRCB continuity in its **own** session evidence: timestamp, RCB, BufOvfl, SqNum/SubSqNum, EntryID, GI reason and replay after reconnect. Treat missing/ambiguous fields as incomplete, not zero faults.
- Evaluate design-only LN/DO/DA structures, observed GetNameList cap 6400 and \`incompleteChains\` separately. 8/8 DataSets is not proof of full model closure. GOOSE bindings/addresses also require separate verification.

## Read-only offline SCL verification

On the authorized local computer, use Python 3 already used by ARSAS CI (no additional libraries). In PowerShell:

\`\`\`powershell
$iid = 'C:\field\BCU_GE.iid'
$export = 'C:\field\BCUGE-discovery.icd'
$sha = '<64-character SHA256 copied from original independent intake record>'
python .\scripts\verify-p10-ge-field-scl.py \`
--trusted-iid $iid \`
--trusted-sha256 $sha \`
--discovery-scl $export \`
--observed-mms-domain BCUGEF650 \`
--endpoint-ip 192.16.1.33 \`
--access-point S1 \`
--out 'C:\field\p10-ge-structural-evidence.json'
\`\`\`

Replace \`S1\` with the **actual trusted** AccessPoint if different. Do not derive the expected trusted hash from the candidate file during the same verification run; compare with the independently recorded original intake hash. The observed-domain argument is an operator-supplied claim; save independent device logs/PCAP proving that claim. The tool hashes and checks both input files, is strictly offline, does not connect to a relay, and has no write path other than an explicitly requested JSON report. It fails closed on missing source SHA, ambiguous AP/IED, wrong IP, changed MMS wire-domain, missing LD/LN, or drift in ordered DataSet/FCDA members. It compares engineering-vs-export logical RCB and GOOSE definitions and reports them separately for review. It cannot infer unobserved MMS values, packet continuity, or physical certification.

Possible structural outcomes: \`STRUCTURE_PASS_ONLY\`, \`FAIL\`, or \`REJECTED_INPUT\`. Even the passing case **always** has \`physicalQualification=NOT_VERIFIED\` and \`r10PromotionAllowed=false\`. It is **never** a substitute for actual IEC 61850 field evidence.

The JSON records input SHA, structural discrepancies and explicitly lists all unverified physical gates. Do **not** modify \`evidence/interoperability-reference-target.json\` or change CI approval flags from this output.

## Field package to attach for P10.4 review

Preserve original IID plus original SHA256, exported ICD/IID, generated export report, full ARSAS Diagnostics from Discovery and Open SCL, packet capture (if allowed), and RCB continuity evidence with timestamps for both sessions. If PCAP contains sensitive operational data, share a redacted engineering-safe version but retain original hash/provenance in the field record.

### Exit criteria

Only mark P10.4 physically accepted after: verified field provenance; correct BCUGE/F650/BCUGEF650 mapping; 8/124 member match; report-only 67 runtime / 6 active RCB with real traffic; no cyclic fallback; Save→Open SCL endpoint/association round-trip; independently reviewed LN/DO/DA/name-list completeness; and independently reviewed BRCB replay/GI continuity. No claim that successful CI or this offline tool satisfies those conditions.
175 changes: 175 additions & 0 deletions scripts/test-verify-p10-ge-field-scl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Offline P10.4 GE field SCL gate regression: no hardware or network required."""
import hashlib
import importlib.util
import tempfile
import unittest
from pathlib import Path
from xml.etree import ElementTree as ET

SCRIPT = Path(__file__).with_name("verify-p10-ge-field-scl.py")
spec = importlib.util.spec_from_file_location("p10_ge_gate", SCRIPT)
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
N = gate.NS
T = gate.TAG


def example(*, ip="192.16.1.33", ied="BCUGE", inst="F650",
ap="S1", wire_ld_name=None, members=124, remove_ln=False):
ET.register_namespace("", N)
root = ET.Element(T("SCL"))
comm = ET.SubElement(root, T("Communication"))
subnet = ET.SubElement(comm, T("SubNetwork"))
conn = ET.SubElement(subnet, T("ConnectedAP"),
{"iedName": ied, "apName": ap})
address = ET.SubElement(conn, T("Address"))
ET.SubElement(address, T("P"), {"type": "IP"}).text = ip
node = ET.SubElement(root, T("IED"), {"name": ied})
access = ET.SubElement(node, T("AccessPoint"), {"name": ap})
server = ET.SubElement(access, T("Server"))
attrs = {"inst": inst}
if wire_ld_name:
attrs["ldName"] = wire_ld_name
ld = ET.SubElement(server, T("LDevice"), attrs)
ln = ET.SubElement(ld, T("LN0"), {"lnClass": "LLN0"})
if not remove_ln:
ET.SubElement(ld, T("LN"), {"lnClass": "CSWI", "inst": "7"})
for index in range(8):
ds = ET.SubElement(ln, T("DataSet"), {"name": f"DS{index + 1}"})
# 4*16+4*15=124: non-overlapping ordered FCDA evidence.
count = (16 if index < 4 else 15)
if members != 124 and index == 0:
count += members - 124
for subindex in range(max(count, 0)):
ET.SubElement(ds, T("FCDA"),
{"ldInst": inst, "lnClass": "CSWI",
"lnInst": "7", "doName": f"Pos{subindex}",
"daName": "stVal", "fc": "ST"})
if index < 6:
ET.SubElement(ln, T("ReportControl"),
{"name": f"RCB{index + 1}", "datSet": f"DS{index + 1}",
"buffered": "true"})
return ET.tostring(root, encoding="utf-8", xml_declaration=True)


class GeFieldEvidenceTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.src = Path(self.temp.name, "BCU_GE.iid")
self.out = Path(self.temp.name, "BCUGE-discovery.icd")
self.src.write_bytes(example())
self.out.write_bytes(example())

def check(self, sha=None, domain="BCUGEF650"):
return gate.assess(
self.src, self.out,
sha or hashlib.sha256(self.src.read_bytes()).hexdigest(),
domain, "192.16.1.33", "S1")

def test_valid_structure_is_explicitly_not_physical_qualification(self):
result = self.check()
self.assertEqual("STRUCTURE_PASS_ONLY", result["structuralVerdict"])
self.assertEqual(8, result["dataSets"])
self.assertEqual(124, result["staticMembers"])
self.assertEqual("NOT_VERIFIED", result["physicalQualification"])
self.assertFalse(result["r10PromotionAllowed"])
self.assertTrue(any("BRCB" in s for s in result["unverifiedPhysicalGates"]))

def test_source_sha_drift_fails_closed(self):
with self.assertRaisesRegex(gate.RejectedEvidence, "SHA-256 mismatch"):
self.check("0" * 64)

def test_same_scl_file_fails_closed(self):
with self.assertRaisesRegex(gate.RejectedEvidence, "same file"):
gate.assess(self.src, self.src,
hashlib.sha256(self.src.read_bytes()).hexdigest(),
"BCUGEF650", "192.16.1.33", "S1")

def test_wrong_observed_wire_domain_fails_even_if_scl_graph_equal(self):
self.assertEqual("FAIL", self.check(domain="BCUGECTRL")["structuralVerdict"])

def test_wrong_endpoint_rejected(self):
self.out.write_bytes(example(ip="192.16.1.34"))
self.assertEqual("FAIL", self.check()["structuralVerdict"])

def test_ipless_scl_is_explicit_review_not_invented_endpoint(self):
root = ET.fromstring(example())
root.find(f".//{T('Address')}").clear()
self.out.write_bytes(ET.tostring(root))
result = self.check()
self.assertEqual("STRUCTURE_PASS_ONLY", result["structuralVerdict"])
self.assertTrue(any("no authoritative ConnectedAP IP" in s for s in result["review"]))

def test_f650_must_not_become_mms_domain_inst(self):
self.out.write_bytes(example(inst="BCUGEF650"))
self.assertEqual("FAIL", self.check()["structuralVerdict"])

def test_extra_explicit_ldname_is_detected(self):
self.out.write_bytes(example(wire_ld_name="OTHER_WIRE_DOMAIN"))
self.assertEqual("FAIL", self.check()["structuralVerdict"])

def test_missing_static_member_fails(self):
self.out.write_bytes(example(members=123))
self.assertEqual("FAIL", self.check()["structuralVerdict"])

def test_member_order_change_fails_not_just_count(self):
root = ET.fromstring(example())
ds = root.find(f".//{T('DataSet')}")
ds[0], ds[1] = ds[1], ds[0]
self.out.write_bytes(ET.tostring(root))
self.assertEqual("FAIL", self.check()["structuralVerdict"])

def test_fcda_unknown_ln_rejected(self):
self.out.write_bytes(example(remove_ln=True))
with self.assertRaisesRegex(gate.RejectedEvidence, "unknown LN"):
self.check()

def test_no_false_rcb_continuity_from_matching_logical_rcbs(self):
result = self.check()
self.assertEqual(6, result["logicalReportControls"]["discoveryExport"])
self.assertEqual("NOT_VERIFIED", result["physicalQualification"])

def test_different_rcb_configuration_is_review_not_physical_pass(self):
root = ET.fromstring(example())
root.find(f".//{T('ReportControl')}").set("buffered", "false")
self.out.write_bytes(ET.tostring(root))
result = self.check()
self.assertEqual("STRUCTURE_PASS_ONLY", result["structuralVerdict"])
self.assertTrue(any("Logical ReportControl" in s for s in result["review"]))
self.assertFalse(result["r10PromotionAllowed"])

def test_source_extra_design_ln_is_review_not_dataset_failure(self):
root = ET.fromstring(example())
ld = root.find(f".//{T('LDevice')}")
ET.SubElement(ld, T("LN"), {"lnClass": "MMXU", "inst": "1"})
self.src.write_bytes(ET.tostring(root))
result = self.check()
self.assertEqual("STRUCTURE_PASS_ONLY", result["structuralVerdict"])
self.assertTrue(any("LN design/live scope differs" in s for s in result["review"]))

def test_external_ied_fcda_is_not_silently_treated_as_local(self):
root = ET.fromstring(example())
root.find(f".//{T('FCDA')}").set("iedName", "DIFFERENT_IED")
self.out.write_bytes(ET.tostring(root))
with self.assertRaisesRegex(gate.RejectedEvidence, "external IED"):
self.check()

def test_dtd_and_entities_rejected(self):
evil = b'<!DOCTYPE SCL [<!ENTITY x "abc">]><SCL xmlns="' + N.encode() + b'"/>'
self.out.write_bytes(evil)
with self.assertRaisesRegex(gate.RejectedEvidence, "DTD/ENTITY"):
self.check()

def test_ambiguous_accesspoint_rejected(self):
root = ET.fromstring(example())
access = root.find(f".//{T('AccessPoint')}")
root.find(f".//{T('IED')}").append(ET.fromstring(ET.tostring(access)))
self.out.write_bytes(ET.tostring(root))
with self.assertRaisesRegex(gate.RejectedEvidence, "AccessPoint"):
self.check()


if __name__ == "__main__":
unittest.main()
Loading
Loading