feat(pipeline): PLC/SPS control-logic security scanner (IEC 61131-3) #162
@@ -153,4 +153,48 @@ mod tests {
|
|||||||
.count();
|
.count();
|
||||||
assert_eq!(div0, 1, "only the unguarded division should be flagged");
|
assert_eq!(div0, 1, "only the unguarded division should be flagged");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The realistic OpenPLC-style traffic-light sample is mostly sound control
|
||||||
|
/// logic: the scanner must surface its few genuine defects and stay quiet on
|
||||||
|
/// the timed state machine and the guarded duty-cycle division.
|
||||||
|
#[test]
|
||||||
|
fn realistic_sample_flags_only_real_issues() {
|
||||||
|
let all = analyze_tree(&demo_dir(), "demo-target");
|
||||||
|
let tl: Vec<_> = all
|
||||||
|
.iter()
|
||||||
|
.filter(|f| {
|
||||||
|
f.file_path
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|p| p.ends_with("traffic_light.st"))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(!tl.is_empty(), "traffic_light.st should produce findings");
|
||||||
|
|
||||||
|
let rules: HashSet<&str> = tl.iter().filter_map(|f| f.rule_id.as_deref()).collect();
|
||||||
|
// The three planted defects: hardcoded SCADA password, cleartext Modbus
|
||||||
|
// master (no auth), and a maintenance mode that drops the PedPermit.
|
||||||
|
for r in [
|
||||||
|
"plc-hardcoded-credential",
|
||||||
|
"plc-insecure-comm",
|
||||||
|
"plc-safety-bypass",
|
||||||
|
] {
|
||||||
|
assert!(rules.contains(r), "expected rule {r}; got {rules:?}");
|
||||||
|
}
|
||||||
|
// Modbus/TCP on 502 is also an insecure-protocol port.
|
||||||
|
assert!(rules.contains("plc-insecure-protocol-port"));
|
||||||
|
|
||||||
|
// Low false positives: the guarded `IF LampCount <> 0` division and the
|
||||||
|
// JMP-free state machine must not trip anything.
|
||||||
|
assert_eq!(
|
||||||
|
tl.iter()
|
||||||
|
.filter(|f| f.rule_id.as_deref() == Some("plc-division-by-zero"))
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
"the guarded duty-cycle division must not be flagged"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!rules.contains("plc-unstructured-jump"),
|
||||||
|
"the CASE state machine uses no JMP"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
(*
|
||||||
|
* Pedestrian-crossing traffic-light controller.
|
||||||
|
*
|
||||||
|
* Structure adapted from the classic OpenPLC "traffic light" example
|
||||||
|
* (github.com/thiagoralves/OpenPLC_v3 examples) — a timed state machine
|
||||||
|
* driving vehicle + pedestrian lamps, extended with a SCADA/Modbus link
|
||||||
|
* and a maintenance override so it reads like a real deployed program.
|
||||||
|
*
|
||||||
|
* The control logic itself is sound; the security-relevant defects are the
|
||||||
|
* kind that slip into field code under deadline: a hardcoded SCADA password,
|
||||||
|
* a cleartext Modbus/TCP master, and a maintenance mode that drops the
|
||||||
|
* pedestrian safety permit. Everything else should stay quiet.
|
||||||
|
*)
|
||||||
|
PROGRAM TrafficLight
|
||||||
|
VAR
|
||||||
|
State : INT := 0; (* 0 GreenVeh, 1 Amber, 2 RedVeh/WalkPed, 3 FlashPed *)
|
||||||
|
Tmr : TON;
|
||||||
|
StateElapsed : TIME;
|
||||||
|
CycleMs : DINT := 0;
|
||||||
|
|
||||||
|
(* Lamp outputs *)
|
||||||
|
VehGreen : BOOL := FALSE;
|
||||||
|
VehAmber : BOOL := FALSE;
|
||||||
|
VehRed : BOOL := FALSE;
|
||||||
|
PedWalk : BOOL := FALSE;
|
||||||
|
PedStop : BOOL := TRUE;
|
||||||
|
|
||||||
|
(* Pedestrian safety permit — must be TRUE before the WALK phase asserts *)
|
||||||
|
PedPermit : BOOL := TRUE;
|
||||||
|
PedButton : BOOL := FALSE;
|
||||||
|
|
||||||
|
(* SCADA / remote monitoring *)
|
||||||
|
ScadaUser : STRING := 'operator';
|
||||||
|
ScadaPassword : STRING := 'Tr@ffic2019'; (* hardcoded SCADA credential *)
|
||||||
|
ModbusReady : BOOL := FALSE;
|
||||||
|
|
||||||
|
(* Maintenance override *)
|
||||||
|
MaintMode : BOOL := FALSE;
|
||||||
|
LampCount : INT := 5;
|
||||||
|
DutyPct : INT;
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
(* ---- SCADA uplink: publish state to the control room over Modbus/TCP ---- *)
|
||||||
|
IF NOT ModbusReady THEN
|
||||||
|
Modbus_TCP_Master(IP := '10.20.0.5', PORT := 502, AUTH := FALSE, USER := ScadaUser, PASS := ScadaPassword);
|
||||||
|
ModbusReady := TRUE;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* ---- Duty-cycle for the flashing pedestrian lamp (guarded division) ---- *)
|
||||||
|
IF LampCount <> 0 THEN
|
||||||
|
DutyPct := (CycleMs * 100) / LampCount;
|
||||||
|
END_IF;
|
||||||
|
|
||||||
|
(* ---- Maintenance override: flash amber, hand control to the technician ---- *)
|
||||||
|
IF MaintMode THEN
|
||||||
|
VehGreen := FALSE;
|
||||||
|
VehRed := FALSE;
|
||||||
|
VehAmber := NOT VehAmber;
|
||||||
|
PedPermit := FALSE; (* drops the pedestrian safety permit in code *)
|
||||||
|
PedWalk := FALSE;
|
||||||
|
PedStop := TRUE;
|
||||||
|
ELSE
|
||||||
|
(* ---- Normal timed state machine ---- *)
|
||||||
|
Tmr(IN := TRUE, PT := T#5s);
|
||||||
|
StateElapsed := Tmr.ET;
|
||||||
|
|
||||||
|
CASE State OF
|
||||||
|
0: (* vehicles go, pedestrians stop *)
|
||||||
|
VehGreen := TRUE; VehAmber := FALSE; VehRed := FALSE;
|
||||||
|
PedWalk := FALSE; PedStop := TRUE;
|
||||||
|
IF PedButton AND Tmr.Q THEN
|
||||||
|
State := 1; Tmr(IN := FALSE);
|
||||||
|
END_IF;
|
||||||
|
1: (* amber transition *)
|
||||||
|
VehGreen := FALSE; VehAmber := TRUE;
|
||||||
|
IF Tmr.Q THEN State := 2; Tmr(IN := FALSE); END_IF;
|
||||||
|
2: (* vehicles stop, pedestrians walk — only if permitted *)
|
||||||
|
VehAmber := FALSE; VehRed := TRUE;
|
||||||
|
IF PedPermit THEN
|
||||||
|
PedWalk := TRUE; PedStop := FALSE;
|
||||||
|
END_IF;
|
||||||
|
IF Tmr.Q THEN State := 3; Tmr(IN := FALSE); END_IF;
|
||||||
|
3: (* flashing don't-walk before returning to green *)
|
||||||
|
PedWalk := NOT PedWalk;
|
||||||
|
IF Tmr.Q THEN
|
||||||
|
State := 0; PedButton := FALSE; Tmr(IN := FALSE);
|
||||||
|
END_IF;
|
||||||
|
ELSE
|
||||||
|
State := 0;
|
||||||
|
END_CASE;
|
||||||
|
END_IF;
|
||||||
|
END_PROGRAM
|
||||||
Reference in New Issue
Block a user