feat(plc): analyse graphical logic (FBD/LD) from PLCopen XML (#169)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 5m23s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped

This commit was merged in pull request #169.
This commit is contained in:
2026-07-16 11:43:49 +00:00
parent 3a53a1d7f2
commit 386d8457d6
3 changed files with 376 additions and 18 deletions
+33
View File
@@ -197,4 +197,37 @@ mod tests {
"the CASE state machine uses no JMP" "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:?}"
);
}
}
} }
+287 -18
View File
@@ -1,17 +1,30 @@
//! PLCopen XML → Structured Text POUs. //! PLCopen XML → Structured Text POUs.
//! //!
//! A PLCopen project stores each POU as `<pou name=".." pouType="..">` with an //! A PLCopen project stores each POU as `<pou name=".." pouType="..">` with an
//! `<interface>` (typed variable sections) and a `<body>`. We handle the //! `<interface>` (typed variable sections) and a `<body>` in one of the IEC
//! Structured-Text body form (`<ST>…</ST>`); FBD/LD/SFC bodies are skipped. //! 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 //! Body languages:
//! from the interface + the ST body) and run it through the ST parser, so both //! - **ST** — taken verbatim.
//! raw `.st` files and PLCopen projects flow through one analysis path. //! - **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::ast::Pou;
use super::parser; 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<Pou> { pub fn parse_plcopen(xml: &str) -> Vec<Pou> {
let doc = match roxmltree::Document::parse(xml) { let doc = match roxmltree::Document::parse(xml) {
Ok(d) => d, Ok(d) => d,
@@ -22,14 +35,9 @@ pub fn parse_plcopen(xml: &str) -> Vec<Pou> {
let name = pou.attribute("name").unwrap_or("pou").to_string(); let name = pou.attribute("name").unwrap_or("pou").to_string();
let pou_type = pou.attribute("pouType").unwrap_or("program"); let pou_type = pou.attribute("pouType").unwrap_or("program");
// ST body text (skip non-ST bodies). let Some(body) = reconstruct_body(pou) else {
let Some(st_node) = pou
.descendants()
.find(|n| n.has_tag_name("ST") && n.ancestors().any(|a| a.has_tag_name("body")))
else {
continue; continue;
}; };
let body = collect_text(st_node);
if body.trim().is_empty() { if body.trim().is_empty() {
continue; continue;
} }
@@ -46,17 +54,195 @@ pub fn parse_plcopen(xml: &str) -> Vec<Pou> {
pous pous
} }
/// Concatenate all descendant text of a node (ST bodies are often wrapped in /// Case-insensitive tag match (PLCopen uses `FBD`/`LD`/`ST`, CODESYS may vary).
/// `<xhtml>` and may contain multiple text runs). fn tag_is(n: &Node, name: &str) -> bool {
fn collect_text(node: roxmltree::Node) -> String { 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 `<body>` (SFC actions and
/// transitions carry their own ST/FBD/LD sub-bodies).
fn reconstruct_body(pou: Node) -> Option<String> {
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<String, Node<'a, 'input>> {
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<String, Node>) -> Option<String> {
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 := <traced expression>;` for an FBD out-variable.
fn out_assignment(outvar: Node, by_id: &HashMap<String, Node>) -> Option<String> {
let target = expression_text(outvar)?;
let value = input_expr(outvar, by_id, 0).unwrap_or_else(|| "0".to_string());
Some(format!("{target} := {value};"))
}
/// `coil := <traced rung expression>;` for an LD coil (negated → `NOT (…)`).
fn coil_assignment(coil: Node, by_id: &HashMap<String, Node>) -> Option<String> {
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<String, Node>, depth: u8) -> Option<String> {
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<String, Node>, 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<String> {
node.descendants() 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 `<expression>` child (variable name or literal).
fn expression_text(node: Node) -> Option<String> {
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. `<variable>` of a contact/coil).
fn child_text(node: Node, name: &str) -> Option<String> {
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 `<xhtml>` 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()) .filter_map(|n| n.text())
.collect::<String>() .collect::<String>()
} }
/// Build an ST `VAR … END_VAR` block from a POU's `<interface>` variable /// Build an ST `VAR … END_VAR` block from a POU's `<interface>` variable
/// sections, so declarations (types, initial values) reach the rules. /// 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 { let Some(interface) = pou.children().find(|n| n.has_tag_name("interface")) else {
return String::new(); return String::new();
}; };
@@ -96,7 +282,7 @@ fn build_var_block(pou: roxmltree::Node) -> String {
} }
/// Render a PLCopen `<type>` element as an ST type string. /// Render a PLCopen `<type>` 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 { let Some(inner) = type_node.children().find(|n| n.is_element()) else {
return "BOOL".to_string(); 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). /// Extract an initial value as an ST literal (quoting strings).
fn initial_value(iv: roxmltree::Node) -> Option<String> { fn initial_value(iv: Node) -> Option<String> {
let simple = iv.descendants().find(|n| n.has_tag_name("simpleValue"))?; let simple = iv.descendants().find(|n| n.has_tag_name("simpleValue"))?;
let raw = simple.attribute("value")?.trim().to_string(); let raw = simple.attribute("value")?.trim().to_string();
if raw.is_empty() { if raw.is_empty() {
@@ -147,3 +333,86 @@ fn initial_value(iv: roxmltree::Node) -> Option<String> {
Some(format!("'{}'", raw.replace('\'', "''"))) 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#"<?xml version="1.0"?>
<project xmlns="http://www.plcopen.org/xml/tc6_0201">
<types><pous>
<pou name="Rung" pouType="program">
<interface><localVars>
<variable name="Motor"><type><BOOL/></type></variable>
</localVars></interface>
<body><LD>
<leftPowerRail localId="0"/>
<contact localId="1"><variable>Start</variable>
<connectionPointIn><connection refLocalId="0"/></connectionPointIn></contact>
<coil localId="2"><variable>Motor</variable>
<connectionPointIn><connection refLocalId="1"/></connectionPointIn></coil>
<inVariable localId="3"><expression>21</expression></inVariable>
<inVariable localId="4"><expression>FALSE</expression></inVariable>
<block localId="10" typeName="Ftp_Send">
<inputVariables>
<variable formalParameter="PORT">
<connectionPointIn><connection refLocalId="3"/></connectionPointIn></variable>
<variable formalParameter="ENCRYPT">
<connectionPointIn><connection refLocalId="4"/></connectionPointIn></variable>
</inputVariables>
</block>
</LD></body>
</pou>
</pous></types>
</project>"#;
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#"<?xml version="1.0"?>
<project xmlns="http://www.plcopen.org/xml/tc6_0201">
<types><pous>
<pou name="Comm" pouType="program">
<body><FBD>
<inVariable localId="1"><expression>502</expression></inVariable>
<inVariable localId="2"><expression>FALSE</expression></inVariable>
<block localId="10" typeName="Modbus_TCP_Master">
<inputVariables>
<variable formalParameter="PORT">
<connectionPointIn><connection refLocalId="1"/></connectionPointIn></variable>
<variable formalParameter="AUTH">
<connectionPointIn><connection refLocalId="2"/></connectionPointIn></variable>
</inputVariables>
</block>
</FBD></body>
</pou>
</pous></types>
</project>"#;
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)
}
}
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Function Block Diagram (FBD) POU in PLCopen TC6 XML form. Demonstrates that
the scanner analyses graphical logic, not just Structured Text: the same
defects (cleartext Modbus master on 502, a hardcoded HMI password, a safety
enable driven FALSE) are here wired as blocks and in/out variables. -->
<project xmlns="http://www.plcopen.org/xml/tc6_0201">
<types>
<pous>
<pou name="PumpFbdCtrl" pouType="functionBlock">
<interface>
<inputVars>
<variable name="HmiPassword"><type><string/></type></variable>
<variable name="Safety_Enable"><type><BOOL/></type></variable>
<variable name="ServerIp"><type><string/></type></variable>
</inputVars>
</interface>
<body>
<FBD>
<!-- Modbus/TCP master: cleartext (AUTH := FALSE) on port 502 -->
<inVariable localId="1"><expression>'10.20.0.5'</expression><connectionPointOut/></inVariable>
<inVariable localId="2"><expression>502</expression><connectionPointOut/></inVariable>
<inVariable localId="3"><expression>FALSE</expression><connectionPointOut/></inVariable>
<block localId="10" typeName="Modbus_TCP_Master">
<inputVariables>
<variable formalParameter="IP">
<connectionPointIn><connection refLocalId="1"/></connectionPointIn>
</variable>
<variable formalParameter="PORT">
<connectionPointIn><connection refLocalId="2"/></connectionPointIn>
</variable>
<variable formalParameter="AUTH">
<connectionPointIn><connection refLocalId="3"/></connectionPointIn>
</variable>
</inputVariables>
<outputVariables/>
</block>
<!-- Hardcoded HMI password wired into an output -->
<inVariable localId="20"><expression>'admin123'</expression><connectionPointOut/></inVariable>
<outVariable localId="21">
<expression>HmiPassword</expression>
<connectionPointIn><connection refLocalId="20"/></connectionPointIn>
</outVariable>
<!-- Safety enable driven FALSE in logic -->
<inVariable localId="30"><expression>FALSE</expression><connectionPointOut/></inVariable>
<outVariable localId="31">
<expression>Safety_Enable</expression>
<connectionPointIn><connection refLocalId="30"/></connectionPointIn>
</outVariable>
</FBD>
</body>
</pou>
</pous>
</types>
</project>