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 + + + + + + + +