MSc THESIS · UvA SNE × KPMG CYBER
The clean-up step that
broke the classifier
An obvious fix that quietly erases the evidence.
Stable-Edge Filtering for Passive OT Device Classification under Operational Change
Jonathan van den Heuvel · jvdh.tech
THE PROBLEM
We classify what we can't scan
- Passive OT asset discovery is a routine KPMG deliverable.
- You can't active-scan a live plant, so we classify devices from captured traffic.
- But real segments are messy: laptops connect, scanners sweep, maintenance happens.
Does a simple clean-up step keep the classifier robust under that change?
THE IDEA + THE LAB
Keep only the connections that last
- Build the graph of who-talks-to-whom, then drop edges that don't persist over time.
- Intuition: a one-off engineering session or a scan sweep is noise, filter it out.
THE TESTBED
- 20
- HOSTS
- 5
- CLASSES · ctrl · sup · eng · hist · it
- 4
- CHANGE SCENARIOS
- 10×10
- LAB × MODEL SEEDS
- ∴
- INDUCTIVE (held-out hosts) · GraphSAGE
builds on Heo & Shin (2025) · artifacts released
BUILDING THE LAB · THE FACTORY
What the devices actually do
BUILDING THE LAB · THE TESTBED
20 containers, one passive tap
- A small plant in Docker: 5 device classes, 4 each, on one shared segment.
- Every container runs the same image, and its class decides whether it serves or polls.
# one image; class set in lab.yaml dev = DEVICE_CLASSES[host.class] dev.setup() # PLC: serves :502 while running: phase = read("phase.json") for plc in dev.targets: # client poll(plc) # HMI · hist · eng sleep(dev.poll_interval)
RUNS 24/7 on a server · 78 scenario runs · 24 GB captured
BUILDING THE LAB · OPERATIONAL CHANGE
Four scripted changes,
known ground truth
- A phase signal tells every container when the world changes, live, mid-capture.
- So I can perturb the network and still know the truth.
for phase in scenario.phases: write("phase.json", phase.effects) sleep(phase.minutes) # live labels = label_edges(config) # truth from CONFIG, not the traffic # "stable" iff seen in EVERY phase
FIVE STATES · FOUR CHANGES
- steady
- nothing changes · the baseline
- maintenance
- plc-1 paused 40 min, its edges vanish→ this is the one that breaks
- onboarding
- a new HMI joins mid-run
- drift
- an engineer repoints to another PLC
- noise
- a scanner sweeps the segment
edge labels come from config, not traffic, no circularity
RQ2 · THE RESULT (held-out macro-F1)
Neutral on four.
It breaks the fifth.
- Neutral on 4 of 5 scenarios: steady, onboarding, config drift, benign scanning.
- No robustness benefit anywhere, the upside we hoped for simply isn't there.
- Under maintenance it significantly hurts.
THE MECHANISM · MAINTENANCE
A paused controller looks like an idle laptop
- 1A controller (PLC) is paused for 40 minutes.
- 2Its polls stop, the “keep persistent edges” filter deletes all 20. in-degree 20→0 · in-bytes 2.1M→0
- 3Stripped bare, it has zero traffic, identical to an idle IT laptop → misread as “IT endpoint.”
A plain random forest breaks the same way, the filter destroys the evidence, not the model.
THE TAKEAWAY
Persistence is the wrong thing to filter on.
A cheap clean-up step can quietly erase the evidence your classifier depends on.
- Don't pre-filter passive OT graphs by recurrence.
- Edge meaning beats persistence, think protocol, direction, endpoint roles.
Reproducible lab · 4 scenarios · code released | jvdh.tech · Jonathan van den Heuvel · github.com/jonathanvdheuvel