From aab22d64929657ec7e4d420434c845b54f5e2366 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:36:34 +0200 Subject: [PATCH] feat(plc): analyse graphical logic (FBD/LD) from PLCopen XML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CODESYS control logic is frequently written in graphical languages, but the scanner only understood Structured-Text bodies — FBD/LD/SFC were skipped, so their comm calls, hardcoded arguments and safety writes went unseen. Translate FBD/LD networks to synthetic ST and run them through the existing ST parser + rules: blocks become calls (`TypeName(pin := arg, …)`), out-variables and coils become assignments, and input pins are resolved by tracing `connectionPointIn`/`refLocalId` back through the network (contacts AND-chain to the power rail; nested blocks are referenced by a synthetic result so calls are emitted exactly once). SFC step/transition graphs are skipped, but the ST/FBD/LD bodies embedded in their actions/transitions are still translated. Also fixes a latent doubling bug in `collect_text`: `descendants()` yields both an element and its child text node, so every value was collected twice. Harmless for ST (duplicate statements landed on one synthetic line and deduped) but it corrupted graphical single-token expressions (`502` -> `502502`, `FALSE` -> `FALSEFALSE`). Now only text nodes are gathered. Adds an FBD demo fixture (pump_fbd.xml) + FBD/LD/regression tests. Part of #165 (graphical languages). Native `.project`/`.projectarchive` parsing and SFC graph semantics remain follow-ons. Tracker #167. Co-Authored-By: Claude Opus 4.8 --- compliance-agent/src/pipeline/plc/mod.rs | 33 ++ compliance-agent/src/pipeline/plc/plcopen.rs | 305 +++++++++++++++++-- examples/plc-demo/pump_fbd.xml | 56 ++++ 3 files changed, 376 insertions(+), 18 deletions(-) create mode 100644 examples/plc-demo/pump_fbd.xml diff --git a/compliance-agent/src/pipeline/plc/mod.rs b/compliance-agent/src/pipeline/plc/mod.rs index f64b676..417004a 100644 --- a/compliance-agent/src/pipeline/plc/mod.rs +++ b/compliance-agent/src/pipeline/plc/mod.rs @@ -197,4 +197,37 @@ mod tests { "the CASE state machine uses no JMP" ); } + + /// Graphical logic must be analysed too: an FBD POU (blocks + in/out + /// variables) is translated to synthetic ST, so the same rules fire on the + /// cleartext Modbus block, the hardcoded HMI password and the safety write. + #[test] + fn fbd_graphical_body_is_analysed() { + let all = analyze_tree(&demo_dir(), "demo-target"); + let fbd: Vec<_> = all + .iter() + .filter(|f| { + f.file_path + .as_deref() + .is_some_and(|p| p.ends_with("pump_fbd.xml")) + }) + .collect(); + assert!( + !fbd.is_empty(), + "pump_fbd.xml (FBD) should produce findings" + ); + + let rules: HashSet<&str> = fbd.iter().filter_map(|f| f.rule_id.as_deref()).collect(); + for r in [ + "plc-insecure-comm", // Modbus_TCP_Master(AUTH := FALSE) + "plc-insecure-protocol-port", // PORT := 502 + "plc-hardcoded-credential", // HmiPassword := 'admin123' + "plc-safety-bypass", // Safety_Enable := FALSE + ] { + assert!( + rules.contains(r), + "expected rule {r} from FBD; got {rules:?}" + ); + } + } } diff --git a/compliance-agent/src/pipeline/plc/plcopen.rs b/compliance-agent/src/pipeline/plc/plcopen.rs index ec2228f..7b6eaa0 100644 --- a/compliance-agent/src/pipeline/plc/plcopen.rs +++ b/compliance-agent/src/pipeline/plc/plcopen.rs @@ -1,17 +1,30 @@ //! PLCopen XML → Structured Text POUs. //! //! A PLCopen project stores each POU as `` with an -//! `` (typed variable sections) and a ``. We handle the -//! Structured-Text body form (``); FBD/LD/SFC bodies are skipped. +//! `` (typed variable sections) and a `` in one of the IEC +//! 61131-3 languages. We reconstruct an equivalent Structured-Text source for +//! each POU (a `VAR` block from the interface + statements from the body) and run +//! it through the ST parser, so raw `.st` files and PLCopen projects — textual or +//! graphical — flow through one analysis path. //! -//! For each ST POU we reconstruct an equivalent ST source (a `VAR` block built -//! from the interface + the ST body) and run it through the ST parser, so both -//! raw `.st` files and PLCopen projects flow through one analysis path. +//! Body languages: +//! - **ST** — taken verbatim. +//! - **FBD / LD** — the graphical network is translated to synthetic ST: blocks +//! become calls (`TypeName(pin := arg, …)`), out-variables / coils become +//! assignments, with input pins resolved by tracing connections. This lets the +//! semantic rules see comm calls, hardcoded arguments and safety writes that +//! live in graphical logic, not just in text. +//! - **SFC** — the step/transition graph itself is skipped; the ST/FBD/LD bodies +//! embedded in its actions and transitions are still translated. + +use std::collections::HashMap; + +use roxmltree::Node; use super::ast::Pou; use super::parser; -/// Parse every Structured-Text POU out of a PLCopen XML document. +/// Parse every POU out of a PLCopen XML document (ST, FBD or LD bodies). pub fn parse_plcopen(xml: &str) -> Vec { let doc = match roxmltree::Document::parse(xml) { Ok(d) => d, @@ -22,14 +35,9 @@ pub fn parse_plcopen(xml: &str) -> Vec { let name = pou.attribute("name").unwrap_or("pou").to_string(); let pou_type = pou.attribute("pouType").unwrap_or("program"); - // ST body text (skip non-ST bodies). - let Some(st_node) = pou - .descendants() - .find(|n| n.has_tag_name("ST") && n.ancestors().any(|a| a.has_tag_name("body"))) - else { + let Some(body) = reconstruct_body(pou) else { continue; }; - let body = collect_text(st_node); if body.trim().is_empty() { continue; } @@ -46,17 +54,195 @@ pub fn parse_plcopen(xml: &str) -> Vec { pous } -/// Concatenate all descendant text of a node (ST bodies are often wrapped in -/// `` and may contain multiple text runs). -fn collect_text(node: roxmltree::Node) -> String { +/// Case-insensitive tag match (PLCopen uses `FBD`/`LD`/`ST`, CODESYS may vary). +fn tag_is(n: &Node, name: &str) -> bool { + n.tag_name().name().eq_ignore_ascii_case(name) +} + +/// Reconstruct a POU's body as Structured Text, whatever language it is written +/// in. Concatenates every language body found under `` (SFC actions and +/// transitions carry their own ST/FBD/LD sub-bodies). +fn reconstruct_body(pou: Node) -> Option { + let mut out = String::new(); + for body in pou.descendants().filter(|n| tag_is(n, "body")) { + for lang in body.children().filter(|n| n.is_element()) { + let piece = match lang.tag_name().name().to_ascii_uppercase().as_str() { + "ST" | "IL" => collect_text(lang), + "FBD" | "LD" => translate_network(lang), + _ => continue, + }; + if !piece.trim().is_empty() { + out.push_str(&piece); + if !piece.ends_with('\n') { + out.push('\n'); + } + } + } + } + if out.trim().is_empty() { + None + } else { + Some(out) + } +} + +// ── graphical (FBD / LD) → synthetic ST ──────────────────────────────── + +/// Translate one FBD/LD network into ST statements: blocks → calls, +/// out-variables and coils → assignments. +fn translate_network(net: Node) -> String { + let by_id = index_local_ids(net); + let mut out = String::new(); + for el in net.children().filter(|n| n.is_element()) { + let stmt = match el.tag_name().name().to_ascii_lowercase().as_str() { + "block" => block_call(el, &by_id).map(|c| format!("{c};")), + "outvariable" => out_assignment(el, &by_id), + "coil" => coil_assignment(el, &by_id), + _ => None, + }; + if let Some(s) = stmt { + out.push_str(&s); + out.push('\n'); + } + } + out +} + +/// Index every element in a network by its `localId` so connections resolve. +fn index_local_ids<'a, 'input>(net: Node<'a, 'input>) -> HashMap> { + net.descendants() + .filter(|n| n.is_element()) + .filter_map(|n| n.attribute("localId").map(|id| (id.to_string(), n))) + .collect() +} + +/// Build a call expression for a block: `TypeName(pin := arg, …)`. +fn block_call(block: Node, by_id: &HashMap) -> Option { + let ty = block.attribute("typeName")?; + let mut args = Vec::new(); + if let Some(inputs) = block.children().find(|n| tag_is(n, "inputVariables")) { + for v in inputs.children().filter(|n| tag_is(n, "variable")) { + let Some(expr) = input_expr(v, by_id, 0) else { + continue; + }; + match v.attribute("formalParameter") { + Some(pin) if !pin.is_empty() => args.push(format!("{pin} := {expr}")), + _ => args.push(expr), + } + } + } + Some(format!("{ty}({})", args.join(", "))) +} + +/// `target := ;` for an FBD out-variable. +fn out_assignment(outvar: Node, by_id: &HashMap) -> Option { + let target = expression_text(outvar)?; + let value = input_expr(outvar, by_id, 0).unwrap_or_else(|| "0".to_string()); + Some(format!("{target} := {value};")) +} + +/// `coil := ;` for an LD coil (negated → `NOT (…)`). +fn coil_assignment(coil: Node, by_id: &HashMap) -> Option { + let target = child_text(coil, "variable")?; + let rung = input_expr(coil, by_id, 0).unwrap_or_else(|| "TRUE".to_string()); + let negated = matches!(coil.attribute("negated"), Some(v) if v.eq_ignore_ascii_case("true")); + let rhs = if negated { + format!("NOT ({rung})") + } else { + rung + }; + Some(format!("{target} := {rhs};")) +} + +/// Resolve the expression feeding `node`'s single input connection. +fn input_expr(node: Node, by_id: &HashMap, depth: u8) -> Option { + let refid = ref_local_id(node)?; + Some(expr_for(&refid, by_id, depth)) +} + +/// Build the ST expression produced by the element with this `localId`. +fn expr_for(local_id: &str, by_id: &HashMap, depth: u8) -> String { + if depth > 24 { + return "0".to_string(); + } + let Some(node) = by_id.get(local_id) else { + return format!("__net{local_id}"); + }; + match node.tag_name().name().to_ascii_lowercase().as_str() { + "invariable" | "inoutvariable" => { + expression_text(*node).unwrap_or_else(|| format!("__net{local_id}")) + } + // A block feeding another element: reference it by a synthetic result + // name; the block is emitted as its own call statement, so we neither + // duplicate the call nor lose it. + "block" => format!("__blk{local_id}"), + "contact" => { + let var = child_text(*node, "variable").unwrap_or_else(|| "TRUE".to_string()); + let negated = + matches!(node.attribute("negated"), Some(v) if v.eq_ignore_ascii_case("true")); + let term = if negated { format!("NOT {var}") } else { var }; + match ref_local_id(*node) { + Some(up) => { + let upstream = expr_for(&up, by_id, depth + 1); + if upstream == "TRUE" { + term + } else { + format!("({upstream} AND {term})") + } + } + None => term, + } + } + "leftpowerrail" => "TRUE".to_string(), + _ => format!("__net{local_id}"), + } +} + +/// The `refLocalId` of `node`'s first input connection, if any. +fn ref_local_id(node: Node) -> Option { node.descendants() + .find(|n| tag_is(n, "connectionPointIn")) + .and_then(|cpi| cpi.descendants().find(|n| tag_is(n, "connection"))) + .and_then(|c| c.attribute("refLocalId")) + .map(|s| s.to_string()) +} + +/// Text of a node's `` child (variable name or literal). +fn expression_text(node: Node) -> Option { + let e = node.children().find(|n| tag_is(n, "expression"))?; + let t = collect_text(e).trim().to_string(); + if t.is_empty() { + None + } else { + Some(t) + } +} + +/// Text of a named child element (e.g. `` of a contact/coil). +fn child_text(node: Node, name: &str) -> Option { + let c = node.children().find(|n| tag_is(n, name))?; + let t = collect_text(c).trim().to_string(); + if t.is_empty() { + None + } else { + Some(t) + } +} + +/// Concatenate the text of a node's descendant text nodes (bodies are often +/// wrapped in `` and may contain multiple text runs). Only text nodes are +/// gathered: an element's `.text()` would re-yield its first child's text, which +/// (with the text node itself) would duplicate every value. +fn collect_text(node: Node) -> String { + node.descendants() + .filter(|n| n.is_text()) .filter_map(|n| n.text()) .collect::() } /// Build an ST `VAR … END_VAR` block from a POU's `` variable /// sections, so declarations (types, initial values) reach the rules. -fn build_var_block(pou: roxmltree::Node) -> String { +fn build_var_block(pou: Node) -> String { let Some(interface) = pou.children().find(|n| n.has_tag_name("interface")) else { return String::new(); }; @@ -96,7 +282,7 @@ fn build_var_block(pou: roxmltree::Node) -> String { } /// Render a PLCopen `` element as an ST type string. -fn type_name(type_node: roxmltree::Node) -> String { +fn type_name(type_node: Node) -> String { let Some(inner) = type_node.children().find(|n| n.is_element()) else { return "BOOL".to_string(); }; @@ -127,7 +313,7 @@ fn type_name(type_node: roxmltree::Node) -> String { } /// Extract an initial value as an ST literal (quoting strings). -fn initial_value(iv: roxmltree::Node) -> Option { +fn initial_value(iv: Node) -> Option { let simple = iv.descendants().find(|n| n.has_tag_name("simpleValue"))?; let raw = simple.attribute("value")?.trim().to_string(); if raw.is_empty() { @@ -147,3 +333,86 @@ fn initial_value(iv: roxmltree::Node) -> Option { Some(format!("'{}'", raw.replace('\'', "''"))) } } + +#[cfg(test)] +mod tests { + use super::parse_plcopen; + use crate::pipeline::plc::rules; + use std::collections::HashSet; + + fn rule_ids(xml: &str) -> HashSet<&'static str> { + parse_plcopen(xml) + .iter() + .flat_map(rules::analyze) + .map(|h| h.rule_id) + .collect() + } + + /// A Ladder Diagram network: a rung (power rail → contact → coil) plus an + /// insecure comm block. Coils/contacts translate to assignments; the block + /// translates to a call so the port rule fires. + #[test] + fn ld_coil_and_block_translate_and_are_analysed() { + let xml = r#" + + + + + + + + + Start + + Motor + + 21 + FALSE + + + + + + + + + + + +"#; + let ids = rule_ids(xml); + // Ftp_Send(PORT := 21, ENCRYPT := FALSE) — port 21 is an insecure protocol. + assert!( + ids.contains("plc-insecure-protocol-port"), + "LD block should flag port 21; got {ids:?}" + ); + } + + /// Doubled-text regression: a graphical expression must be extracted once, + /// so literals like `502` and `FALSE` stay intact (not `502502`/`FALSEFALSE`). + #[test] + fn graphical_expression_text_is_not_duplicated() { + let xml = r#" + + + + + 502 + FALSE + + + + + + + + + + + +"#; + let ids = rule_ids(xml); + assert!(ids.contains("plc-insecure-protocol-port")); // PORT := 502 (not 502502) + assert!(ids.contains("plc-insecure-comm")); // AUTH := FALSE (not FALSEFALSE) + } +} diff --git a/examples/plc-demo/pump_fbd.xml b/examples/plc-demo/pump_fbd.xml new file mode 100644 index 0000000..ea65eab --- /dev/null +++ b/examples/plc-demo/pump_fbd.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + '10.20.0.5' + 502 + FALSE + + + + + + + + + + + + + + + + + 'admin123' + + HmiPassword + + + + + FALSE + + Safety_Enable + + + + + + + + -- 2.54.0