Chapter 28 — OT, IoT and cyber-physical security: implementation I
Lesson map
Lesson 28 published. Map, example, EX082.
flowchart TD
N0["Learning outcomes"]
N1["28.1 Implementation shape"]
N2["28.2 Clause tests"]
N3["28.3 Logging"]
N4["Practice note"]
N5["28.1b Reason catalogue and fuzzing"]
N6["Field note — fuzz findings"]
N7["Synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> N6 --> N7
N7 --> EX["Worked example"]
EX --> Q["Exercise EX082"]
Logical drawing
flowchart LR
N0["Learning outcomes"]
N1["28.1 Implementation shape"]
N2["28.2 Clause tests"]
N3["28.3 Logging"]
N4["Practice note"]
N5["28.1b Reason catalogue and fuzzing"]
N6["Field note — fuzz findings"]
N7["Synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> N6 --> N7 --> Q["EX082"]
[Learning outcomes]
|
v
[28.1 Implementation shape]
|
v
[28.2 Clause tests]
|
v
[28.3 Logging]
|
v
[Practice note]
|
v
[28.1b Reason catalogue and fuzzing]
|
v
[Field note — fuzz findings]
|
v
[Synthesis]
|
v
[Worked example] --> [Exercise EX082]
Learning outcomes
You will (1) implement permit() with explicit reason codes; (2) add tests for each clause; (3) log without storing secrets.
28.1 Implementation shape
Pure function: inputs source, function code, register, AllowListVersion to (allow: bool, reason: str). No network I/O inside the predicate. That keeps tests fast and free of plant contact.
Lena's permit function at ABP follows this shape exactly: it takes the decoded frame fields and the allow-list version and returns a decision with a reason. Loading the allow-list and writing logs happen outside the function, so its tests run in milliseconds on synthetic data and never risk contact with the plant.
28.2 Clause tests
One test file per clause: station allow, function code, register range, policy-unavailable. Synthetic fixtures only. Mutation: break a clause and watch the test fail.
"""Lab: Modbus function-code allow-list on synthetic, already-decoded requests (no network)."""
ALLOW = {("HMI-1", 3), ("HMI-1", 6), ("HIST-1", 3), ("HIST-1", 4)} # (source, function code)
WRITE_REGS = {("HMI-1", 6): range(40001, 40011)} # HMI may write setpoints only
reqs = [("HMI-1", 3, 40001), ("HMI-1", 6, 40003), ("HMI-1", 6, 40050),
("HIST-1", 3, 30001), ("HIST-1", 16, 40001), ("ENG-WS", 8, 0), ("HIST-1", 4, 30010)]
for src, fc, reg in reqs:
if (src, fc) not in ALLOW:
d = "BLOCK fc-not-allowed"
elif (src, fc) in WRITE_REGS and reg not in WRITE_REGS[(src, fc)]:
d = "BLOCK register-out-of-range"
else:
d = "PASS"
print(f"{src:7} fc={fc:2} reg={reg:5} -> {d}")
Expected output:
HMI-1 fc= 3 reg=40001 -> PASS
HMI-1 fc= 6 reg=40003 -> PASS
HMI-1 fc= 6 reg=40050 -> BLOCK register-out-of-range
HIST-1 fc= 3 reg=30001 -> PASS
HIST-1 fc=16 reg=40001 -> BLOCK fc-not-allowed
ENG-WS fc= 8 reg= 0 -> BLOCK fc-not-allowed
HIST-1 fc= 4 reg=30010 -> PASS
Lena keeps one test file per clause for ABP's permit function, with fixtures named after the requirement they check. Mutation testing confirmed each file fails when its clause is broken: deleting the register-range check, for example, made the out-of-range tests fail at once. A clause whose tests survive a mutation is treated as untested.
28.3 Logging
Log decision, reason, policy version, actor. Do not log passwords or full frame payloads that might contain proprietary recipe data. Redact as needed.
Synthetic case — Ashford Batch Plant. Implementation I shipped reason codes that the timeline UI shows — operators finally saw why a write was blocked at 03:12.
Lena's logs show which policy version made each decision. Frame payloads are left out because they can contain recipe values. Lena checks a sample of logs after each release to confirm nothing sensitive slipped in.
28.4 Errors and limits
Stringly-typed reasons drift; use an enum shared with the coverage board.
Lena's implementation uses a shared list of reason codes, such as register-out-of-range and policy-unavailable, published as a versioned document. The coverage board and the timeline view read the same list, so a new code cannot appear in logs without being understood elsewhere. Consumers that see an unknown code treat it as a deny and raise an alert for the tool owner. The document is reviewed with each predicate change.
Practice note
Fuzz the predicate with random stations, codes and broken UTF-8. It must DENY or error closed, never ALLOW on parse failure.
28.1b Reason catalogue and fuzzing
The catalogue lists, for each reason code, the clause that emits it and the test that pins it: fc-not-allowed, register-out-of-range, policy-unavailable, station-unknown. Fuzz inputs that once found a bug join a regression list that is replayed on every release, including the UTF-8 station name that once crashed the decoder.
Lena's review checklist for a monitor predicate change covers the new clause, new tests, reason-code docs, a fuzz case and fail-closed behaviour on a parse error. A reviewer who approves a predicate change without the list has checked the author, not the change.
Lena's checklist is attached to every predicate review.
Field note — fuzz findings
A five-minute fuzz harness threw a UTF-8 station name that previously crashed the decoder into an uncaught exception — which the caller treated as ALLOW. The fix returned DENY parse-error and the harness now runs in CI on every predicate change.
28.5 Worked example — a clause test that caught a real bug (synthetic ABP)
Each clause of the permit function gets its own test: source allowed, function code allowed, register in range, default deny. Lena's fuzzing harness then feeds 10,000 random (source, function, register) triples drawn from a seeded generator and asserts that every PASS satisfies all clauses. On the first run it found that function code 23 (read/write multiple registers) passed for HMI-1 because the implementation checked only the read half of the request. The fix rejects 23 unless explicitly listed, a clause test now pins it, and the incident note records which seed reproduced the failure, so anyone can rerun it.
Exercises
- EX082 — Signature. Type signature of permit and why AllowListVersion is an argument. Rubric: 3 = signature + version pin reason; 2 = signature; 1 = vague; 0 = none.
- EX083 — Mutation. Which test fails if register range is ignored? Rubric: 3 = 40050 expects BLOCK; 2 = vague; 1 = wrong; 0 = none.
- EX084 — Redact. Two log fields to redact. Rubric: 3 = e.g. passwords, full recipe-bearing payloads; 2 = one; 1 = vague; 0 = none.
Synthesis
Pure predicate, clause tests, safe logs.
Continue studying — chapter 28 of 50
← Chapter 27 (draft) · Course chapter index · Chapter 29 (draft) →