diff --git a/compliance-agent/src/pipeline/plc/mod.rs b/compliance-agent/src/pipeline/plc/mod.rs index b23991e..f64b676 100644 --- a/compliance-agent/src/pipeline/plc/mod.rs +++ b/compliance-agent/src/pipeline/plc/mod.rs @@ -153,4 +153,48 @@ mod tests { .count(); 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" + ); + } } diff --git a/examples/plc-demo/traffic_light.st b/examples/plc-demo/traffic_light.st new file mode 100644 index 0000000..98c3ba5 --- /dev/null +++ b/examples/plc-demo/traffic_light.st @@ -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