feat(pipeline): PLC/SPS control-logic security scanner (IEC 61131-3)
CI / Check (pull_request) Failing after 2m31s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped

Implements ScanType::PlcControlLogic — the missing piece for PlcSps targets,
which previously classified but ran no scan.

New `pipeline::plc`:
- A real IEC 61131-3 Structured Text front end: lexer + recursive-descent parser
  → AST (POUs, typed VAR sections, statements, expressions). Tolerant recovery so
  odd constructs never sink a file.
- PLCopen XML extractor: pulls each ST POU's interface vars + `<ST>` body and
  reconstructs equivalent ST, so raw `.st` files and PLCopen projects share one
  analysis path.
- Eight semantic, guard-aware rules over the AST → findings: hardcoded
  credentials, default/weak passwords, safety-interlock/watchdog bypass, array
  indexed by unvalidated input, division without a zero-guard (suppressed when an
  enclosing `IF <d> <> 0` proves it), insecure comm (auth/encryption disabled),
  and cleartext OT protocol ports, plus unstructured JMP. Each carries CWE +
  remediation.
- `PlcControlLogicScanner` (Scanner impl) walks the project tree and emits
  `Finding`s (dedup fingerprint, file, line, severity).

Wired into `run_target_pipeline`: when the scan plan includes PlcControlLogic,
`run_plc_scan` ingests the PlcProject artifact, analyzes it, and persists the
findings (findings_count handled by run_target).

Demo fixtures under examples/plc-demo/ (a vulnerable pump-station `.st` + a
PLCopen `conveyor.xml`). Tests: parser, all-rules-fire, guarded-clean-is-quiet,
and an end-to-end tree scan — 5 passing.

Adds `roxmltree` (read-only XML) for PLCopen parsing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-07-16 09:52:42 +02:00
co-authored by Claude Opus 4.8
parent a981311413
commit fcd49ecdf7
12 changed files with 2463 additions and 8 deletions
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Demo PLCopen project — conveyor sorter. Deliberately vulnerable. -->
<project xmlns="http://www.plcopen.org/xml/tc6_0201">
<types>
<pous>
<pou name="ConveyorCtrl" pouType="program">
<interface>
<localVars>
<variable name="AdminPwd">
<type><string/></type>
<initialValue><simpleValue value="password"/></initialValue>
</variable>
<variable name="Belt">
<type>
<array>
<dimension lower="0" upper="3"/>
<baseType><INT/></baseType>
</array>
</type>
</variable>
</localVars>
<inputVars>
<variable name="Slot"><type><INT/></type></variable>
</inputVars>
</interface>
<body>
<ST>
<xhtml xmlns="http://www.w3.org/1999/xhtml">Belt[Slot] := 1;
Ftp_Send(HOST := '192.168.1.5', PORT := 21, ENCRYPT := FALSE);
</xhtml>
</ST>
</body>
</pou>
</pous>
</types>
</project>
+57
View File
@@ -0,0 +1,57 @@
(*
* Demo PLC program pump-station control (IEC 61131-3 Structured Text).
*
* Deliberately vulnerable, for the compliance-scanner PLC control-logic demo.
* Each issue below is flagged by pipeline::plc::rules.
*)
FUNCTION_BLOCK PumpStationCtrl
VAR_INPUT
OperatorCmd : INT; (* HMI command index untrusted *)
FlowSetpoint : REAL;
END_VAR
VAR_OUTPUT
PumpSpeed : REAL;
Fault : BOOL;
END_VAR
VAR
HmiPassword : STRING := 'admin123'; (* hardcoded + default credential *)
ApiKey : STRING := 'sk_live_9c1f2a'; (* hardcoded secret *)
PumpProfiles : ARRAY[0..7] OF REAL;
Safety_Enable : BOOL := TRUE;
Watchdog_Kick : INT := 1;
MeasuredFlow : REAL;
ScaleFactor : REAL;
i : INT;
END_VAR
(* Operator can index the profile table with an unvalidated command. *)
PumpSpeed := PumpProfiles[OperatorCmd];
(* Divisor is a live process value that can read zero on a stopped line. *)
ScaleFactor := FlowSetpoint / MeasuredFlow;
(* Safety interlock disabled straight from application logic. *)
IF OperatorCmd = 99 THEN
Safety_Enable := FALSE;
Watchdog_Kick := 0;
END_IF;
(* Unauthenticated Modbus/TCP link on the cleartext OT port. *)
Modbus_TCP_Connect(IP := '10.10.5.20', PORT := 502, AUTH := FALSE, PASSWORD := 'plc');
(* Unstructured jump around the fault handler. *)
IF MeasuredFlow > 1000.0 THEN
JMP trip;
END_IF;
(* A correctly guarded division must NOT be flagged. *)
IF ScaleFactor <> 0.0 THEN
PumpSpeed := PumpSpeed / ScaleFactor;
END_IF;
RETURN;
trip:
Fault := TRUE;
PumpSpeed := 0.0;
END_FUNCTION_BLOCK