Industrial Rule Library¶
These are complete executable policies, not pseudocode. Each listing is included from
rules/muli_state_rules, the same source loaded by the automated Rule Engine tests.
Customer deployment still requires asset, point, identity, timing, severity, and enforcement review. Start in monitor mode unless the use case has completed site acceptance.
DNP3 multi-object Select-Before-Operate¶
# Production-style DNP3 SBO policy for complete multi-point CROB/G41 commands.
# Customize TTL and the final default policy for the deployment. The rule is
# fail-closed for malformed control requests and consumes a Select exactly once.
version: 1
rules:
- name: dnp3-sbo-drop-incomplete-control
order: 5
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
(!dnp3.HasCanonicalEndpoints() || !dnp3.HasControlFingerprint())
action: drop
log: true
meta:
policy: '"sbo_required"'
violation: '"incomplete_control_identity"'
function: 'dnp3.meta.FunctionCodeText()'
- name: dnp3-sbo-drop-direct-operate
order: 10
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() &&
dnp3.IsDirectOperate() && dnp3.HasControlFingerprint()
action: drop
log: true
meta:
policy: '"sbo_required"'
violation: '"direct_operate"'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
targets: 'dnp3.ControlTargetCount()'
fingerprint: 'dnp3.ControlFingerprint()'
- name: dnp3-sbo-record-select-request
order: 20
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsSelect() &&
dnp3.HasCanonicalEndpoints() && dnp3.HasApplicationSequence() &&
dnp3.HasControlFingerprint()
action: set_state
state_key_expr: '"dnp3:sbo:" + dnp3.ControlSessionKey()'
state_value_expr: >
"pending|" + string(dnp3.ApplicationSequence()) + "|" +
dnp3.ControlFingerprint()
state_ttl: 5
log: true
meta:
phase: '"select_request"'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
sequence: 'dnp3.ApplicationSequence()'
targets: 'dnp3.ControlTargetCount()'
fingerprint: 'dnp3.ControlFingerprint()'
- name: dnp3-sbo-confirm-select-response
order: 30
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() &&
dnp3.HasCanonicalEndpoints() && dnp3.HasApplicationSequence() &&
dnp3.ControlResponseOK()
action: transition_state
state_key_expr: '"dnp3:sbo:" + dnp3.ControlSessionKey()'
state_from_expr: >
"pending|" + string(dnp3.ApplicationSequence()) + "|" +
dnp3.ControlFingerprint()
state_value_expr: '"selected|" + dnp3.ControlFingerprint()'
state_ttl: 5
log: true
meta:
phase: '"select_response"'
result: '"success"'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
sequence: 'dnp3.ApplicationSequence()'
targets: 'dnp3.ControlTargetCount()'
fingerprint: 'dnp3.ControlFingerprint()'
- name: dnp3-sbo-clear-mismatched-control-response
order: 34
description: Invalidate pending state when a successful response cannot confirm it.
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.ControlResponseOK() &&
state_action_rule == "dnp3-sbo-confirm-select-response" &&
!state_action_applied
action: delete_state
state_key_expr: '"dnp3:sbo:" + dnp3.ControlSessionKey()'
log: true
meta:
phase: '"select_response"'
violation: '"select_response_mismatch"'
sequence: 'dnp3.ApplicationSequence()'
targets: 'dnp3.ControlTargetCount()'
fingerprint: 'dnp3.ControlFingerprint()'
- name: dnp3-sbo-clear-failed-control-response
order: 35
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() &&
dnp3.HasCanonicalEndpoints() && dnp3.HasControlFingerprint() &&
!dnp3.ControlResponseOK()
action: delete_state
state_key_expr: '"dnp3:sbo:" + dnp3.ControlSessionKey()'
log: true
meta:
phase: '"control_response"'
result: '"failed"'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
targets: 'dnp3.ControlTargetCount()'
fingerprint: 'dnp3.ControlFingerprint()'
iin: 'dnp3.meta.IIN()'
- name: dnp3-sbo-consume-operate
order: 40
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
dnp3.HasCanonicalEndpoints() && dnp3.HasControlFingerprint()
action: transition_state
state_key_expr: '"dnp3:sbo:" + dnp3.ControlSessionKey()'
state_from_expr: '"selected|" + dnp3.ControlFingerprint()'
state_value: consumed
state_ttl: 1
log: true
meta:
phase: '"operate"'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
targets: 'dnp3.ControlTargetCount()'
fingerprint: 'dnp3.ControlFingerprint()'
- name: dnp3-sbo-accept-valid-operate
order: 50
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
state_action_rule == "dnp3-sbo-consume-operate" &&
state_action_applied
action: accept
log: true
meta:
policy: '"sbo_required"'
result: '"valid_operate"'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
targets: 'dnp3.ControlTargetCount()'
fingerprint: 'dnp3.ControlFingerprint()'
- name: dnp3-sbo-drop-invalid-operate
order: 60
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate()'
action: drop
log: true
meta:
policy: '"sbo_required"'
violation: '"operate_without_matching_select"'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
targets: 'dnp3.ControlTargetCount()'
fingerprint: 'dnp3.ControlFingerprint()'
- name: dnp3-sbo-default-accept
order: 1000
condition: 'true'
action: accept
DNP3 command-to-status verification¶
# Customer demo: verify CROB:7 Latch On/Off against binary feedback BI:20.
# Customize command point, feedback point, outstation scope and timeout.
# The state lives for 30 seconds; policy timeout is checked at age >= 5 seconds
# on the next observed packet (scheduler-independent version).
version: 1
rules:
- name: dnp3-csv-drop-conflicting-command
order: 10
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
dnp3.CrobOpcodePresent(7) &&
StateExists("dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20")
action: drop
log: true
meta:
policy: '"command_status_verification"'
violation: '"command_while_feedback_pending"'
outstation: 'dnp3.Outstation()'
command_point: '7'
- name: dnp3-csv-record-latch-on
order: 20
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
dnp3.HasCanonicalEndpoints() && dnp3.IsLatchOn(7)
action: set_state
state_key_expr: >
"dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
state_value: awaiting:on
state_ttl: 30
log: true
meta:
phase: '"command_sent"'
expected_feedback: '"on"'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
- name: dnp3-csv-record-latch-off
order: 21
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
dnp3.HasCanonicalEndpoints() && dnp3.IsLatchOff(7)
action: set_state
state_key_expr: >
"dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
state_value: awaiting:off
state_ttl: 30
log: true
meta:
phase: '"command_sent"'
expected_feedback: '"off"'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
- name: dnp3-csv-verify-feedback-on
order: 30
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() &&
dnp3.HasCanonicalEndpoints() && dnp3.HasPointRef("BI:20") &&
dnp3.PointRefOn("BI:20")
action: transition_state
state_key_expr: >
"dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
state_from: awaiting:on
state_value: verified
state_ttl: 1
log: true
meta:
phase: '"feedback"'
result: '"verified"'
feedback: '"on"'
outstation: 'dnp3.Outstation()'
- name: dnp3-csv-verify-feedback-off
order: 31
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() &&
dnp3.HasCanonicalEndpoints() && dnp3.HasPointRef("BI:20") &&
dnp3.PointRefOff("BI:20")
action: transition_state
state_key_expr: >
"dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
state_from: awaiting:off
state_value: verified
state_ttl: 1
log: true
meta:
phase: '"feedback"'
result: '"verified"'
feedback: '"off"'
outstation: 'dnp3.Outstation()'
- name: dnp3-csv-fail-opposite-on
order: 40
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() &&
dnp3.HasCanonicalEndpoints() && dnp3.PointRefOn("BI:20")
action: transition_state
state_key_expr: >
"dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
state_from: awaiting:off
state_value: failed:opposite_feedback
state_ttl: 30
log: true
meta:
phase: '"feedback"'
result: '"opposite_feedback"'
expected: '"off"'
actual: '"on"'
- name: dnp3-csv-fail-opposite-off
order: 41
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() &&
dnp3.HasCanonicalEndpoints() && dnp3.PointRefOff("BI:20")
action: transition_state
state_key_expr: >
"dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
state_from: awaiting:on
state_value: failed:opposite_feedback
state_ttl: 30
log: true
meta:
phase: '"feedback"'
result: '"opposite_feedback"'
expected: '"on"'
actual: '"off"'
- name: dnp3-csv-timeout-awaiting-on
order: 50
condition: >
dnp3.IsDNP3() && dnp3.HasCanonicalEndpoints() &&
GetState("dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20") == "awaiting:on" &&
StateAge("dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20") >= 5
action: transition_state
state_key_expr: >
"dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
state_from: awaiting:on
state_value: failed:timeout
state_ttl: 30
log: true
meta:
phase: '"feedback_timeout"'
expected: '"on"'
timeout_seconds: '5'
- name: dnp3-csv-timeout-awaiting-off
order: 51
condition: >
dnp3.IsDNP3() && dnp3.HasCanonicalEndpoints() &&
GetState("dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20") == "awaiting:off" &&
StateAge("dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20") >= 5
action: transition_state
state_key_expr: >
"dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
state_from: awaiting:off
state_value: failed:timeout
state_ttl: 30
log: true
meta:
phase: '"feedback_timeout"'
expected: '"off"'
timeout_seconds: '5'
- name: dnp3-csv-log-uncommanded-feedback
order: 60
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() &&
dnp3.HasCanonicalEndpoints() && dnp3.HasPointRef("BI:20") &&
!StateExists("dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20")
action: log
meta:
policy: '"command_status_verification"'
observation: '"feedback_without_observed_command"'
outstation: 'dnp3.Outstation()'
feedback: 'dnp3.meta.PointRefStateText("BI:20")'
- name: dnp3-csv-default-accept
order: 1000
condition: 'true'
action: accept
DNP3 restart recovery¶
# Customer demo: gate DNP3 controls after device restart until time sync (when
# requested) and a successful final Class-0 integrity response complete.
# Timeout alerts are evaluated on the next packet until scheduler support lands.
version: 1
rules:
- name: dnp3-recovery-log-restart-outside-maintenance
order: 5
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.DeviceRestarted() &&
!StateExists("industrial:maintenance:device:" + string(dnp3.Outstation()))
action: log
stop: false
meta:
policy: '"restart_recovery"'
observation: '"restart_outside_maintenance"'
outstation: 'dnp3.Outstation()'
- name: dnp3-recovery-log-repeated-restart
order: 6
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.DeviceRestarted() &&
StateExists("dnp3:recovery:" + string(dnp3.Outstation()))
action: log
stop: false
meta:
policy: '"restart_recovery"'
observation: '"repeated_restart_during_recovery"'
recovery_state: 'GetState("dnp3:recovery:" + string(dnp3.Outstation()))'
outstation: 'dnp3.Outstation()'
- name: dnp3-recovery-drop-control-while-recovering
order: 10
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
StateExists("dnp3:recovery:" + string(dnp3.Outstation())) &&
GetState("dnp3:recovery:" + string(dnp3.Outstation())) != "operational"
action: drop
log: true
meta:
policy: '"restart_recovery"'
violation: '"control_during_recovery"'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
recovery_state: 'GetState("dnp3:recovery:" + string(dnp3.Outstation()))'
- name: dnp3-recovery-observe-restart
order: 20
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasCanonicalEndpoints() &&
dnp3.DeviceRestarted() &&
!StateExists("dnp3:recovery:" + string(dnp3.Outstation()))
action: set_state
state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
state_value: restarted
state_ttl: 120
log: true
meta:
phase: '"restarted"'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
iin: 'dnp3.meta.IINHexText()'
- name: dnp3-recovery-require-time-sync
order: 25
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.NeedTime() &&
GetState("dnp3:recovery:" + string(dnp3.Outstation())) == "restarted"
action: transition_state
state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
state_from: restarted
state_value: time_sync_required
state_ttl: 120
log: true
meta:
phase: '"time_sync_required"'
outstation: 'dnp3.Outstation()'
- name: dnp3-recovery-skip-time-sync-when-not-required
order: 26
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && !dnp3.NeedTime() &&
GetState("dnp3:recovery:" + string(dnp3.Outstation())) == "restarted"
action: transition_state
state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
state_from: restarted
state_value: integrity_scan_required
state_ttl: 120
log: true
meta:
phase: '"integrity_scan_required"'
time_sync_skipped: 'true'
- name: dnp3-recovery-record-time-write
order: 30
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() &&
dnp3.HasObject(50, 1) && dnp3.HasApplicationSequence() &&
GetState("dnp3:recovery:" + string(dnp3.Outstation())) == "time_sync_required"
action: transition_state
state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
state_from: time_sync_required
state_value_expr: '"time_sync_pending|" + string(dnp3.ApplicationSequence())'
state_ttl: 30
log: true
meta:
phase: '"time_sync_pending"'
sequence: 'dnp3.ApplicationSequence()'
- name: dnp3-recovery-confirm-time-write
order: 35
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasApplicationSequence() &&
!dnp3.HasIINError()
action: transition_state
state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
state_from_expr: '"time_sync_pending|" + string(dnp3.ApplicationSequence())'
state_value: integrity_scan_required
state_ttl: 120
log: true
meta:
phase: '"integrity_scan_required"'
sequence: 'dnp3.ApplicationSequence()'
- name: dnp3-recovery-record-integrity-read
order: 40
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsClassRead(0) &&
dnp3.HasApplicationSequence() &&
GetState("dnp3:recovery:" + string(dnp3.Outstation())) == "integrity_scan_required"
action: transition_state
state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
state_from: integrity_scan_required
state_value_expr: '"integrity_scan_pending|" + string(dnp3.ApplicationSequence())'
state_ttl: 60
log: true
meta:
phase: '"integrity_scan_pending"'
sequence: 'dnp3.ApplicationSequence()'
- name: dnp3-recovery-complete-integrity-response
order: 45
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasApplicationSequence() &&
dnp3.IsFinalFragment() && !dnp3.HasIINError()
action: transition_state
state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
state_from_expr: '"integrity_scan_pending|" + string(dnp3.ApplicationSequence())'
state_value: operational
state_ttl: 5
log: true
meta:
phase: '"operational"'
sequence: 'dnp3.ApplicationSequence()'
outstation: 'dnp3.Outstation()'
- name: dnp3-recovery-fail-response
order: 50
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasIINError() &&
StateExists("dnp3:recovery:" + string(dnp3.Outstation()))
action: set_state
state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
state_value: failed:iin_error
state_ttl: 120
log: true
meta:
phase: '"failed"'
reason: '"iin_error"'
iin: 'dnp3.meta.IINHexText()'
- name: dnp3-recovery-timeout
order: 60
condition: >
dnp3.IsDNP3() && dnp3.HasCanonicalEndpoints() &&
StateExists("dnp3:recovery:" + string(dnp3.Outstation())) &&
GetState("dnp3:recovery:" + string(dnp3.Outstation())) != "operational" &&
StateAge("dnp3:recovery:" + string(dnp3.Outstation())) >= 60
action: set_state
state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
state_value: failed:timeout
state_ttl: 120
log: true
meta:
phase: '"failed"'
reason: '"recovery_timeout"'
timeout_seconds: '60'
- name: dnp3-recovery-default-accept
order: 1000
condition: 'true'
action: accept
Maintenance-authorized DNP3 control¶
# Customer demo: a designated DNP3 CROB:99 Latch-On acts as a maintenance
# key-switch. Only engineer@OT-ENGINEERING can authorize one DNP3 restart for
# the same SoftEther session and outstation within 300 seconds.
version: 1
rules:
- name: maintenance-drop-unauthorized-key-switch
order: 10
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
dnp3.IsLatchOn(99) &&
(!se.Present() || se.User() != "engineer" || se.Hub() != "OT-ENGINEERING")
action: drop
log: true
meta:
policy: '"maintenance_authorization"'
violation: '"unauthorized_maintenance_key_switch"'
user: 'se.User()'
hub: 'se.Hub()'
outstation: 'dnp3.Outstation()'
- name: maintenance-authorize-session
order: 20
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
dnp3.HasCanonicalEndpoints() && dnp3.IsLatchOn(99) &&
se.Present() && se.User() == "engineer" && se.Hub() == "OT-ENGINEERING"
action: set_state
state_key_expr: >
"industrial:maintenance:session:" + se.Session() + ":device:" +
string(dnp3.Outstation())
state_value: authorized
state_ttl: 300
log: true
meta:
phase: '"authorized"'
user: 'se.User()'
session: 'se.Session()'
hub: 'se.Hub()'
outstation: 'dnp3.Outstation()'
expires_seconds: '300'
- name: maintenance-mark-device-active
order: 21
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
dnp3.HasCanonicalEndpoints() && dnp3.IsLatchOn(99) &&
se.Present() && se.User() == "engineer" && se.Hub() == "OT-ENGINEERING"
action: set_state
state_key_expr: '"industrial:maintenance:device:" + string(dnp3.Outstation())'
state_value: active
state_ttl: 300
log: true
meta:
phase: '"maintenance_active"'
outstation: 'dnp3.Outstation()'
- name: maintenance-consume-restart-authorization
order: 30
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart() &&
dnp3.HasCanonicalEndpoints() && se.Present()
action: transition_state
state_key_expr: >
"industrial:maintenance:session:" + se.Session() + ":device:" +
string(dnp3.Outstation())
state_from: authorized
state_value: consumed
state_ttl: 1
log: true
meta:
phase: '"authorization_consumption"'
operation: '"dnp3_restart"'
user: 'se.User()'
session: 'se.Session()'
outstation: 'dnp3.Outstation()'
- name: maintenance-accept-authorized-restart
order: 40
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart() &&
state_action_rule == "maintenance-consume-restart-authorization" &&
state_action_applied
action: accept
log: true
meta:
policy: '"maintenance_authorization"'
result: '"authorized_restart"'
user: 'se.User()'
session: 'se.Session()'
outstation: 'dnp3.Outstation()'
- name: maintenance-drop-unauthorized-restart
order: 50
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart()'
action: drop
log: true
meta:
policy: '"maintenance_authorization"'
violation: '"restart_without_valid_session_authorization"'
user: 'se.User()'
session: 'se.Session()'
hub: 'se.Hub()'
outstation: 'dnp3.Outstation()'
- name: maintenance-default-accept
order: 1000
condition: 'true'
action: accept
DNP3 active/standby master¶
# Customer demo: first control master becomes active per outstation. For this
# example master 3 may authorize failover to master 4 using CROB:98 Latch-On.
version: 1
rules:
- name: dnp3-master-authorize-failover-3-to-4
order: 10
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
dnp3.Master() == 3 && dnp3.IsLatchOn(98)
action: transition_state
state_key_expr: '"dnp3:active_master:" + string(dnp3.Outstation())'
state_from: '3'
state_value: failover_pending:4
state_ttl: 30
log: true
meta:
phase: '"failover_pending"'
from_master: '3'
to_master: '4'
outstation: 'dnp3.Outstation()'
- name: dnp3-master-accept-failover-authorization
order: 11
condition: >
state_action_rule == "dnp3-master-authorize-failover-3-to-4" &&
state_action_applied
action: accept
log: true
meta:
policy: '"active_standby_master"'
result: '"failover_authorized"'
- name: dnp3-master-claim-first-controller
order: 20
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
dnp3.HasCanonicalEndpoints()
action: transition_state
state_key_expr: '"dnp3:active_master:" + string(dnp3.Outstation())'
state_from: '@absent'
state_value_expr: 'string(dnp3.Master())'
state_ttl: 0
log: true
meta:
phase: '"active_master_claim"'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
- name: dnp3-master-activate-standby-4
order: 25
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
dnp3.Master() == 4
action: transition_state
state_key_expr: '"dnp3:active_master:" + string(dnp3.Outstation())'
state_from: failover_pending:4
state_value: '4'
state_ttl: 0
log: true
meta:
phase: '"failover_complete"'
active_master: '4'
outstation: 'dnp3.Outstation()'
- name: dnp3-master-accept-active
order: 30
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
GetState("dnp3:active_master:" + string(dnp3.Outstation())) == string(dnp3.Master())
action: accept
log: true
meta:
policy: '"active_standby_master"'
result: '"active_master_control"'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
- name: dnp3-master-drop-standby-or-split-brain
order: 40
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl()'
action: drop
log: true
meta:
policy: '"active_standby_master"'
violation: '"standby_or_split_brain_control"'
attempted_master: 'dnp3.Master()'
active_state: 'GetState("dnp3:active_master:" + string(dnp3.Outstation()))'
outstation: 'dnp3.Outstation()'
- name: dnp3-master-default-accept
order: 1000
condition: 'true'
action: accept
Industrial session identity binding¶
# Customer demo: bind the first industrial control flow to SoftEther identity,
# network endpoint and DNP3 master/outstation. Later controls must match exactly.
version: 1
rules:
- name: identity-drop-control-without-softether
order: 5
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() && !se.Present()'
action: drop
log: true
meta:
policy: '"session_identity_binding"'
violation: '"missing_identity_context"'
- name: identity-claim-first-control
order: 10
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
dnp3.HasCanonicalEndpoints() && se.Present()
action: transition_state
state_key_expr: '"industrial:identity:" + dnp3.ControlSessionKey()'
state_from: '@absent'
state_value_expr: >
se.Hub() + "|" + se.Session() + "|" + se.User() + "|" +
src_ip + "|" + src_mac
state_ttl: 3600
log: true
meta:
phase: '"identity_claim"'
hub: 'se.Hub()'
session: 'se.Session()'
user: 'se.User()'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
- name: identity-validate-control
order: 20
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
dnp3.HasCanonicalEndpoints() && se.Present()
action: transition_state
state_key_expr: '"industrial:identity:" + dnp3.ControlSessionKey()'
state_from_expr: >
se.Hub() + "|" + se.Session() + "|" + se.User() + "|" +
src_ip + "|" + src_mac
state_value_expr: >
se.Hub() + "|" + se.Session() + "|" + se.User() + "|" +
src_ip + "|" + src_mac
state_ttl: 3600
log: true
meta:
phase: '"identity_validation"'
hub: 'se.Hub()'
session: 'se.Session()'
user: 'se.User()'
- name: identity-accept-bound-control
order: 30
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
state_action_rule == "identity-validate-control" && state_action_applied
action: accept
log: true
meta:
policy: '"session_identity_binding"'
result: '"bound_identity"'
hub: 'se.Hub()'
session: 'se.Session()'
user: 'se.User()'
- name: identity-drop-drift-or-hijack
order: 40
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl()'
action: drop
log: true
meta:
policy: '"session_identity_binding"'
violation: '"identity_drift_or_session_hijack"'
hub: 'se.Hub()'
session: 'se.Session()'
user: 'se.User()'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
- name: identity-default-accept
order: 1000
condition: 'true'
action: accept
DNP3 safety trip and reset¶
# Customer demo: BI:30 trip cause, CROB:31 acknowledge, BI:30 clear and CROB:32 reset.
# Only operator@OT-OPERATIONS may acknowledge/reset.
version: 1
rules:
- name: safety-observe-trip
order: 10
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOn("BI:30") &&
(!StateExists("dnp3:safety:" + string(dnp3.Outstation())) ||
GetState("dnp3:safety:" + string(dnp3.Outstation())) == "normal")
action: set_state
state_key_expr: '"dnp3:safety:" + string(dnp3.Outstation())'
state_value: tripped
state_ttl: 0
log: true
meta:
phase: '"tripped"'
cause_point: '30'
outstation: 'dnp3.Outstation()'
- name: safety-acknowledge-trip
order: 20
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(31) &&
se.Present() && se.User() == "operator" && se.Hub() == "OT-OPERATIONS"
action: transition_state
state_key_expr: '"dnp3:safety:" + string(dnp3.Outstation())'
state_from: tripped
state_value: acknowledged
state_ttl: 0
log: true
meta:
phase: '"acknowledged"'
user: 'se.User()'
session: 'se.Session()'
- name: safety-cause-cleared
order: 30
condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:30")'
action: transition_state
state_key_expr: '"dnp3:safety:" + string(dnp3.Outstation())'
state_from: acknowledged
state_value: reset_allowed
state_ttl: 300
log: true
meta:
phase: '"reset_allowed"'
cause_point: '30'
- name: safety-consume-valid-reset
order: 40
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(32) &&
se.Present() && se.User() == "operator" && se.Hub() == "OT-OPERATIONS" &&
StateExists("industrial:maintenance:device:" + string(dnp3.Outstation()))
action: transition_state
state_key_expr: '"dnp3:safety:" + string(dnp3.Outstation())'
state_from: reset_allowed
state_value: normal
state_ttl: 1
log: true
meta:
phase: '"reset"'
user: 'se.User()'
outstation: 'dnp3.Outstation()'
maintenance_active: 'StateExists("industrial:maintenance:device:" + string(dnp3.Outstation()))'
- name: safety-accept-valid-reset
order: 50
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(32) &&
state_action_rule == "safety-consume-valid-reset" && state_action_applied
action: accept
log: true
meta:
policy: '"safety_trip_reset"'
result: '"valid_reset"'
- name: safety-drop-invalid-reset
order: 60
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(32)'
action: drop
log: true
meta:
policy: '"safety_trip_reset"'
violation: '"reset_without_completed_sequence"'
current_state: 'GetState("dnp3:safety:" + string(dnp3.Outstation()))'
user: 'se.User()'
- name: safety-default-accept
order: 1000
condition: 'true'
action: accept
DNP3 unauthorized state change¶
# Monitor-only customer demo: BO feedback BI:20 is expected only after CROB:7.
version: 1
rules:
- name: state-change-expect-on
order: 10
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(7)'
action: set_state
state_key_expr: '"dnp3:expected_change:" + string(dnp3.Outstation()) + ":bi20"'
state_value: expected:on
state_ttl: 10
- name: state-change-expect-off
order: 11
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOff(7)'
action: set_state
state_key_expr: '"dnp3:expected_change:" + string(dnp3.Outstation()) + ":bi20"'
state_value: expected:off
state_ttl: 10
- name: state-change-consume-on
order: 20
condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOn("BI:20")'
action: transition_state
state_key_expr: '"dnp3:expected_change:" + string(dnp3.Outstation()) + ":bi20"'
state_from: expected:on
state_value: observed
state_ttl: 1
- name: state-change-consume-off
order: 21
condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:20")'
action: transition_state
state_key_expr: '"dnp3:expected_change:" + string(dnp3.Outstation()) + ":bi20"'
state_from: expected:off
state_value: observed
state_ttl: 1
- name: state-change-log-unexpected
order: 30
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasPointRef("BI:20") &&
!state_action_applied &&
(state_action_rule == "state-change-consume-on" ||
state_action_rule == "state-change-consume-off")
action: log
meta:
policy: '"unauthorized_state_change"'
observation: '"feedback_without_matching_command"'
severity: '"medium"'
outstation: 'dnp3.Outstation()'
point: '"BI:20"'
state: 'dnp3.meta.PointRefStateText("BI:20")'
- name: state-change-default-accept
order: 1000
condition: 'true'
action: accept
DNP3 valve/pump process interlock¶
# Customer demo: BI:10 must report valve open before CROB:11 pump start.
version: 1
rules:
- name: interlock-valve-open
order: 10
condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOn("BI:10")'
action: set_state
state_key_expr: '"dnp3:interlock:" + string(dnp3.Outstation()) + ":valve10:pump11"'
state_value: prerequisite_met
state_ttl: 10
log: true
meta:
phase: '"prerequisite_met"'
valve_point: '10'
outstation: 'dnp3.Outstation()'
- name: interlock-valve-closed
order: 11
condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:10")'
action: delete_state
state_key_expr: '"dnp3:interlock:" + string(dnp3.Outstation()) + ":valve10:pump11"'
log: true
meta:
phase: '"prerequisite_removed"'
- name: interlock-consume-pump-start
order: 20
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(11)'
action: transition_state
state_key_expr: '"dnp3:interlock:" + string(dnp3.Outstation()) + ":valve10:pump11"'
state_from: prerequisite_met
state_value: operation_allowed
state_ttl: 1
log: true
meta:
phase: '"pump_start"'
valve_point: '10'
pump_point: '11'
- name: interlock-accept-pump-start
order: 30
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(11) &&
state_action_rule == "interlock-consume-pump-start" && state_action_applied
action: accept
log: true
meta:
policy: '"valve_before_pump"'
result: '"interlock_satisfied"'
- name: interlock-drop-unsafe-pump-start
order: 40
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(11)'
action: drop
log: true
meta:
policy: '"valve_before_pump"'
violation: '"pump_start_without_open_valve"'
outstation: 'dnp3.Outstation()'
- name: interlock-default-accept
order: 1000
condition: 'true'
action: accept
DNP3 file-transfer lifecycle¶
# Customer demo: controlled write of config.bin (<=10 MiB), exact block order,
# same SoftEther owner session, successful close, and maintenance authorization.
# DNP3 functions: 25=open, 26=close, 30=abort; data blocks use Write (FC=2).
version: 1
rules:
- name: file-drop-invalid-open
order: 5
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(25) &&
(!se.Present() || se.User() != "engineer" ||
!StateExists("industrial:maintenance:device:" + string(dnp3.Outstation())) ||
!dnp3m.file.NameEq("config.bin") || !dnp3m.file.ModeEq(2) ||
dnp3m.file.Size() <= 0 || dnp3m.file.Size() > 10485760 ||
!dnp3.HasApplicationSequence())
action: drop
log: true
meta:
policy: '"dnp3_file_transfer"'
violation: '"invalid_open_policy"'
file: 'dnp3m.file.Name()'
size: 'dnp3m.file.Size()'
user: 'se.User()'
- name: file-begin-open
order: 10
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(25) &&
dnp3.HasCanonicalEndpoints() && dnp3.HasApplicationSequence() &&
dnp3m.file.NameEq("config.bin") && dnp3m.file.ModeEq(2) &&
dnp3m.file.Size() > 0 && dnp3m.file.Size() <= 10485760 &&
se.Present() && se.User() == "engineer" &&
StateExists("industrial:maintenance:device:" + string(dnp3.Outstation()))
action: transition_state
state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
state_from: '@absent'
state_value_expr: '"open_pending|" + string(dnp3.ApplicationSequence())'
state_ttl: 30
log: true
meta:
phase: '"open_pending"'
file: 'dnp3m.file.Name()'
size: 'dnp3m.file.Size()'
owner_session: 'se.Session()'
- name: file-bind-owner
order: 11
condition: >
state_action_rule == "file-begin-open" && state_action_applied
action: set_state
state_key_expr: '"dnp3:file_owner:" + dnp3.ControlSessionKey()'
state_value_expr: 'se.Hub() + "|" + se.Session() + "|" + se.User()'
state_ttl: 300
- name: file-accept-valid-open
order: 12
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(25) &&
GetState("dnp3:file_owner:" + dnp3.ControlSessionKey()) ==
se.Hub() + "|" + se.Session() + "|" + se.User()
action: accept
log: true
meta:
policy: '"dnp3_file_transfer"'
result: '"open_allowed"'
- name: file-drop-concurrent-open
order: 13
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(25)'
action: drop
log: true
meta:
policy: '"dnp3_file_transfer"'
violation: '"concurrent_or_duplicate_open"'
- name: file-confirm-open-response
order: 20
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasApplicationSequence() &&
dnp3m.file.StatusEq(0) && dnp3m.file.Handle() > 0
action: transition_state
state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
state_from_expr: '"open_pending|" + string(dnp3.ApplicationSequence())'
state_value_expr: '"opened|" + string(dnp3m.file.Handle()) + "|next:0"'
state_ttl: 120
log: true
meta:
phase: '"opened"'
handle: 'dnp3m.file.Handle()'
- name: file-record-last-block
order: 30
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() &&
dnp3m.file.Present() && dnp3m.file.LastBlock() && dnp3m.file.BlockNum() >= 0 &&
GetState("dnp3:file_owner:" + dnp3.ControlSessionKey()) ==
se.Hub() + "|" + se.Session() + "|" + se.User()
action: transition_state
state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
state_from_expr: >
(dnp3m.file.BlockNum() == 0 ? "opened|" : "transferring|") +
string(dnp3m.file.Handle()) + "|next:" + string(dnp3m.file.BlockNum())
state_value_expr: '"last_block|" + string(dnp3m.file.Handle())'
state_ttl: 60
log: true
meta:
phase: '"last_block"'
block: 'dnp3m.file.BlockNum()'
handle: 'dnp3m.file.Handle()'
- name: file-record-data-block
order: 31
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() &&
dnp3m.file.Present() && !dnp3m.file.LastBlock() && dnp3m.file.BlockNum() >= 0 &&
GetState("dnp3:file_owner:" + dnp3.ControlSessionKey()) ==
se.Hub() + "|" + se.Session() + "|" + se.User()
action: transition_state
state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
state_from_expr: >
(dnp3m.file.BlockNum() == 0 ? "opened|" : "transferring|") +
string(dnp3m.file.Handle()) + "|next:" + string(dnp3m.file.BlockNum())
state_value_expr: >
"transferring|" + string(dnp3m.file.Handle()) + "|next:" +
string(dnp3m.file.BlockNum() + 1)
state_ttl: 120
log: true
meta:
phase: '"transferring"'
block: 'dnp3m.file.BlockNum()'
next_block: 'dnp3m.file.BlockNum() + 1'
- name: file-accept-ordered-block
order: 35
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() && dnp3m.file.Present() &&
(state_action_rule == "file-record-last-block" ||
state_action_rule == "file-record-data-block") && state_action_applied
action: accept
log: true
meta:
policy: '"dnp3_file_transfer"'
result: '"ordered_block"'
block: 'dnp3m.file.BlockNum()'
- name: file-drop-invalid-block
order: 40
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() && dnp3m.file.Present()'
action: drop
log: true
meta:
policy: '"dnp3_file_transfer"'
violation: '"block_without_open_wrong_owner_handle_or_order"'
block: 'dnp3m.file.BlockNum()'
handle: 'dnp3m.file.Handle()'
- name: file-record-close-request
order: 50
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(26) &&
dnp3.HasApplicationSequence() &&
GetState("dnp3:file_owner:" + dnp3.ControlSessionKey()) ==
se.Hub() + "|" + se.Session() + "|" + se.User()
action: transition_state
state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
state_from_expr: '"last_block|" + string(dnp3m.file.Handle())'
state_value_expr: '"close_pending|" + string(dnp3.ApplicationSequence())'
state_ttl: 30
log: true
meta:
phase: '"close_pending"'
handle: 'dnp3m.file.Handle()'
- name: file-accept-valid-close
order: 55
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(26) &&
state_action_rule == "file-record-close-request" && state_action_applied
action: accept
- name: file-drop-invalid-close
order: 56
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(26)'
action: drop
log: true
meta:
policy: '"dnp3_file_transfer"'
violation: '"close_before_last_block_or_wrong_owner"'
- name: file-confirm-close-response
order: 60
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasApplicationSequence() &&
dnp3m.file.StatusEq(0)
action: transition_state
state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
state_from_expr: '"close_pending|" + string(dnp3.ApplicationSequence())'
state_value: completed
state_ttl: 5
log: true
meta:
phase: '"completed"'
- name: file-abort-transfer
order: 70
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(30)'
action: delete_state
state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
log: true
meta:
phase: '"aborted"'
- name: file-default-accept
order: 1000
condition: 'true'
action: accept
DNP3 configuration change and restart verification¶
# Customer demo: a protected configuration Write (G80V1) must be followed by a
# correlated restart and successful Class-0 health scan within policy windows.
version: 1
rules:
- name: config-record-change
order: 10
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() && dnp3.HasObject(80, 1) &&
se.Present() && se.User() == "engineer" &&
StateExists("industrial:maintenance:device:" + string(dnp3.Outstation()))
action: set_state
state_key_expr: '"dnp3:change_restart:" + string(dnp3.Outstation())'
state_value: config_changed
state_ttl: 300
log: true
meta:
phase: '"config_changed"'
user: 'se.User()'
session: 'se.Session()'
outstation: 'dnp3.Outstation()'
- name: config-record-expected-restart
order: 20
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart() &&
dnp3.HasApplicationSequence()
action: transition_state
state_key_expr: '"dnp3:change_restart:" + string(dnp3.Outstation())'
state_from: config_changed
state_value_expr: '"restart_pending|" + string(dnp3.ApplicationSequence())'
state_ttl: 60
log: true
meta:
phase: '"restart_pending"'
sequence: 'dnp3.ApplicationSequence()'
- name: config-accept-expected-restart
order: 25
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart() &&
state_action_rule == "config-record-expected-restart" && state_action_applied
action: accept
log: true
meta:
policy: '"change_then_restart"'
result: '"expected_restart"'
- name: config-drop-unexpected-restart
order: 26
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart()'
action: drop
log: true
meta:
policy: '"change_then_restart"'
violation: '"restart_without_configuration_change"'
outstation: 'dnp3.Outstation()'
- name: config-confirm-device-restarted
order: 30
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.DeviceRestarted() &&
dnp3.HasApplicationSequence()
action: transition_state
state_key_expr: '"dnp3:change_restart:" + string(dnp3.Outstation())'
state_from_expr: '"restart_pending|" + string(dnp3.ApplicationSequence())'
state_value: health_check_required
state_ttl: 120
log: true
meta:
phase: '"health_check_required"'
sequence: 'dnp3.ApplicationSequence()'
- name: config-record-health-scan
order: 40
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsClassRead(0) &&
dnp3.HasApplicationSequence()
action: transition_state
state_key_expr: '"dnp3:change_restart:" + string(dnp3.Outstation())'
state_from: health_check_required
state_value_expr: '"health_pending|" + string(dnp3.ApplicationSequence())'
state_ttl: 60
log: true
meta:
phase: '"health_check_pending"'
sequence: 'dnp3.ApplicationSequence()'
- name: config-verify-health-response
order: 50
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.IsFinalFragment() &&
dnp3.HasApplicationSequence() && !dnp3.HasIINError()
action: transition_state
state_key_expr: '"dnp3:change_restart:" + string(dnp3.Outstation())'
state_from_expr: '"health_pending|" + string(dnp3.ApplicationSequence())'
state_value: verified
state_ttl: 5
log: true
meta:
phase: '"verified"'
outstation: 'dnp3.Outstation()'
- name: config-log-change-without-restart
order: 60
condition: >
dnp3.IsDNP3() && dnp3.HasCanonicalEndpoints() &&
GetState("dnp3:change_restart:" + string(dnp3.Outstation())) == "config_changed" &&
StateAge("dnp3:change_restart:" + string(dnp3.Outstation())) >= 120
action: set_state
state_key_expr: '"dnp3:change_restart:" + string(dnp3.Outstation())'
state_value: failed:restart_timeout
state_ttl: 300
log: true
meta:
phase: '"failed"'
reason: '"configuration_change_without_restart"'
- name: config-default-accept
order: 1000
condition: 'true'
action: accept
Industrial rate and burst detection¶
# Customer demo: rolling-window limits. TTL refreshes on each accepted increment;
# one threshold-crossing event is logged, while every over-limit event is dropped.
version: 1
rules:
- name: rate-count-dnp3-operate
order: 10
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate()'
action: increment_state
state_key_expr: '"rate:dnp3:operate:" + dnp3.ControlSessionKey()'
state_delta: 1
state_limit: 5
state_ttl: 10
- name: rate-mark-operate-threshold
order: 11
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
state_action_rule == "rate-count-dnp3-operate" && !state_action_applied
action: transition_state
state_key_expr: '"rate_alert:dnp3:operate:" + dnp3.ControlSessionKey()'
state_from: '@absent'
state_value: fired
state_ttl: 10
- name: rate-log-operate-threshold-once
order: 12
condition: >
state_action_rule == "rate-mark-operate-threshold" && state_action_applied
action: log
stop: false
meta:
policy: '"rate_burst"'
violation: '"dnp3_operate_rate_exceeded"'
limit: '5'
window_seconds: '10'
master: 'dnp3.Master()'
outstation: 'dnp3.Outstation()'
- name: rate-drop-over-limit-operate
order: 13
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
StateExists("rate_alert:dnp3:operate:" + dnp3.ControlSessionKey()) &&
GetStateInt("rate:dnp3:operate:" + dnp3.ControlSessionKey()) >= 5
action: drop
- name: rate-count-dnp3-restart
order: 20
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart()'
action: increment_state
state_key_expr: '"rate:dnp3:restart:" + dnp3.ControlSessionKey()'
state_delta: 1
state_limit: 2
state_ttl: 60
- name: rate-drop-restart-burst
order: 21
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart() &&
state_action_rule == "rate-count-dnp3-restart" && !state_action_applied
action: drop
log: true
meta:
policy: '"rate_burst"'
violation: '"dnp3_restart_rate_exceeded"'
limit: '2'
window_seconds: '60'
- name: rate-count-dnp3-write
order: 30
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite()'
action: increment_state
state_key_expr: '"rate:dnp3:write:" + dnp3.ControlSessionKey()'
state_delta: 1
state_limit: 20
state_ttl: 10
- name: rate-drop-write-storm
order: 31
condition: >
dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() &&
state_action_rule == "rate-count-dnp3-write" && !state_action_applied
action: drop
log: true
meta:
policy: '"rate_burst"'
violation: '"dnp3_write_storm"'
limit: '20'
window_seconds: '10'
- name: rate-default-accept
order: 1000
condition: 'true'
action: accept
Industrial alarm lifecycle and chattering¶
# Customer demo: BI:40 alarm lifecycle and chattering detection. CROB:41 Latch-On
# acknowledges the alarm. Four real state toggles within a rolling 30s window
# generate one chattering event.
version: 1
rules:
- name: alarm-activate
order: 5
condition: >
dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOn("BI:40") &&
(!StateExists("alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40") ||
GetState("alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40") == "cleared" ||
GetState("alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40") == "cleared_without_ack")
action: set_state
state_key_expr: '"alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40"'
state_value: active
state_ttl: 0
log: true
meta:
phase: '"active"'
alarm_point: '40'
- name: alarm-acknowledge
order: 6
condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(41)'
action: transition_state
state_key_expr: '"alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40"'
state_from: active
state_value: acknowledged
state_ttl: 0
log: true
meta:
phase: '"acknowledged"'
master: 'dnp3.Master()'
- name: alarm-clear-after-ack
order: 7
condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:40")'
action: transition_state
state_key_expr: '"alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40"'
state_from: acknowledged
state_value: cleared
state_ttl: 5
log: true
meta:
phase: '"cleared"'
- name: alarm-clear-without-ack
order: 8
condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:40")'
action: transition_state
state_key_expr: '"alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40"'
state_from: active
state_value: cleared_without_ack
state_ttl: 30
log: true
meta:
phase: '"cleared_without_ack"'
observation: '"alarm_cleared_before_acknowledge"'
- name: alarm-init-last-on
order: 10
condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOn("BI:40")'
action: transition_state
state_key_expr: '"alarm:last:" + string(dnp3.Outstation()) + ":bi40"'
state_from: '@absent'
state_value: on
state_ttl: 300
- name: alarm-init-last-off
order: 11
condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:40")'
action: transition_state
state_key_expr: '"alarm:last:" + string(dnp3.Outstation()) + ":bi40"'
state_from: '@absent'
state_value: off
state_ttl: 300
- name: alarm-toggle-to-on
order: 20
condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOn("BI:40")'
action: transition_state
state_key_expr: '"alarm:last:" + string(dnp3.Outstation()) + ":bi40"'
state_from: off
state_value: on
state_ttl: 300
- name: alarm-toggle-to-off
order: 21
condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:40")'
action: transition_state
state_key_expr: '"alarm:last:" + string(dnp3.Outstation()) + ":bi40"'
state_from: on
state_value: off
state_ttl: 300
- name: alarm-count-real-toggle
order: 30
condition: >
(state_action_rule == "alarm-toggle-to-on" ||
state_action_rule == "alarm-toggle-to-off") && state_action_applied
action: increment_state
state_key_expr: '"alarm:toggles:" + string(dnp3.Outstation()) + ":bi40"'
state_delta: 1
state_ttl: 30
- name: alarm-mark-chattering
order: 40
condition: >
state_action_rule == "alarm-count-real-toggle" && state_action_applied &&
GetStateInt("alarm:toggles:" + string(dnp3.Outstation()) + ":bi40") >= 4
action: transition_state
state_key_expr: '"alarm:chattering:" + string(dnp3.Outstation()) + ":bi40"'
state_from: '@absent'
state_value: detected
state_ttl: 30
- name: alarm-log-chattering-once
order: 41
condition: 'state_action_rule == "alarm-mark-chattering" && state_action_applied'
action: log
meta:
policy: '"alarm_lifecycle"'
observation: '"alarm_chattering"'
point: '"BI:40"'
toggles: 'GetStateInt("alarm:toggles:" + string(dnp3.Outstation()) + ":bi40")'
window_seconds: '30'
- name: alarm-default-accept
order: 1000
condition: 'true'
action: accept
Modbus read-before-write and readback¶
Supported This existing example is preserved and tested. Further Modbus expansion is not currently active.
# Industrial Modbus read-before-write and readback policy.
# Customer parameters: holding register 10, allowed value 0..1000, TTLs, verdicts.
# The fixed target is intentional: Modbus read responses carry values but not the
# original address, so the transaction state supplies that correlation context.
version: 1
rules:
- name: modbus-rbw-record-read-request
order: 10
condition: >
modbus.IsModbus() && modbus.IsRequest() && modbus.Function() == 3 &&
modbus.Address() == 10 && modbus.Quantity() == 1 &&
modbus.TransactionKey() != ""
action: set_state
state_key_expr: '"modbus:rbw:read-tx:" + modbus.TransactionKey()'
state_value: pending
state_ttl: 5
- name: modbus-rbw-confirm-read-response
order: 20
condition: >
modbus.IsModbus() && modbus.IsResponse() && !modbus.IsException() &&
modbus.Function() == 3 && modbus.RegisterValueCount() == 1 &&
modbus.TransactionKey() != ""
action: transition_state
state_key_expr: '"modbus:rbw:read-tx:" + modbus.TransactionKey()'
state_from: pending
state_value: confirmed
state_ttl: 2
- name: modbus-rbw-authorize-write-after-read
order: 30
condition: >
state_action_rule == "modbus-rbw-confirm-read-response" &&
state_action_applied
action: set_state
state_key_expr: '"modbus:rbw:read-ok:" + modbus.SessionKey() + ":hr10"'
state_value: authorized
state_ttl: 30
log: true
meta:
phase: '"read_confirmed"'
register: '10'
observed_value: 'modbus.RegisterValueAt(0)'
transaction: 'modbus.TransactionID()'
- name: modbus-rbw-record-authorized-write
order: 40
condition: >
modbus.IsModbus() && modbus.IsRequest() && modbus.Function() == 6 &&
modbus.Address() == 10 && modbus.RegisterValueCount() == 1 &&
modbus.RegValue(10) >= 0 && modbus.RegValue(10) <= 1000 &&
StateExists("modbus:rbw:read-ok:" + modbus.SessionKey() + ":hr10") &&
modbus.TransactionKey() != ""
action: set_state
state_key_expr: '"modbus:rbw:write-tx:" + modbus.TransactionKey()'
state_value_expr: 'string(modbus.RegValue(10))'
state_ttl: 5
log: true
meta:
phase: '"write_requested"'
register: '10'
requested_value: 'modbus.RegValue(10)'
transaction: 'modbus.TransactionID()'
- name: modbus-rbw-drop-write-without-read
order: 50
condition: >
modbus.IsModbus() && modbus.IsRequest() && modbus.Function() == 6 &&
modbus.Address() == 10 &&
!StateExists("modbus:rbw:read-ok:" + modbus.SessionKey() + ":hr10")
action: drop
log: true
meta:
violation: '"write_without_confirmed_read"'
register: '10'
requested_value: 'modbus.RegValue(10)'
- name: modbus-rbw-confirm-write-response
order: 60
condition: >
modbus.IsModbus() && modbus.IsResponse() && !modbus.IsException() &&
modbus.Function() == 6 && modbus.TransactionKey() != ""
action: transition_state
state_key_expr: '"modbus:rbw:write-tx:" + modbus.TransactionKey()'
state_from_expr: 'string(modbus.RegValue(10))'
state_value: acknowledged
state_ttl: 2
- name: modbus-rbw-await-readback
order: 70
condition: >
state_action_rule == "modbus-rbw-confirm-write-response" &&
state_action_applied
action: set_state
state_key_expr: '"modbus:rbw:expected:" + modbus.SessionKey() + ":hr10"'
state_value_expr: 'string(modbus.RegValue(10))'
state_ttl: 30
- name: modbus-rbw-verify-readback
order: 80
condition: >
modbus.IsModbus() && modbus.IsResponse() && modbus.Function() == 3 &&
modbus.RegisterValueCount() == 1 &&
StateExists("modbus:rbw:expected:" + modbus.SessionKey() + ":hr10") &&
modbus.RegisterValueAt(0) == GetStateInt("modbus:rbw:expected:" + modbus.SessionKey() + ":hr10")
action: delete_state
state_key_expr: '"modbus:rbw:expected:" + modbus.SessionKey() + ":hr10"'
log: true
meta:
phase: '"readback_verified"'
register: '10'
observed_value: 'modbus.RegisterValueAt(0)'
- name: modbus-rbw-log-readback-mismatch
order: 90
condition: >
modbus.IsModbus() && modbus.IsResponse() && modbus.Function() == 3 &&
modbus.RegisterValueCount() == 1 &&
StateExists("modbus:rbw:expected:" + modbus.SessionKey() + ":hr10") &&
modbus.RegisterValueAt(0) != GetStateInt("modbus:rbw:expected:" + modbus.SessionKey() + ":hr10")
action: log
log: true
meta:
violation: '"readback_mismatch"'
register: '10'
expected: 'GetStateInt("modbus:rbw:expected:" + modbus.SessionKey() + ":hr10")'
observed: 'modbus.RegisterValueAt(0)'
- name: modbus-rbw-default-accept
order: 1000
condition: 'true'
action: accept