Ask any tool that touches a plant network whether it is safe and it will say yes. The claim is usually true in the sense the vendor means it: the aggressive features are behind a flag, and the flag is off.
The problem with that shape is that it survives exactly as long as the flag does. A refactor, a default that flips, a config file that fails to parse and falls back to a zero value, and the write path is reachable again. The safety property lives in a boolean, and booleans are the least durable thing in a codebase.
What we do instead, on Modbus
Modbus is the clearest example because it has a well-defined split. Function codes 1, 2, 3, 4, 7, 20, 24 and 43 read. Function codes 5, 6, 15, 16, 22 and 23 write.
Our module knows both lists, and the function that frames a request accepts only the read set. Its switch statement ends in a default that returns false. There is no branch that produces a write frame from the read path, so no configuration mistake, no flipped default and no forgotten flag makes one appear. The write cannot be framed.
The module does carry exactly one write probe, and it sits behind an explicit operator consent switch. Today that switch has no setter anywhere in production code, which means the probe is unreachable on a normal install. That is a gap in one sense and the right answer in another: the doctrine forbids the write, so the honest position is that Modbus contributes no controllability primitive rather than that it contributes one nobody can reach.
What that costs, said plainly
It costs a finding. Our other industrial protocols earn a controllability verdict from a read. DNP3 earns it when an outstation answers without a Secure Authentication challenge. S7comm earns it from the protection level the CPU reports about itself. EtherNet/IP earns it from what the identity reply says.
Modbus has no equivalent, because Modbus has no authentication layer to be missing. The protocol simply does not have one. So on a plant full of unauthenticated Modbus controllers, our report does not claim a proven write capability, and it says why rather than leaving a blank.
We would rather ship that than a proof we do not hold. The alternative is a report that tells a plant manager we demonstrated control of a packaging line when what we actually did was read a coil.
The gate that was doing nothing
The same audit turned up the other side of this. Our fragility gate takes a policy class as a plain string, and ten scanners were passing values that matched no class at all. Every one of them fell through to a permissive default and ran with no gate, including an RPC endpoint enumeration aimed at DeltaV, Experion, CENTUM and 800xA nodes that had already been marked fragile.
Nothing failed. The call was there, it read like protection in review, and it did nothing. That is worse than no gate, because no gate at least looks like no gate.
An unknown class now takes the most restrictive interpretation and logs the call site. And there is a class whose only job is to let an author say "this is deliberately ungated" on purpose, because without one, the author who means that reaches for a string that matches nothing and gets the same result by accident.
The per-host switch, and what it really covers
Above all of it sits the operator's own do-not-touch list. Name a host and the aggressive classes close on it. Our safety page tells the operator which modules consult that list, and that page is derived from the code rather than hand maintained, because the hand-maintained version went stale the first week and under-reported by thirteen modules.
Two modules are excluded from that list on purpose, and the exclusion is the interesting part. The FrameWorX listener binds a socket and joins a multicast group, so it is not accurate to say it puts nothing on the wire: a group join emits a membership report to the local infrastructure. What is true is that nothing it emits is addressed to a device. There is no disturbance for the switch to withhold, and a test re-derives that claim from the source: the module may listen, join and read, and may contain no transmit primitive at all.
Telling an operator a control is protecting them where nothing runs is the one claim a safety page cannot afford.
Frequently asked questions
Why is making read-only a boolean flag not safe enough for OT scanning?
A flag-based safe mode only survives as long as the flag does. A refactor, a default that flips, or a config file that fails to parse and falls back to zero can make the write path reachable again. The safety property lives in a boolean, and booleans are described as the least durable thing in a codebase.
Which Modbus function codes are treated as reads and which as writes?
Function codes 1, 2, 3, 4, 7, 20, 24 and 43 read. Function codes 5, 6, 15, 16, 22 and 23 write. The module's request-framing function accepts only the read set, and its switch statement ends in a default that returns false, so there is no code path that can produce a write frame from a read call.
Why doesn't Modbus earn a controllability finding the way other OT protocols do?
Other protocols prove controllability from a read: DNP3 from a missing Secure Authentication challenge, S7comm from the CPU's reported protection level, EtherNet/IP from its identity reply. Modbus has no authentication layer to be missing, so on unauthenticated Modbus controllers the report states that no proven write capability exists rather than leaving the question unanswered.
What happened when the fragility gate was passed an unrecognised policy class?
Ten scanners passed policy class values that matched no defined class, so the gate fell through to a permissive default and ran with no protection at all, including an RPC endpoint enumeration aimed at DeltaV, Experion, CENTUM and 800xA nodes already marked fragile. Nothing failed visibly, which the post calls worse than having no gate at all.
How is an operator's do-not-touch host list kept accurate across modules?
Naming a host closes the aggressive classes on it, and the safety page listing which modules consult that list is now derived directly from the code rather than hand-maintained, because the hand-maintained version had gone stale within a week and under-reported coverage by thirteen modules.
Why are the FrameWorX listener modules excluded from the do-not-touch list?
The FrameWorX listener binds a socket and joins a multicast group, so it isn't accurate to say it puts nothing on the wire, a group join emits a membership report to local infrastructure. But nothing it emits is addressed to a device, so there's no disturbance for the switch to withhold, a claim a test re-derives from the source code.