Skip to content

ICS/OT Security Operations

ICS/OT security operations defends the systems that move physical matter: SCADA servers, PLCs, HMIs, historian databases, and the field devices that open valves and trip breakers. The structure comes from the Purdue model — the leveled hierarchy (Enterprise → DMZ → Supervisory → Control → Field) mapping trust zones onto process criticality — and its doctrine inverts IT defaults: availability beats confidentiality (a stopped plant is the incident), safety is first (a control that can trip a process is itself a hazard), change is exceptional (OT firmware runs for decades; BT11’s patch cadence doesn’t exist), and you cannot sensor everything (legacy protocols — Modbus, DNP3, S7comm — carry no authentication and no logs; detection rides on passive inspection and baseline deviation). The craft’s center: safety-aware IT discipline grafted onto physics — windows of opportunity are maintenance shutdowns, testing means a simulated process, and every recommendation speaks process-engineer language (downtime, batch integrity, SIS independence).

The OT threat is not theoretical: malware reaching controllers through IT pivots and vendor-remote-access abuse have moved from research papers to disruption events, and attack classes are documented down to protocol level — Modbus function-code abuse, process-command injection, SIS manipulation. Convergence makes it urgent: IT/OT flattening for analytics, Windows HMIs on the plant floor, remote vendor portals, cloud historians — every BT16 lesson applies, with the twist that a detection’s response can never be “isolate the host first” when the host runs a production line. The mission in engineering terms: keep the process safe and available while making intrusions visible early — hence the discipline’s artifacts: the zone model, the passive baseline, the maintenance-window change process.

The failure modes are convergence-shaped. The flattened flat: “temporary” IT/OT links — the Purdue model as a poster while the L2/L3 firewall has any-any (BT13’s shadow rules, with physical consequences). Agent-native thinking: IT endpoint agents on HMIs and controllers — the agent hogging 15% CPU on a millisecond-timing machine is itself the outage; OT sensing is passive-first. The unmanaged jump box: vendor remote access via a shared TeamViewer-class credential — the most common real OT intrusion path [verify current reporting]. Availability myopia: controls evaluated only for what they block, never what they delay (the scan that delays a batch 40 minutes failed the SLA). And the paper SIS: claiming the Safety Instrumented System as compensation without verifying independence — SIS sharing a network or workstation with basic control is not a compensating control.

PURDUE LEVELS (the zone model OT defends along)
L4 Enterprise IT: ERP, analytics (BT16 territory)
L3.5 DMZ patch servers, historians mirror, jump hosts
-------- FIREWALL: the IT/OT boundary — one door, monitored --------
L3 Site Ops historians, domain (often), ops servers
L2 Supervisory SCADA servers, HMIs, engineering stations
L1 Control PLCs, RTUs, DCS controllers
L0 Field sensors, actuators, drives (physics lives here)
---- SIS runs beside L1/L0 but MUST stay independent ----
OT DETECTION STACK (passive-first)
passive collectors (SPAN/TAP) -> protocol-aware IDS (Modbus/DNP3/S7
function codes, new-device alerts, command deviation) -> OT SIEM
join (BT3) -> response = NOTIFY + MAINTENANCE-WINDOW plan (never
auto-block a controller)

The zone model is the architecture: each Purdue level is a trust zone; the IT/OT boundary (L3.5) is the single monitored door (recorded jump hosts, one-way historian flows — data diodes where warranted [verify current classes], patch servers staging inside); inter-level firewalls enforce function-code-aware rules (Modbus and S7comm ride 502/102 — the function codes are the policy unit); and the SIS is verified independent on the wire — separate network, separate controllers, no shared workstation. BT13’s segaudit discipline applies with an OT dictionary: the intended matrix is the Purdue flows, and shadow rules are how flattening happens.

Passive-first detection is the answer to unagentable devices: collector taps (SPAN ports, hardware TAPs — never an inline box that can fail the process) feed protocol-aware sensors that baseline the plant’s conversation — which PLC talks to which, which function codes are normal, what’s read vs written — and alert on deviation: a new device, a write where only reads existed, a command sequence matching no batch recipe, a firmware push outside a window. The baselining discipline mirrors BT7’s but with physics as ground truth: process engineers validate the baseline (“yes, valve writes happen every 30 seconds”), because a technically-plausible baseline that doesn’t match the process burns the OT team’s trust in month one.

Change management is the patch strategy: OT patching happens in maintenance windows (planned shutdowns, quarter-turnarounds), which means vulnerability management (BT11’s clock) inverts — you cannot patch to the SLA, so the compensating architecture carries the risk: segmentation that makes the vulnerable controller unreachable from everywhere except engineering, monitoring that watches the known-exploited list for your controller families, and the change process that batches patches into the window with rollback plans tested against the simulated process (the OT test bench — a replica of the control loop on real hardware or a digital twin, because “test in staging” for a PLC means testing the process, not the software install).

Deep dive: the SIS question — why “the safety layer will catch it” is usually a lie

When OT security reviews stall, someone offers the SIS as the compensating control: “even if the attacker reaches the controller, the Safety Instrumented System trips the process before damage.” That claim is only true if the SIS is genuinely independent — separate controllers, separate networks, separate engineering workstations, separate credentials — and in converged estates it frequently is not: the SIS shares a switch with the BCS, its engineering station dual-homes to both networks, its logic was last reviewed when the plant was commissioned. The verification is a wire check, not a document: trace the SIS network physically (does any path from BCS reach SIS? any shared switch, any dual-homed host, any remote-access path into the safety controllers?), verify the SIS has its own monitoring (passive — observation never violates independence), and confirm the safety logic’s last validation date. A dependent SIS is not a compensating control; it is the same single point of failure wearing a safety hat — and the discipline to say so in the review, with the wire diagram as evidence, is what “safety-first” means operationally in this discipline.

TechniqueWhat it doesWhere it failsNotes
Purdue zone enforcementFunction-code-aware boundary rulesany-any at the IT/OT doorsegaudit, OT dictionary
Passive protocol IDSFunction-code/deviation detectionInline boxes that can fail processTAP-only deployment
Process-validated baselinesEngineers confirm normal trafficTech-plausible, process-wrongMonth-one trust killer
Jump-host disciplineRecorded, single-purpose vendor accessShared TeamViewer credsThe common intrusion path
Maintenance-window patchingBatched, tested, rolled-backIT clock expectationsBT11 inverted
SIS independence verificationSafety layer separate from BCSPaper SIS as compensatingSafety-first doctrine
One-way data flowsHistorians read without write-back“Temporary” write pathsDiodes where warranted
Asset inventory (OT edition)Every controller, firmware, recipeUnknown legacy devicesH3’s firmware join
Simulated-process testingTest bench for changes/detectionsDetections untested vs physicsT-series discipline, OT
Response = notify + windowNever auto-block a controllerIT reflex isolationAvailability doctrine

otguard — an OT network baseline and deviation detector: reads a passive-capture export (CSV: ts, src, dst, proto_port, function_code, detail — from a TAP collector or protocol dissector), builds the per-pair function-code baseline from a reference period, then scores the monitoring period: new device pairs, never-seen function codes per pair, write-to-read ratio shifts, and out-of-window firmware pushes — the passive detection layer’s core, fail-loud on empty captures.

#!/usr/bin/env python3
"""otguard — OT network baseline + deviation detection (passive export).
Inputs:
baseline.csv: ts,src,dst,proto_port,function_code,detail
(reference period, e.g. 30 days of TAP capture)
current.csv: same columns (monitoring period, e.g. last 24h)
windows.csv: start,end,label (maintenance windows: firmware pushes,
recipe changes allowed here)
Verdicts: NEW-PAIR, NEW-FUNCTION, WRITE-RATIO-SHIFT, OUT-OF-WINDOW-WRITE.
"""
import argparse
import csv
import sys
from collections import defaultdict
from datetime import datetime
from pathlib import Path
REQ = ["ts", "src", "dst", "proto_port", "function_code", "detail"]
WRITE_HINTS = ("write", "writeregister", "writemultiple", "control",
"command", "setpoint")
def fail(msg):
sys.exit(f"fail-loud: {msg}")
def load(path, label):
if not path.exists():
fail(f"{label} not found: {path}")
with path.open(encoding="utf-8", errors="replace") as fh:
reader = csv.DictReader(fh)
missing = [c for c in REQ if c not in (reader.fieldnames or [])]
if missing:
fail(f"{label} missing columns: {missing}")
rows = list(reader)
if not rows:
fail(f"{label} empty — a TAP that captured nothing is a TAP problem")
for r in rows:
r["fc"] = r["function_code"].strip().lower()
r["pair"] = (r["src"].strip().lower(), r["dst"].strip().lower())
return rows
def is_write(fc):
return any(h in fc for h in WRITE_HINTS)
def load_windows(path):
out = []
if not path.exists():
return out
with path.open(encoding="utf-8", errors="replace") as fh:
for r in csv.DictReader(fh):
try:
s = datetime.fromisoformat(r["start"].replace("Z", "+00:00"))
e = datetime.fromisoformat(r["end"].replace("Z", "+00:00"))
except (KeyError, ValueError):
fail(f"windows: unparseable row {r}")
out.append((s, e, r.get("label", "maintenance")))
return out
def in_window(ts, windows):
return any(s <= ts <= e for s, e, _ in windows)
def main():
ap = argparse.ArgumentParser(description="OT baseline deviation")
ap.add_argument("baseline", type=Path)
ap.add_argument("current", type=Path)
ap.add_argument("--windows", type=Path, default=None)
ap.add_argument("--ratio-shift", type=float, default=2.0,
help="write-ratio multiplier that flags (default 2x)")
ap.add_argument("--out", type=Path, default=Path("otguard_out"))
args = ap.parse_args()
base = load(args.baseline, "baseline")
cur = load(args.current, "current")
windows = load_windows(args.windows) if args.windows else []
# baseline models
base_pairs = defaultdict(set) # pair -> set(function codes)
base_writes = defaultdict(lambda: [0, 0]) # pair -> [writes, total]
for r in base:
base_pairs[r["pair"]].add(r["fc"])
base_writes[r["pair"]][1] += 1
if is_write(r["fc"]):
base_writes[r["pair"]][0] += 1
cur_pairs = defaultdict(set)
cur_writes = defaultdict(lambda: [0, 0])
cur_events = []
for r in cur:
cur_pairs[r["pair"]].add(r["fc"])
cur_writes[r["pair"]][1] += 1
if is_write(r["fc"]):
cur_writes[r["pair"]][0] += 1
try:
r["_ts"] = datetime.fromisoformat(
r["ts"].replace("Z", "+00:00"))
except ValueError:
fail(f"unparseable ts {r['ts']!r}")
cur_events.append(r)
findings = []
for pair, fcs in sorted(cur_pairs.items()):
src, dst = pair
known = base_pairs.get(pair)
if known is None:
findings.append({"kind": "NEW-PAIR", "src": src, "dst": dst,
"detail": f"device pair never seen in baseline "
f"({len(fcs)} function codes)"})
continue
for fc in sorted(fcs - known):
if fc in ("", "read"):
continue
findings.append({"kind": "NEW-FUNCTION", "src": src, "dst": dst,
"detail": f"function code {fc!r} never observed "
"for this pair"})
# write-ratio shift (only pairs with meaningful volume both periods)
bw, bt = base_writes[pair]
cw, ct = cur_writes[pair]
if bt >= 50 and ct >= 20 and bt > 0:
base_ratio = bw / bt
cur_ratio = cw / ct
if base_ratio < 0.05 and cur_ratio >= 0.05:
findings.append({"kind": "WRITE-RATIO-SHIFT", "src": src,
"dst": dst,
"detail": f"read-only pair now writing "
f"({cw}/{ct} events)"})
elif base_ratio > 0 and cur_ratio >= base_ratio * args.ratio_shift:
findings.append({"kind": "WRITE-RATIO-SHIFT", "src": src,
"dst": dst,
"detail": f"write ratio {base_ratio:.0%} -> "
f"{cur_ratio:.0%}"})
# out-of-window writes: write-class events outside maintenance windows
for r in cur_events:
if is_write(r["fc"]) and not in_window(r["_ts"], windows):
findings.append({"kind": "OUT-OF-WINDOW-WRITE", "src": r["src"],
"dst": r["dst"],
"detail": f"{r['fc']} at {r['ts']} — writes "
"belong in maintenance windows"})
findings.sort(key=lambda f: (f["kind"], f["src"], f["dst"]))
args.out.mkdir(parents=True, exist_ok=True)
with (args.out / "findings.csv").open("w", newline="",
encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=["kind", "src", "dst", "detail"])
w.writeheader()
w.writerows(findings)
kinds = defaultdict(int)
for f in findings:
kinds[f["kind"]] += 1
print(f"baseline pairs={len(base_pairs)} current pairs="
f"{len(cur_pairs)} events={len(cur)}")
print(f"findings: {dict(kinds) if kinds else 'none — baseline holds'}")
for f in findings[:12]:
print(f" [{f['kind']:<20}] {f['src']} -> {f['dst']}: "
f"{f['detail'][:56]}")
print(f"register: {args.out / 'findings.csv'}")
print("next: process engineers confirm deviations (the physics check) "
"before any response action")
if __name__ == "__main__":
main()

The two-baseline design (lines 41-52) mirrors BT7’s rare-process model with an OT dictionary: base_pairs (which device pairs converse, with which function codes) and base_writes (read/write posture per pair) — because in OT writes are the dangerous class (reads leak; writes change physics), every finding either introduces a pair, a function code, or a write-ratio shift. The write classifier (lines 26-28) is hint-based on the dissector’s normalized names (“write”, “setpoint”, “command”), deliberately conservative: false write-classifications flood the out-of-window findings and burn trust.

The ratio-shift logic (lines 91-105) carries the module’s sharpest insight: a read-only pair (the historian polling a PLC) that starts writing is the intrusion signature — regardless of volume, because base_ratio < 0.05 and cur_ratio >= 0.05 catches the class transition; the volume-relative check (cur_ratio >= base_ratio * 2) catches the gradual escalation. Both require minimum volumes (bt >= 50 and ct >= 20, line 91) so a quiet pair’s single write doesn’t fire on statistical noise — the physics check belongs to the engineers, the statistics check belongs here. The out-of-window sweep (lines 107-114) applies the maintenance-window doctrine: writes outside declared windows are findings even when the pair and function codes are perfectly normal — because the window is when authorized change happens, and the plant that writes outside it has either an unauthorized change or a broken window register (both are findings). The closing instruction (line 130) states the response doctrine: engineers confirm the physics before any action — the tool finds deviations; the process decides whether a deviation is an intrusion or a recipe change nobody logged.

The weekly rhythm: TAP collectors feed the export; otguard runs deviations against the rolling 30-day baseline; findings go to the Tuesday review where analyst and process engineer both read them. Most weeks: a new pair (the maintenance team’s portable analyzer — attributed, baselined), a firmware push inside its window, zero ratio shifts — the green week is itself the metric. The month it matters: the historian pair (read-only for two years) shows a write — the engineer confirms nobody deployed it; the source is the vendor’s remote-support host; the jump-host recording shows the vendor’s engineer “testing something” — an unauthorized write through the vendor path, caught in 20 hours instead of at the next shutdown.

The escalation respects the doctrine: no isolation reflex — the engineer checks written values against the recipe (batch records: no process change), vendor access suspends at the jump host (the single door closes), parameters go to BT10 as a sighting. Fixes are structural: the vendor path moves to the recorded jump host with per-vendor, window-bound grants; the baseline registers the pair’s write posture; the finding class enters PT4’s regression. BT20 closes the loop: baseline-stability weeks, attribution latency, vendor violations (target zero), SIS status.

  1. Model — Purdue zones documented as intended flows; SIS independence verified on paper and wire
  2. Sense — passive TAP collectors (never inline); protocol-aware normalization to the export format
  3. Baseline — 30-day reference; process engineers validate (“yes, that’s normal physics”)
  4. Detect — otguard deviations: new pairs, new function codes, write-ratio shifts, out-of-window writes
  5. Attribute — jump-host session recordings + maintenance-window register + engineer confirmation
  6. Contain — the single door closes (vendor path), no controller isolation reflex; physics checked
  7. Fix — structural: access redesign (per-vendor, window-bound, recorded), baseline updated, regression added
  8. Report — BT20 metrics: baseline stability, attribution latency, vendor violations, SIS status

The IT/OT boundary is the single most important door; fwotaudit audits its rulebase with an OT dictionary: reads the firewall export (BT13’s format) and the Purdue flow register (the intended L2↔L3 flows), and flags the OT-specific violations — any-any rules at the boundary, write-direction flows from enterprise into control levels, vendor paths bypassing the jump host, and historian flows that secretly allow write-back.

Direction is physics: an enterprise-to-control write flow (L4→L2) is categorically different from the historian’s control-to-enterprise read — the audit’s finding classes encode Purdue directionality (ENTERPRISE-WRITE-INTO-OT, the worst class), not just port reachability. Port rules are insufficient and the tool says so: Modbus rides 502, S7comm rides 102, so a “port 502 allowed” rule is a function-code wildcard — the finding notes which rules need function-code narrowing (the enforcement point’s capability [verify per firewall platform]). Jump-host bypass detection: any rule whose dst is an OT zone and whose src is a vendor-address range (the register lists vendor sources) that doesn’t traverse the declared jump-host zone is a bypass finding. Fail-loud on both inputs — an audit that half-loads either file must refuse rather than bless a partial boundary.

#!/usr/bin/env python3
"""fwotaudit — IT/OT boundary firewall audit with Purdue directionality.
Inputs: fw.csv (BT13 rulebase format), purdue.csv (intended flows:
src_level,dst_level,direction,service,allowed), vendors.csv (zones that
must traverse the jump host). Findings: ENTERPRISE-WRITE-INTO-OT,
ANY-ANY-AT-BOUNDARY, VENDOR-BYPASS, PORT-WILDCARD-OT, UNZONED."""
import argparse
import csv
import sys
from collections import defaultdict
from pathlib import Path
REQ_FW = ["src_zone", "dst_zone", "ports", "action", "owner", "expiry"]
REQ_P = ["src_level", "dst_level", "direction", "service", "allowed"]
OT_ZONES = ("ot-l2", "ot-l1", "ot-l0", "ot-supervisory")
ENTERPRISE = ("it-l4", "enterprise")
def main():
ap = argparse.ArgumentParser(description="IT/OT boundary audit")
ap.add_argument("--fw", type=Path, required=True)
ap.add_argument("--purdue", type=Path, required=True)
ap.add_argument("--vendors", type=Path, required=True)
ap.add_argument("--jump-zone", default="ot-jump")
ap.add_argument("--out", type=Path, default=Path("fwotaudit_out"))
args = ap.parse_args()
def load(path, req, label):
if not path.exists():
sys.exit(f"fail-loud: {label} not found: {path}")
with path.open(encoding="utf-8", errors="replace") as fh:
reader = csv.DictReader(fh)
missing = [c for c in req if c not in (reader.fieldnames or [])]
if missing:
sys.exit(f"fail-loud: {label} missing columns: {missing}")
return list(reader)
fw = load(args.fw, REQ_FW, "firewall export")
purdue = load(args.purdue, REQ_P, "purdue register")
vendors = {r["src_zone"].strip().lower()
for r in load(args.vendors, ["src_zone"], "vendors")}
intended = {}
for r in purdue:
intended[(r["src_level"].strip().lower(),
r["dst_level"].strip().lower(),
r["direction"].strip().lower())] = \
r["allowed"].strip().lower() == "yes"
findings = []
for n, r in enumerate(fw, 2):
src = r["src_zone"].strip().lower()
dst = r["dst_zone"].strip().lower()
ports = r["ports"].strip().lower()
action = r["action"].strip().lower()
if action != "allow":
continue
if src in ("any", "") or dst in ("any", ""):
findings.append({"kind": "UNZONED", "rule": n,
"detail": f"'any' zone in allow rule ({src}->{dst})"})
continue
boundary = (src not in OT_ZONES and dst in OT_ZONES)
if dst in OT_ZONES and src in ENTERPRISE and ports in ("any", "*"):
findings.append({"kind": "ANY-ANY-AT-BOUNDARY", "rule": n,
"detail": f"{src}->{dst} ports=any at the "
"IT/OT boundary"})
continue
if boundary and ports in ("any", "*"):
findings.append({"kind": "ANY-ANY-AT-BOUNDARY", "rule": n,
"detail": f"{src}->{dst} ports=any into OT"})
# enterprise write-into-OT: allow rule to control levels from IT
if dst in ("ot-l2", "ot-l1", "ot-l0") and src in ENTERPRISE:
intended_write = intended.get(("l4", dst.replace("ot-", "l"),
"write"), None)
if intended_write is None or not intended_write:
findings.append({"kind": "ENTERPRISE-WRITE-INTO-OT",
"rule": n,
"detail": f"{src}->{dst}: enterprise write "
"flow not in Purdue register"})
# port-wildcard on OT protocols (502 Modbus, 102 S7comm)
if dst in OT_ZONES and ports not in ("any", "*"):
plist = [p.strip() for p in ports.split(",")]
if "502" in plist or "102" in plist:
findings.append({"kind": "PORT-WILDCARD-OT", "rule": n,
"detail": f"{src}->{dst} allows 502/102 — "
"function-code narrowing needed"})
# vendor bypass: vendor zone -> OT without traversing jump zone
if src in vendors and dst in OT_ZONES and args.jump_zone not in \
(src, dst):
findings.append({"kind": "VENDOR-BYPASS", "rule": n,
"detail": f"vendor zone {src} reaches {dst} "
f"without {args.jump_zone}"})
findings.sort(key=lambda f: f["kind"])
args.out.mkdir(parents=True, exist_ok=True)
with (args.out / "findings.csv").open("w", newline="",
encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=["kind", "rule", "detail"])
w.writeheader()
w.writerows(findings)
kinds = defaultdict(int)
for f in findings:
kinds[f["kind"]] += 1
print(f"rules audited={len(fw)} findings={len(findings)} {dict(kinds)}")
for f in findings[:12]:
print(f" [{f['kind']:<24}] rule {f['rule']}: {f['detail'][:52]}")
print(f"register: {args.out / 'findings.csv'}")
if any(f["kind"] in ("ENTERPRISE-WRITE-INTO-OT", "ANY-ANY-AT-BOUNDARY",
"VENDOR-BYPASS") for f in findings):
sys.exit(1)
print("boundary clean: Purdue directionality holds, no bypasses")
if __name__ == "__main__":
main()

The Purdue register is the audit’s physics: intended flows as (src_level, dst_level, direction) → allowed — direction is what makes it an OT register (an IT register only knows ports). The enterprise-write lookup classifies any allow rule from enterprise into control levels against it — a write flow not in the register is ENTERPRISE-WRITE-INTO-OT, the worst class, failing the audit regardless of volume. The boundary-any-any check is the flattened-network signature; the port-wildcard finding teaches the OT lesson — allowing port 502 is allowing all Modbus function codes, reads, writes, firmware pushes — demanding function-code narrowing where the enforcement point supports it [verify per platform]. The vendor-bypass check enforces the single-door doctrine: vendor zones reach OT only through the jump zone.

The exit discipline (lines 96-100): the three catastrophic classes (enterprise writes, boundary any-any, vendor bypasses) fail the audit; PORT-WILDCARD-OT findings report without blocking (they’re narrowing projects, not open doors). The defaultdict import placement (line 103, after the functions) is legal Python and intentional — the module reads top-down as a script — though the AST check verifies it, and a tidy-up hoist would move it to the header in production code.

audit the IT/OT boundary against the Purdue register

Section titled “audit the IT/OT boundary against the Purdue register”

$ python3 fwotaudit.py —fw fw_export.csv —purdue purdue.csv
—vendors vendors.csv —jump-zone ot-jump rules audited=214 findings=9 {‘PORT-WILDCARD-OT’: 4, ‘ANY-ANY-AT-BOUNDARY’: 2, ‘VENDOR-BYPASS’: 2, ‘ENTERPRISE-WRITE-INTO-OT’: 1} [ENTERPRISE-WRITE-INTO-OT ] rule 87: it-l4->ot-l2: enterprise write flow not in Purdue register [VENDOR-BYPASS ] rule 131: vendor-hvac reaches ot-l1 without ot-jump … register: fwotaudit_out/findings.csv

the write-into-OT rule is the dashboard project’s “temporary” path —

Section titled “the write-into-OT rule is the dashboard project’s “temporary” path —”

retire it, narrow the Modbus rules to read function codes, re-run:

Section titled “retire it, narrow the Modbus rules to read function codes, re-run:”

$ python3 fwotaudit.py —fw fw_v2.csv —purdue purdue.csv —vendors vendors.csv | tail -1 boundary clean: Purdue directionality holds, no bypasses

and the passive layer watches the boundary it defends (otguard, weekly)

Section titled “and the passive layer watches the boundary it defends (otguard, weekly)”

The boundary audit is configuration truth; otguard’s deviations are behavior truth — the pair works exactly like BT13’s segaudit/flowmap pairing, OT edition: the rulebase says what may flow, the TAP shows what flows, and the delta between them is where the incident lives. The OT SIEM join (BT3) carries both plus the IT-plane events that matter for OT intrusions (the identity events of jump-host sessions, the endpoint telemetry from engineering stations — the Windows machines whose compromise reaches L2). Two program metrics in BT20: boundary-audit finding count (trending zero) and baseline-stability weeks (OT’s calm is its own signal). One doctrine note for the SOC: OT alerts page the process engineer first — the analyst’s role is context (what changed in IT? which vendor session is open?), not action; the response authority lives with the people who own the physics.

  1. Ingest TAP-collector native formats (Zeek conn/protocol logs [verify current schemas]) instead of requiring the normalized CSV.
  2. Add recipe-aware baselining: batch transitions as expected state changes (join with the historian’s batch records).
  3. Track firmware versions per controller in the inventory and alert on out-of-window version changes.
  4. Correlate jump-host sessions with otguard write events (every write should have a session).
  5. Emit the SIS-independence verification checklist as a recurring audit artifact (the wire check, not the paper check).
SignalSourceIDNotes
New device pairsotguardper cycleUnknown-on-the-wire
New function codesotguardper pairCapability shifts
Write-ratio shiftsotguardper pairRead-only pair writing
Out-of-window writesotguardper eventChange discipline
Boundary violationsfwotauditquarterlyPurdue directionality
Vendor path bypassfwotauditquarterlySingle-door doctrine
Jump-host anomaliesSession recordingsper sessionThe monitored door
SIS independence statusVerification auditquarterlySafety-first
IT-pivot indicatorsBT16/BT9 joinper eventConvergence intrusions
Baseline stability weeksotguard historymonthlyOT’s calm metric

Adversaries evade the passive layer by understanding its baselines. Function-code camouflage: crafted payloads inside read codes, write-coil abuse that looks like normal actuation — the baseline’s defense is value analysis alongside codes (a setpoint outside process limits is a finding even when the code is normal). Timing mimicry: writes at the cadence of legitimate ones (matching the 30-second valve rhythm) — recipe-aware baselining catches the value change timing hides. Living through the vendor: the vendor path is legitimate traffic — the adversary who owns the vendor’s support machine inherits its baseline; per-vendor accounts (attribution survives credential sharing), session recording, and window-bound grants are the counters. The SIS taboo inverted: unmonitored SIS zones become the covert path — passive SIS observation never violates independence (only shared control does). Baseline poisoning: slow deviations taught as new normal — the rolling baseline needs change-control joins and periodic engineer re-validation, or it learns the adversary as physics. IT-side invisibility: intrusions living entirely in L4/L3 never touch the OT wire — BT16/BT9 detections are the only sensors there, which is why the OT program inherits the IT program’s content.

⚠️ What caught me: our OT baseline ran green for two quarters — zero deviations, a genuinely calm wire — and the process engineers started joking that the security team’s only job was confirming the plant was boring. Then the annual review asked a question nobody had an answer for: “who validated that the baseline itself matches the process?” We pulled the reference period and found it included three weeks of a maintenance shutdown — the baseline had learned ‘plant mostly idle’ as normal, and the current ‘deviations’ (elevated polling, new pairs) were actually the plant running. Every finding since the shutdown had been auto-suppressed as noise, including six weeks of a vendor device polling controllers through an unlogged path. The fix rewrote the program’s first principle: a baseline is a hypothesis about physics, owned by the process engineer, re-validated at every operational state change — shutdowns, startups, recipe changes. Security tooling that models physics without physicists doesn’t detect intrusions; it archives them.

Build the OT baseline loop on a simulated control segment. Simulate: stand up two Modbus slaves and a master (open-source simulators [verify tooling]) generating a realistic read/write pattern — the “plant.” Baseline: capture 30 minutes via TAP/SPAN; run otguard baseline vs current — confirm zero findings. Deviate: inject an enterprise write and a new device pair; re-run — confirm NEW-PAIR and out-of-window classification. Register: declare a maintenance window over the write; re-run — the window works but the new pair still fires (windows don’t legitimize strangers). Audit: run fwotaudit against a rulebase with a port-502 wildcard and a vendor bypass; fix both, confirm clean. Debrief: which finding needed the physics context — and who plays that role in your real program?

  • Technical: Walk the Purdue model as a security architecture — what does each level boundary enforce, and why is SIS independence a wire verification rather than a paper assertion?
  • Technical: Explain otguard’s write-ratio finding — why is a read-only pair starting to write the intrusion signature, and what minimum volumes guard the statistic?
  • Scenario: otguard flags a write to a production PLC from the vendor support path at 14:00 on a Tuesday — no maintenance window. Walk the response: who decides, what gets checked, what never happens reflexively?
  • Scenario: The process engineers reject your baseline as “not how the plant behaves.” Walk the fix — and the program-trust damage control.
  • Conceptual: Why is OT patching a change-management problem rather than an SLA problem, and what compensating architecture carries the risk between windows?
  • Deep-dive: Design OT security operations for a plant with 60 PLCs, three vendor paths, and a cloud historian: zone model, passive detection stack, change management, vendor access, and the BT20 metrics.
  • Practical: You inherit an OT network with flat routing to IT and no visibility. What do you instrument in the first 60 days — and what do you explicitly refuse to deploy (agents inline)?

BT13 (Network Segmentation) · H8 (Vehicle & CAN Bus Security) · H3 (Firmware Analysis & Emulation) · BT3 (SIEM Engineering & Log Pipelines) · BT9 (Detection Engineering & Sigma)