From 6d02b138c614afedff68675f214a226081fdd73d Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Thu, 16 Jul 2026 08:31:43 +0000 Subject: [PATCH] feat(pipeline): PLC/SPS control-logic security scanner (IEC 61131-3) (#162) --- Cargo.lock | 23 +- compliance-agent/Cargo.toml | 2 + compliance-agent/src/pipeline/mod.rs | 1 + compliance-agent/src/pipeline/orchestrator.rs | 51 ++ compliance-agent/src/pipeline/plc/ast.rs | 226 ++++++ compliance-agent/src/pipeline/plc/lexer.rs | 372 +++++++++ compliance-agent/src/pipeline/plc/mod.rs | 200 +++++ compliance-agent/src/pipeline/plc/parser.rs | 766 ++++++++++++++++++ compliance-agent/src/pipeline/plc/plcopen.rs | 149 ++++ compliance-agent/src/pipeline/plc/rules.rs | 632 +++++++++++++++ examples/plc-demo/conveyor.xml | 36 + examples/plc-demo/pump_station.st | 57 ++ examples/plc-demo/traffic_light.st | 92 +++ 13 files changed, 2599 insertions(+), 8 deletions(-) create mode 100644 compliance-agent/src/pipeline/plc/ast.rs create mode 100644 compliance-agent/src/pipeline/plc/lexer.rs create mode 100644 compliance-agent/src/pipeline/plc/mod.rs create mode 100644 compliance-agent/src/pipeline/plc/parser.rs create mode 100644 compliance-agent/src/pipeline/plc/plcopen.rs create mode 100644 compliance-agent/src/pipeline/plc/rules.rs create mode 100644 examples/plc-demo/conveyor.xml create mode 100644 examples/plc-demo/pump_station.st create mode 100644 examples/plc-demo/traffic_light.st diff --git a/Cargo.lock b/Cargo.lock index 80aca18..d33fbb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -679,6 +679,7 @@ dependencies = [ "rand 0.9.2", "regex", "reqwest", + "roxmltree", "secrecy", "serde", "serde_json", @@ -2103,7 +2104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3698,7 +3699,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4628,6 +4629,12 @@ dependencies = [ "syn", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rust-stemmers" version = "1.2.0" @@ -4679,7 +4686,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4692,7 +4699,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5215,7 +5222,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "proc-macro2", "quote", "syn", @@ -5570,10 +5577,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6746,7 +6753,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/compliance-agent/Cargo.toml b/compliance-agent/Cargo.toml index 5cdb70b..edd972e 100644 --- a/compliance-agent/Cargo.toml +++ b/compliance-agent/Cargo.toml @@ -42,6 +42,8 @@ tokio-cron-scheduler = "0.13" dotenvy = "0.15" hmac = "0.12" walkdir = "2" +# Read-only XML tree parsing for PLCopen project files (POU extraction). +roxmltree = "0.20" base64 = "0.22" urlencoding = "2" futures-util = "0.3" diff --git a/compliance-agent/src/pipeline/mod.rs b/compliance-agent/src/pipeline/mod.rs index 2a0f2be..a53f0f9 100644 --- a/compliance-agent/src/pipeline/mod.rs +++ b/compliance-agent/src/pipeline/mod.rs @@ -10,6 +10,7 @@ pub mod lint; pub mod orchestrator; pub mod patterns; pub mod plan; +pub mod plc; mod pr_review; pub mod repo_view; pub mod sbom; diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index d5b8ad4..f4419d5 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -449,6 +449,11 @@ impl PipelineOrchestrator { // wizard-created targets, not just migrated ones. self.ensure_dast_target(target, &plan).await; + // PLC control-logic analysis for PLC/SPS targets (a PlcProject artifact). + if plan.has(ScanType::PlcControlLogic) { + return self.run_plc_scan(target, &target_id, scan_run_id).await; + } + match target.code_artifact() { Some(code) if code.kind == ArtifactKind::GitRepo => { let repo = RepoView::from_target(target, code); @@ -478,6 +483,52 @@ impl PipelineOrchestrator { } } + /// Analyze a PLC/SPS project (Structured Text / PLCopen XML) for + /// control-logic security issues and persist the new findings. + async fn run_plc_scan( + &self, + target: &OnboardedTarget, + target_id: &str, + scan_run_id: &str, + ) -> Result { + tracing::info!(target_id, "[{target_id}] PLC control-logic analysis"); + self.update_phase(scan_run_id, "plc_analysis").await; + + let ctx = crate::ingest::IngestContext::from_config(&self.config, target_id); + let ingest_set = crate::ingest::ingest_all(target, &ctx)?; + let path = target + .first_of(ArtifactKind::PlcProject) + .and_then(|a| ingest_set.get(&a.id)) + .and_then(|ia| ia.working_path.clone()); + let Some(path) = path else { + tracing::warn!(target_id, "PLC scan: no ingested PLC project path"); + return Ok(0); + }; + + let findings = crate::pipeline::plc::analyze_tree(&path, target_id); + tracing::info!( + target_id, + found = findings.len(), + "PLC control-logic analysis complete" + ); + + let mut new_count = 0u32; + for mut finding in findings { + finding.scan_run_id = Some(scan_run_id.to_string()); + if self + .db + .findings() + .find_one(doc! { "fingerprint": &finding.fingerprint }) + .await? + .is_none() + { + self.db.findings().insert_one(&finding).await?; + new_count += 1; + } + } + Ok(new_count) + } + /// Ingest the target's artifacts, classify (tramiton for firmware/RTOS/Yocto, /// heuristics otherwise), and store the detected classification on the target. /// Best-effort — never fails the scan. diff --git a/compliance-agent/src/pipeline/plc/ast.rs b/compliance-agent/src/pipeline/plc/ast.rs new file mode 100644 index 0000000..fd4ae3c --- /dev/null +++ b/compliance-agent/src/pipeline/plc/ast.rs @@ -0,0 +1,226 @@ +//! Abstract syntax tree for IEC 61131-3 Structured Text (ST). +//! +//! This is the security-relevant subset: POUs with their variable declarations +//! and statement bodies, enough to run semantic control-logic rules over. It is +//! deliberately not a full language model — declarations we don't reason about +//! (e.g. exotic type definitions) are parsed loosely and kept as raw text. + +/// A Program Organization Unit: a PROGRAM, FUNCTION, or FUNCTION_BLOCK. +#[derive(Debug, Clone)] +pub struct Pou { + pub name: String, + pub kind: PouKind, + /// The declared variables, across all VAR_* sections. + pub vars: Vec, + /// The statement body. + pub body: Vec, + /// 1-based line where the POU header appears (in the source that was parsed). + pub line: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PouKind { + Program, + Function, + FunctionBlock, +} + +impl PouKind { + pub fn label(self) -> &'static str { + match self { + PouKind::Program => "PROGRAM", + PouKind::Function => "FUNCTION", + PouKind::FunctionBlock => "FUNCTION_BLOCK", + } + } +} + +/// A single declared variable. +#[derive(Debug, Clone)] +pub struct VarDecl { + pub name: String, + pub section: VarSection, + /// The declared type as written (e.g. `BOOL`, `INT`, `ARRAY[0..9] OF INT`). + pub type_name: String, + /// Whether the type is an ARRAY, and its declared bounds `(lo, hi)` when + /// they are literal integers — used by the array-bounds rule. + pub array_bounds: Option<(i64, i64)>, + /// The initializer expression, if any (`:= `). + pub init: Option, + pub line: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VarSection { + Var, + Input, + Output, + InOut, + Global, + Temp, + External, +} + +/// A statement. +#[derive(Debug, Clone)] +pub enum Stmt { + Assign { + target: Expr, + value: Expr, + line: u32, + }, + If { + /// (condition, body) for IF and each ELSIF, in order. + branches: Vec<(Expr, Vec)>, + else_body: Option>, + line: u32, + }, + Case { + selector: Expr, + /// (label expressions, body) per CASE arm. + arms: Vec<(Vec, Vec)>, + else_body: Option>, + line: u32, + }, + For { + var: String, + from: Expr, + to: Expr, + by: Option, + body: Vec, + line: u32, + }, + While { + cond: Expr, + body: Vec, + line: u32, + }, + Repeat { + body: Vec, + until: Expr, + line: u32, + }, + /// A bare call statement, e.g. `TON1(IN := x, PT := T#5s);`. + Call { + callee: String, + args: Vec, + line: u32, + }, + Return { + line: u32, + }, + Exit { + line: u32, + }, + /// `JMP label;` — an unstructured jump. + Jump { + label: String, + line: u32, + }, + /// `label:` — a jump target. + Label { + name: String, + line: u32, + }, +} + +/// One argument in a call: positional (`name: None`) or named (`X := expr`). +#[derive(Debug, Clone)] +pub struct CallArg { + pub name: Option, + pub value: Expr, +} + +/// An expression. +#[derive(Debug, Clone)] +pub enum Expr { + Int(i64, u32), + Real(f64, u32), + Bool(bool, u32), + /// A string literal, with the unquoted contents. + Str(String, u32), + /// A duration / date / time literal, kept as raw text (`T#5s`, `DT#...`). + Time(String, u32), + Ident(String, u32), + /// `base[index]`. + Index { + base: Box, + index: Box, + line: u32, + }, + /// `base.field`. + Member { + base: Box, + field: String, + line: u32, + }, + Unary { + op: UnOp, + expr: Box, + line: u32, + }, + Binary { + op: BinOp, + lhs: Box, + rhs: Box, + line: u32, + }, + /// A function call used as an expression, e.g. `LIMIT(a, b, c)`. + Call { + callee: String, + args: Vec, + line: u32, + }, +} + +impl Expr { + /// The 1-based source line this expression starts on. + pub fn line(&self) -> u32 { + match self { + Expr::Int(_, l) + | Expr::Real(_, l) + | Expr::Bool(_, l) + | Expr::Str(_, l) + | Expr::Time(_, l) + | Expr::Ident(_, l) + | Expr::Index { line: l, .. } + | Expr::Member { line: l, .. } + | Expr::Unary { line: l, .. } + | Expr::Binary { line: l, .. } + | Expr::Call { line: l, .. } => *l, + } + } + + /// If this expression is a plain identifier, its name. + pub fn as_ident(&self) -> Option<&str> { + match self { + Expr::Ident(name, _) => Some(name.as_str()), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnOp { + Not, + Neg, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BinOp { + Add, + Sub, + Mul, + Div, + Mod, + Pow, + Eq, + Ne, + Lt, + Le, + Gt, + Ge, + And, + Or, + Xor, +} diff --git a/compliance-agent/src/pipeline/plc/lexer.rs b/compliance-agent/src/pipeline/plc/lexer.rs new file mode 100644 index 0000000..4c32426 --- /dev/null +++ b/compliance-agent/src/pipeline/plc/lexer.rs @@ -0,0 +1,372 @@ +//! Lexer for IEC 61131-3 Structured Text. +//! +//! Tokenizes ST source into a flat token stream with 1-based line numbers. +//! Keywords are case-insensitive. Handles `(* *)` and `//` comments, `'..'` and +//! `".."` strings (with `''`/`""` escapes), based integers (`16#FF`, `2#1010`), +//! and duration/date literals (`T#5s`, `DT#...`) kept as raw text. + +/// A lexed token with its source line. +#[derive(Debug, Clone)] +pub struct Token { + pub kind: Tok, + pub line: u32, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Tok { + Int(i64), + Real(f64), + Str(String), + Time(String), + Bool(bool), + Ident(String), + Kw(Keyword), + Assign, // := + Plus, // + + Minus, // - + Star, // * + Slash, // / + Power, // ** + LParen, // ( + RParen, // ) + LBrack, // [ + RBrack, // ] + Dot, // . + DotDot, // .. + Comma, // , + Semi, // ; + Colon, // : + Lt, // < + Le, // <= + Gt, // > + Ge, // >= + Eq, // = + Ne, // <> + Amp, // & + Eof, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Keyword { + Program, + EndProgram, + Function, + EndFunction, + FunctionBlock, + EndFunctionBlock, + Var, + VarInput, + VarOutput, + VarInOut, + VarGlobal, + VarTemp, + VarExternal, + Constant, + EndVar, + Array, + Of, + If, + Then, + Elsif, + Else, + EndIf, + Case, + EndCase, + For, + To, + By, + Do, + EndFor, + While, + EndWhile, + Repeat, + Until, + EndRepeat, + Return, + Exit, + Jmp, + Not, + And, + Or, + Xor, + Mod, + Type, + EndType, + Struct, + EndStruct, +} + +fn keyword_from(word: &str) -> Option { + use Keyword::*; + Some(match word.to_ascii_uppercase().as_str() { + "PROGRAM" => Program, + "END_PROGRAM" => EndProgram, + "FUNCTION" => Function, + "END_FUNCTION" => EndFunction, + "FUNCTION_BLOCK" => FunctionBlock, + "END_FUNCTION_BLOCK" => EndFunctionBlock, + "VAR" => Var, + "VAR_INPUT" => VarInput, + "VAR_OUTPUT" => VarOutput, + "VAR_IN_OUT" => VarInOut, + "VAR_GLOBAL" => VarGlobal, + "VAR_TEMP" => VarTemp, + "VAR_EXTERNAL" => VarExternal, + "CONSTANT" => Constant, + "END_VAR" => EndVar, + "ARRAY" => Array, + "OF" => Of, + "IF" => If, + "THEN" => Then, + "ELSIF" => Elsif, + "ELSE" => Else, + "END_IF" => EndIf, + "CASE" => Case, + "END_CASE" => EndCase, + "FOR" => For, + "TO" => To, + "BY" => By, + "DO" => Do, + "END_FOR" => EndFor, + "WHILE" => While, + "END_WHILE" => EndWhile, + "REPEAT" => Repeat, + "UNTIL" => Until, + "END_REPEAT" => EndRepeat, + "RETURN" => Return, + "EXIT" => Exit, + "JMP" => Jmp, + "NOT" => Not, + "AND" => And, + "OR" => Or, + "XOR" => Xor, + "MOD" => Mod, + "TYPE" => Type, + "END_TYPE" => EndType, + "STRUCT" => Struct, + "END_STRUCT" => EndStruct, + _ => return None, + }) +} + +/// Tokenize `src`. Unknown characters are skipped (best-effort — a scanner must +/// not die on odd input). +pub fn lex(src: &str) -> Vec { + let chars: Vec = src.chars().collect(); + let mut i = 0usize; + let mut line = 1u32; + let mut out = Vec::new(); + + let bump_line = |c: char, line: &mut u32| { + if c == '\n' { + *line += 1; + } + }; + + while i < chars.len() { + let c = chars[i]; + + // Whitespace. + if c.is_whitespace() { + bump_line(c, &mut line); + i += 1; + continue; + } + + // Line comment: // + if c == '/' && i + 1 < chars.len() && chars[i + 1] == '/' { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + continue; + } + + // Block comment: (* ... *) + if c == '(' && i + 1 < chars.len() && chars[i + 1] == '*' { + i += 2; + while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == ')') { + bump_line(chars[i], &mut line); + i += 1; + } + i = (i + 2).min(chars.len()); + continue; + } + + let tok_line = line; + + // String literal: '...' or "..." + if c == '\'' || c == '"' { + let quote = c; + i += 1; + let mut s = String::new(); + while i < chars.len() { + let ch = chars[i]; + if ch == quote { + // Doubled quote is an escaped quote. + if i + 1 < chars.len() && chars[i + 1] == quote { + s.push(quote); + i += 2; + continue; + } + i += 1; + break; + } + bump_line(ch, &mut line); + s.push(ch); + i += 1; + } + out.push(Token { + kind: Tok::Str(s), + line: tok_line, + }); + continue; + } + + // Identifier / keyword / time literal / boolean. + if c.is_ascii_alphabetic() || c == '_' { + let start = i; + while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') { + i += 1; + } + let word: String = chars[start..i].iter().collect(); + + // Duration/date/time literal prefix: T#, TIME#, DT#, D#, TOD#, LT# ... + if i < chars.len() && chars[i] == '#' { + let up = word.to_ascii_uppercase(); + if matches!( + up.as_str(), + "T" | "TIME" | "DT" | "D" | "TOD" | "LT" | "DATE" + ) { + let lit_start = start; + i += 1; // consume '#' + while i < chars.len() + && (chars[i].is_ascii_alphanumeric() + || chars[i] == '.' + || chars[i] == '_' + || chars[i] == ':') + { + i += 1; + } + let lit: String = chars[lit_start..i].iter().collect(); + out.push(Token { + kind: Tok::Time(lit), + line: tok_line, + }); + continue; + } + } + + let kind = match word.to_ascii_uppercase().as_str() { + "TRUE" => Tok::Bool(true), + "FALSE" => Tok::Bool(false), + _ => match keyword_from(&word) { + Some(kw) => Tok::Kw(kw), + None => Tok::Ident(word), + }, + }; + out.push(Token { + kind, + line: tok_line, + }); + continue; + } + + // Number: decimal, real, or based (16#..., 2#...). + if c.is_ascii_digit() { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '_') { + i += 1; + } + // Based literal: # + if i < chars.len() && chars[i] == '#' { + let base_str: String = chars[start..i].iter().filter(|c| **c != '_').collect(); + i += 1; + let dstart = i; + while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') { + i += 1; + } + let digits: String = chars[dstart..i].iter().filter(|c| **c != '_').collect(); + let radix = base_str.parse::().unwrap_or(10); + let val = i64::from_str_radix(&digits, radix.clamp(2, 36)).unwrap_or(0); + out.push(Token { + kind: Tok::Int(val), + line: tok_line, + }); + continue; + } + // Real: has a '.' (not '..') or exponent. + let is_real = + i < chars.len() && chars[i] == '.' && !(i + 1 < chars.len() && chars[i + 1] == '.'); + if is_real { + i += 1; + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '_') { + i += 1; + } + let raw: String = chars[start..i].iter().filter(|c| **c != '_').collect(); + out.push(Token { + kind: Tok::Real(raw.parse().unwrap_or(0.0)), + line: tok_line, + }); + continue; + } + let raw: String = chars[start..i].iter().filter(|c| **c != '_').collect(); + out.push(Token { + kind: Tok::Int(raw.parse().unwrap_or(0)), + line: tok_line, + }); + continue; + } + + // Operators / punctuation (longest match first). + let two: String = chars[i..(i + 2).min(chars.len())].iter().collect(); + let kind = match two.as_str() { + ":=" => Some(Tok::Assign), + "<=" => Some(Tok::Le), + ">=" => Some(Tok::Ge), + "<>" => Some(Tok::Ne), + ".." => Some(Tok::DotDot), + "**" => Some(Tok::Power), + _ => None, + }; + if let Some(k) = kind { + out.push(Token { + kind: k, + line: tok_line, + }); + i += 2; + continue; + } + let one = match c { + '+' => Some(Tok::Plus), + '-' => Some(Tok::Minus), + '*' => Some(Tok::Star), + '/' => Some(Tok::Slash), + '(' => Some(Tok::LParen), + ')' => Some(Tok::RParen), + '[' => Some(Tok::LBrack), + ']' => Some(Tok::RBrack), + '.' => Some(Tok::Dot), + ',' => Some(Tok::Comma), + ';' => Some(Tok::Semi), + ':' => Some(Tok::Colon), + '<' => Some(Tok::Lt), + '>' => Some(Tok::Gt), + '=' => Some(Tok::Eq), + '&' => Some(Tok::Amp), + _ => None, + }; + if let Some(k) = one { + out.push(Token { + kind: k, + line: tok_line, + }); + } + i += 1; + } + + out.push(Token { + kind: Tok::Eof, + line, + }); + out +} diff --git a/compliance-agent/src/pipeline/plc/mod.rs b/compliance-agent/src/pipeline/plc/mod.rs new file mode 100644 index 0000000..f64b676 --- /dev/null +++ b/compliance-agent/src/pipeline/plc/mod.rs @@ -0,0 +1,200 @@ +//! PLC control-logic security scanner for IEC 61131-3 targets. +//! +//! Parses Structured Text (raw `.st`/`.scl`/`.exp` files and PLCopen-XML +//! projects) into an AST and runs semantic control-logic security rules over it. +//! Implements [`ScanType::PlcControlLogic`]. + +pub mod ast; +pub mod lexer; +pub mod parser; +pub mod plcopen; +pub mod rules; + +use std::path::Path; + +use compliance_core::error::CoreError; +use compliance_core::models::{Finding, ScanType}; +use compliance_core::traits::{ScanOutput, Scanner}; + +use crate::pipeline::dedup; + +/// Scanner for `ScanType::PlcControlLogic`. +pub struct PlcControlLogicScanner; + +impl Scanner for PlcControlLogicScanner { + fn name(&self) -> &str { + "plc-control-logic" + } + + fn scan_type(&self) -> ScanType { + ScanType::PlcControlLogic + } + + #[tracing::instrument(skip_all)] + async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result { + let findings = analyze_tree(repo_path, repo_id); + Ok(ScanOutput { + findings, + sbom_entries: Vec::new(), + }) + } +} + +/// Walk a PLC project tree and produce findings. +pub(crate) fn analyze_tree(root: &Path, repo_id: &str) -> Vec { + let mut findings = Vec::new(); + for entry in walkdir::WalkDir::new(root) + .into_iter() + .filter_map(|e| e.ok()) + { + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + let is_st = matches!(ext.as_str(), "st" | "iecst" | "scl" | "exp" | "il"); + let is_xml = matches!(ext.as_str(), "xml" | "plcopen" | "project"); + if !is_st && !is_xml { + continue; + } + let Ok(content) = std::fs::read_to_string(path) else { + continue; + }; + let pous = if is_xml { + plcopen::parse_plcopen(&content) + } else { + parser::parse(&content) + }; + if pous.is_empty() { + continue; + } + let rel = path + .strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + for pou in &pous { + for hit in rules::analyze(pou) { + let line_s = hit.line.to_string(); + let fingerprint = + dedup::compute_fingerprint(&[repo_id, &rel, hit.rule_id, &pou.name, &line_s]); + let mut f = Finding::new( + repo_id.to_string(), + fingerprint, + "plc-control-logic".to_string(), + ScanType::PlcControlLogic, + hit.title, + hit.description, + hit.severity, + ); + f.file_path = Some(rel.clone()); + f.line_number = Some(hit.line); + f.rule_id = Some(hit.rule_id.to_string()); + f.cwe = hit.cwe.map(String::from); + f.remediation = Some(hit.remediation.to_string()); + findings.push(f); + } + } + } + findings +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use std::path::PathBuf; + + fn demo_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .join("examples/plc-demo") + } + + #[test] + fn scans_demo_project_end_to_end() { + let findings = analyze_tree(&demo_dir(), "demo-target"); + assert!(!findings.is_empty(), "demo project should produce findings"); + + let rules: HashSet<&str> = findings + .iter() + .filter_map(|f| f.rule_id.as_deref()) + .collect(); + for r in [ + "plc-hardcoded-credential", + "plc-default-password", + "plc-safety-bypass", + "plc-array-unchecked-index", + "plc-insecure-comm", + "plc-insecure-protocol-port", + "plc-unstructured-jump", + "plc-division-by-zero", + ] { + assert!(rules.contains(r), "expected rule {r}; got {rules:?}"); + } + + // Every finding is well-formed for storage. + for f in &findings { + assert_eq!(f.repo_id, "demo-target"); + assert!(f.file_path.is_some(), "finding needs a file"); + assert!(f.line_number.is_some(), "finding needs a line"); + } + + // The guarded division (IF ScaleFactor <> 0.0) must not be double-counted: + // exactly one division-by-zero (the unguarded MeasuredFlow divide). + let div0 = findings + .iter() + .filter(|f| f.rule_id.as_deref() == Some("plc-division-by-zero")) + .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/compliance-agent/src/pipeline/plc/parser.rs b/compliance-agent/src/pipeline/plc/parser.rs new file mode 100644 index 0000000..c90b3d8 --- /dev/null +++ b/compliance-agent/src/pipeline/plc/parser.rs @@ -0,0 +1,766 @@ +//! Recursive-descent parser for the security-relevant subset of Structured Text. +//! +//! Tolerant by design: it parses the POUs, variable sections, and statement +//! bodies it understands, and skips (with statement/POU-level recovery) anything +//! it does not, so a single odd construct never sinks the whole file. + +use super::ast::*; +use super::lexer::{Keyword as K, Tok, Token}; + +pub struct Parser { + toks: Vec, + pos: usize, +} + +impl Parser { + pub fn new(toks: Vec) -> Self { + Self { toks, pos: 0 } + } + + // ── token helpers ────────────────────────────────────────────── + fn peek(&self) -> &Tok { + &self.toks[self.pos.min(self.toks.len() - 1)].kind + } + fn line(&self) -> u32 { + self.toks[self.pos.min(self.toks.len() - 1)].line + } + fn at_end(&self) -> bool { + matches!(self.peek(), Tok::Eof) + } + fn advance(&mut self) -> Tok { + let t = self.toks[self.pos.min(self.toks.len() - 1)].kind.clone(); + if self.pos < self.toks.len() - 1 { + self.pos += 1; + } + t + } + fn eat(&mut self, t: &Tok) -> bool { + if self.peek() == t { + self.advance(); + true + } else { + false + } + } + fn eat_kw(&mut self, k: K) -> bool { + if matches!(self.peek(), Tok::Kw(x) if *x == k) { + self.advance(); + true + } else { + false + } + } + fn at_kw(&self, k: K) -> bool { + matches!(self.peek(), Tok::Kw(x) if *x == k) + } + fn ident(&mut self) -> Option { + if let Tok::Ident(s) = self.peek() { + let s = s.clone(); + self.advance(); + Some(s) + } else { + None + } + } + + // ── top level ────────────────────────────────────────────────── + /// Parse every POU in the token stream. + pub fn parse_units(&mut self) -> Vec { + let mut pous = Vec::new(); + while !self.at_end() { + match self.peek() { + Tok::Kw(K::Program) => { + self.advance(); + if let Some(p) = self.parse_pou(PouKind::Program, K::EndProgram) { + pous.push(p); + } + } + Tok::Kw(K::Function) => { + self.advance(); + if let Some(p) = self.parse_pou(PouKind::Function, K::EndFunction) { + pous.push(p); + } + } + Tok::Kw(K::FunctionBlock) => { + self.advance(); + if let Some(p) = self.parse_pou(PouKind::FunctionBlock, K::EndFunctionBlock) { + pous.push(p); + } + } + // Skip TYPE...END_TYPE and anything else at top level. + _ => { + self.advance(); + } + } + } + pous + } + + fn parse_pou(&mut self, kind: PouKind, end: K) -> Option { + let line = self.line(); + let name = self.ident().unwrap_or_else(|| "".to_string()); + // Optional `: return_type` for functions. + if self.eat(&Tok::Colon) { + let _ = self.advance(); // return type token + } + + let mut vars = Vec::new(); + // Variable sections precede the body. + while let Some(section) = self.var_section_kw() { + self.advance(); + let _ = self.eat_kw(K::Constant); // CONSTANT is informational for our rules + self.parse_var_decls(section, &mut vars); + } + + // Body statements until END_. + let mut body = Vec::new(); + while !self.at_end() && !self.at_kw(end) { + if let Some(s) = self.parse_stmt() { + body.push(s); + } + } + self.eat_kw(end); + + Some(Pou { + name, + kind, + vars, + body, + line, + }) + } + + fn var_section_kw(&self) -> Option { + match self.peek() { + Tok::Kw(K::Var) => Some(VarSection::Var), + Tok::Kw(K::VarInput) => Some(VarSection::Input), + Tok::Kw(K::VarOutput) => Some(VarSection::Output), + Tok::Kw(K::VarInOut) => Some(VarSection::InOut), + Tok::Kw(K::VarGlobal) => Some(VarSection::Global), + Tok::Kw(K::VarTemp) => Some(VarSection::Temp), + Tok::Kw(K::VarExternal) => Some(VarSection::External), + _ => None, + } + } + + fn parse_var_decls(&mut self, section: VarSection, out: &mut Vec) { + while !self.at_end() && !self.at_kw(K::EndVar) { + let line = self.line(); + // names: a, b, c + let mut names = Vec::new(); + match self.ident() { + Some(n) => names.push(n), + None => { + // Not a declaration we understand — skip to next ; or END_VAR. + self.sync_decl(); + continue; + } + } + while self.eat(&Tok::Comma) { + if let Some(n) = self.ident() { + names.push(n); + } + } + if !self.eat(&Tok::Colon) { + self.sync_decl(); + continue; + } + let (type_name, array_bounds) = self.parse_type(); + let init = if self.eat(&Tok::Assign) { + Some(self.parse_expr()) + } else { + None + }; + self.eat(&Tok::Semi); + for n in names { + out.push(VarDecl { + name: n, + section, + type_name: type_name.clone(), + array_bounds, + init: init.clone(), + line, + }); + } + } + self.eat_kw(K::EndVar); + } + + /// Parse a (possibly ARRAY) type, returning its rendered name and literal + /// bounds when present. + fn parse_type(&mut self) -> (String, Option<(i64, i64)>) { + if self.eat_kw(K::Array) { + let mut bounds = None; + if self.eat(&Tok::LBrack) { + let lo = self.int_lit(); + self.eat(&Tok::DotDot); + let hi = self.int_lit(); + if let (Some(lo), Some(hi)) = (lo, hi) { + bounds = Some((lo, hi)); + } + // Skip any further dimensions / tokens to the closing bracket. + while !self.at_end() && !self.eat(&Tok::RBrack) { + self.advance(); + } + } + self.eat_kw(K::Of); + let elem = self.type_ident(); + (format!("ARRAY OF {elem}"), bounds) + } else { + (self.type_ident(), None) + } + } + + fn type_ident(&mut self) -> String { + // Types can be qualified idents; keep it simple: one token, plus any + // string-length suffix like STRING[80]. + let base = match self.advance() { + Tok::Ident(s) => s, + Tok::Kw(_) => "TYPE".to_string(), + other => format!("{other:?}"), + }; + if self.eat(&Tok::LBrack) { + while !self.at_end() && !self.eat(&Tok::RBrack) { + self.advance(); + } + } + base + } + + fn int_lit(&mut self) -> Option { + match self.peek() { + Tok::Int(n) => { + let n = *n; + self.advance(); + Some(n) + } + Tok::Minus => { + self.advance(); + if let Tok::Int(n) = self.peek() { + let n = -*n; + self.advance(); + Some(n) + } else { + None + } + } + _ => None, + } + } + + fn sync_decl(&mut self) { + while !self.at_end() && !self.eat(&Tok::Semi) && !self.at_kw(K::EndVar) { + self.advance(); + } + } + fn sync_stmt(&mut self) { + while !self.at_end() && !self.eat(&Tok::Semi) { + // Stop at block terminators so recovery doesn't swallow structure. + if matches!( + self.peek(), + Tok::Kw( + K::EndIf + | K::EndFor + | K::EndWhile + | K::EndCase + | K::EndRepeat + | K::EndProgram + | K::EndFunction + | K::EndFunctionBlock + | K::Else + | K::Elsif + ) + ) { + return; + } + self.advance(); + } + } + + // ── statements ───────────────────────────────────────────────── + fn parse_stmt(&mut self) -> Option { + let line = self.line(); + match self.peek().clone() { + Tok::Semi => { + self.advance(); + None + } + Tok::Kw(K::If) => self.parse_if(), + Tok::Kw(K::Case) => self.parse_case(), + Tok::Kw(K::For) => self.parse_for(), + Tok::Kw(K::While) => self.parse_while(), + Tok::Kw(K::Repeat) => self.parse_repeat(), + Tok::Kw(K::Return) => { + self.advance(); + self.eat(&Tok::Semi); + Some(Stmt::Return { line }) + } + Tok::Kw(K::Exit) => { + self.advance(); + self.eat(&Tok::Semi); + Some(Stmt::Exit { line }) + } + Tok::Kw(K::Jmp) => { + self.advance(); + let label = self.ident().unwrap_or_default(); + self.eat(&Tok::Semi); + Some(Stmt::Jump { label, line }) + } + Tok::Ident(name) => { + // Could be `label:`, `call(...)`, or an assignment. + // Lookahead: ident ':' (not ':=') → label. + if matches!( + self.toks.get(self.pos + 1).map(|t| &t.kind), + Some(Tok::Colon) + ) && !matches!(self.toks.get(self.pos + 2).map(|t| &t.kind), Some(Tok::Eq)) + { + self.advance(); // ident + self.advance(); // ':' + return Some(Stmt::Label { name, line }); + } + let lhs = self.parse_expr(); + if self.eat(&Tok::Assign) { + let value = self.parse_expr(); + self.eat(&Tok::Semi); + Some(Stmt::Assign { + target: lhs, + value, + line, + }) + } else if let Expr::Call { callee, args, .. } = lhs { + self.eat(&Tok::Semi); + Some(Stmt::Call { callee, args, line }) + } else { + // Bare expression / FB invocation without args recognized — + // skip to the terminator. + self.sync_stmt(); + None + } + } + _ => { + self.sync_stmt(); + None + } + } + } + + fn parse_block_until(&mut self, terms: &[K]) -> Vec { + let mut body = Vec::new(); + while !self.at_end() && !terms.iter().any(|k| self.at_kw(*k)) { + if let Some(s) = self.parse_stmt() { + body.push(s); + } + } + body + } + + fn parse_if(&mut self) -> Option { + let line = self.line(); + self.eat_kw(K::If); + let mut branches = Vec::new(); + let cond = self.parse_expr(); + self.eat_kw(K::Then); + let body = self.parse_block_until(&[K::Elsif, K::Else, K::EndIf]); + branches.push((cond, body)); + while self.eat_kw(K::Elsif) { + let c = self.parse_expr(); + self.eat_kw(K::Then); + let b = self.parse_block_until(&[K::Elsif, K::Else, K::EndIf]); + branches.push((c, b)); + } + let else_body = if self.eat_kw(K::Else) { + Some(self.parse_block_until(&[K::EndIf])) + } else { + None + }; + self.eat_kw(K::EndIf); + self.eat(&Tok::Semi); + Some(Stmt::If { + branches, + else_body, + line, + }) + } + + fn parse_case(&mut self) -> Option { + let line = self.line(); + self.eat_kw(K::Case); + let selector = self.parse_expr(); + self.eat_kw(K::Of); + let mut arms = Vec::new(); + let mut else_body = None; + while !self.at_end() && !self.at_kw(K::EndCase) { + if self.eat_kw(K::Else) { + else_body = Some(self.parse_block_until(&[K::EndCase])); + break; + } + // labels: expr {, expr} : + let mut labels = vec![self.parse_expr()]; + while self.eat(&Tok::Comma) { + labels.push(self.parse_expr()); + } + self.eat(&Tok::Colon); + let body = self.parse_block_until(&[K::EndCase, K::Else]); + arms.push((labels, body)); + } + self.eat_kw(K::EndCase); + self.eat(&Tok::Semi); + Some(Stmt::Case { + selector, + arms, + else_body, + line, + }) + } + + fn parse_for(&mut self) -> Option { + let line = self.line(); + self.eat_kw(K::For); + let var = self.ident().unwrap_or_default(); + self.eat(&Tok::Assign); + let from = self.parse_expr(); + self.eat_kw(K::To); + let to = self.parse_expr(); + let by = if self.eat_kw(K::By) { + Some(self.parse_expr()) + } else { + None + }; + self.eat_kw(K::Do); + let body = self.parse_block_until(&[K::EndFor]); + self.eat_kw(K::EndFor); + self.eat(&Tok::Semi); + Some(Stmt::For { + var, + from, + to, + by, + body, + line, + }) + } + + fn parse_while(&mut self) -> Option { + let line = self.line(); + self.eat_kw(K::While); + let cond = self.parse_expr(); + self.eat_kw(K::Do); + let body = self.parse_block_until(&[K::EndWhile]); + self.eat_kw(K::EndWhile); + self.eat(&Tok::Semi); + Some(Stmt::While { cond, body, line }) + } + + fn parse_repeat(&mut self) -> Option { + let line = self.line(); + self.eat_kw(K::Repeat); + let body = self.parse_block_until(&[K::Until, K::EndRepeat]); + self.eat_kw(K::Until); + let until = self.parse_expr(); + self.eat_kw(K::EndRepeat); + self.eat(&Tok::Semi); + Some(Stmt::Repeat { body, until, line }) + } + + // ── expressions (precedence climbing) ────────────────────────── + pub fn parse_expr(&mut self) -> Expr { + self.parse_or() + } + + fn parse_or(&mut self) -> Expr { + let mut lhs = self.parse_and(); + loop { + let op = match self.peek() { + Tok::Kw(K::Or) => BinOp::Or, + Tok::Kw(K::Xor) => BinOp::Xor, + _ => break, + }; + let line = self.line(); + self.advance(); + let rhs = self.parse_and(); + lhs = Expr::Binary { + op, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + line, + }; + } + lhs + } + + fn parse_and(&mut self) -> Expr { + let mut lhs = self.parse_cmp(); + while matches!(self.peek(), Tok::Kw(K::And) | Tok::Amp) { + let op = BinOp::And; + let line = self.line(); + self.advance(); + let rhs = self.parse_cmp(); + lhs = Expr::Binary { + op, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + line, + }; + } + lhs + } + + fn parse_cmp(&mut self) -> Expr { + let mut lhs = self.parse_add(); + loop { + let op = match self.peek() { + Tok::Eq => BinOp::Eq, + Tok::Ne => BinOp::Ne, + Tok::Lt => BinOp::Lt, + Tok::Le => BinOp::Le, + Tok::Gt => BinOp::Gt, + Tok::Ge => BinOp::Ge, + _ => break, + }; + let line = self.line(); + self.advance(); + let rhs = self.parse_add(); + lhs = Expr::Binary { + op, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + line, + }; + } + lhs + } + + fn parse_add(&mut self) -> Expr { + let mut lhs = self.parse_mul(); + loop { + let op = match self.peek() { + Tok::Plus => BinOp::Add, + Tok::Minus => BinOp::Sub, + _ => break, + }; + let line = self.line(); + self.advance(); + let rhs = self.parse_mul(); + lhs = Expr::Binary { + op, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + line, + }; + } + lhs + } + + fn parse_mul(&mut self) -> Expr { + let mut lhs = self.parse_unary(); + loop { + let op = match self.peek() { + Tok::Star => BinOp::Mul, + Tok::Slash => BinOp::Div, + Tok::Kw(K::Mod) => BinOp::Mod, + Tok::Power => BinOp::Pow, + _ => break, + }; + let line = self.line(); + self.advance(); + let rhs = self.parse_unary(); + lhs = Expr::Binary { + op, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + line, + }; + } + lhs + } + + fn parse_unary(&mut self) -> Expr { + let line = self.line(); + match self.peek() { + Tok::Kw(K::Not) => { + self.advance(); + Expr::Unary { + op: UnOp::Not, + expr: Box::new(self.parse_unary()), + line, + } + } + Tok::Minus => { + self.advance(); + Expr::Unary { + op: UnOp::Neg, + expr: Box::new(self.parse_unary()), + line, + } + } + _ => self.parse_postfix(), + } + } + + fn parse_postfix(&mut self) -> Expr { + let mut e = self.parse_primary(); + loop { + let line = self.line(); + match self.peek() { + Tok::LBrack => { + self.advance(); + let index = self.parse_expr(); + self.eat(&Tok::RBrack); + e = Expr::Index { + base: Box::new(e), + index: Box::new(index), + line, + }; + } + Tok::Dot => { + self.advance(); + let field = self.ident().unwrap_or_default(); + e = Expr::Member { + base: Box::new(e), + field, + line, + }; + } + _ => break, + } + } + e + } + + fn parse_primary(&mut self) -> Expr { + let line = self.line(); + match self.advance() { + Tok::Int(n) => Expr::Int(n, line), + Tok::Real(r) => Expr::Real(r, line), + Tok::Bool(b) => Expr::Bool(b, line), + Tok::Str(s) => Expr::Str(s, line), + Tok::Time(t) => Expr::Time(t, line), + Tok::LParen => { + let e = self.parse_expr(); + self.eat(&Tok::RParen); + e + } + Tok::Ident(name) => { + if self.eat(&Tok::LParen) { + let args = self.parse_call_args(); + Expr::Call { + callee: name, + args, + line, + } + } else { + Expr::Ident(name, line) + } + } + // Unrecognized start of expression — yield a placeholder identifier. + _ => Expr::Ident(String::new(), line), + } + } + + fn parse_call_args(&mut self) -> Vec { + let mut args = Vec::new(); + if self.eat(&Tok::RParen) { + return args; + } + loop { + // Named arg: ident := expr (peek two tokens). + if let Tok::Ident(name) = self.peek().clone() { + if matches!( + self.toks.get(self.pos + 1).map(|t| &t.kind), + Some(Tok::Assign) + ) { + self.advance(); // ident + self.advance(); // := + let value = self.parse_expr(); + args.push(CallArg { + name: Some(name), + value, + }); + if self.eat(&Tok::Comma) { + continue; + } + break; + } + } + let value = self.parse_expr(); + args.push(CallArg { name: None, value }); + if self.eat(&Tok::Comma) { + continue; + } + break; + } + self.eat(&Tok::RParen); + args + } +} + +/// Parse ST source into its POUs. +pub fn parse(src: &str) -> Vec { + let toks = super::lexer::lex(src); + Parser::new(toks).parse_units() +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#" +PROGRAM Main +VAR + idx : INT; + pw : STRING := 'admin123'; + buf : ARRAY[0..9] OF INT; + ok : BOOL := FALSE; +END_VAR + // a comment + IF idx > 0 THEN + buf[idx] := idx * 2; + ELSE + JMP done; + END_IF; + Comm(IP := '10.0.0.1', PORT := 502); +done: + ok := TRUE; +END_PROGRAM +"#; + + #[test] + fn parses_program_vars_and_body() { + let pous = parse(SAMPLE); + assert_eq!(pous.len(), 1, "one POU"); + let p = &pous[0]; + assert_eq!(p.name, "Main"); + assert_eq!(p.kind, PouKind::Program); + // vars: idx, pw, buf, ok + assert_eq!(p.vars.len(), 4); + let pw = p.vars.iter().find(|v| v.name == "pw").expect("pw"); + assert!(matches!(&pw.init, Some(Expr::Str(s, _)) if s == "admin123")); + let buf = p.vars.iter().find(|v| v.name == "buf").expect("buf"); + assert_eq!(buf.array_bounds, Some((0, 9))); + // body has an IF, a Call, a Label, and an Assign + assert!(p.body.iter().any(|s| matches!(s, Stmt::If { .. }))); + assert!(p + .body + .iter() + .any(|s| matches!(s, Stmt::Call { callee, .. } if callee == "Comm"))); + assert!(p + .body + .iter() + .any(|s| matches!(s, Stmt::Label { name, .. } if name == "done"))); + } + + #[test] + fn jmp_inside_if_is_captured() { + let pous = parse(SAMPLE); + let p = &pous[0]; + // find the IF, check its else branch has a JMP + let has_jmp = p.body.iter().any(|s| match s { + Stmt::If { else_body, .. } => else_body + .as_ref() + .map(|b| b.iter().any(|s| matches!(s, Stmt::Jump { .. }))) + .unwrap_or(false), + _ => false, + }); + assert!(has_jmp, "JMP should be parsed inside the ELSE branch"); + } +} diff --git a/compliance-agent/src/pipeline/plc/plcopen.rs b/compliance-agent/src/pipeline/plc/plcopen.rs new file mode 100644 index 0000000..ec2228f --- /dev/null +++ b/compliance-agent/src/pipeline/plc/plcopen.rs @@ -0,0 +1,149 @@ +//! 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. +//! +//! 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. + +use super::ast::Pou; +use super::parser; + +/// Parse every Structured-Text POU out of a PLCopen XML document. +pub fn parse_plcopen(xml: &str) -> Vec { + let doc = match roxmltree::Document::parse(xml) { + Ok(d) => d, + Err(_) => return Vec::new(), + }; + let mut pous = Vec::new(); + for pou in doc.descendants().filter(|n| n.has_tag_name("pou")) { + 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 { + continue; + }; + let body = collect_text(st_node); + if body.trim().is_empty() { + continue; + } + + let var_block = build_var_block(pou); + let kw = match pou_type.to_ascii_lowercase().as_str() { + "function" => "FUNCTION", + "functionblock" | "functionblocktype" => "FUNCTION_BLOCK", + _ => "PROGRAM", + }; + let synthetic = format!("{kw} {name}\n{var_block}{body}\nEND_{kw}\n"); + pous.extend(parser::parse(&synthetic)); + } + 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 { + node.descendants() + .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 { + let Some(interface) = pou.children().find(|n| n.has_tag_name("interface")) else { + return String::new(); + }; + let mut out = String::from("VAR\n"); + let mut any = false; + for container in interface.children().filter(|n| n.is_element()) { + // localVars / inputVars / outputVars / inOutVars / tempVars / globalVars / externalVars + if !container.tag_name().name().ends_with("Vars") { + continue; + } + for var in container.children().filter(|n| n.has_tag_name("variable")) { + let Some(vname) = var.attribute("name") else { + continue; + }; + let ty = var + .children() + .find(|n| n.has_tag_name("type")) + .map(type_name) + .unwrap_or_else(|| "BOOL".to_string()); + let init = var + .children() + .find(|n| n.has_tag_name("initialValue")) + .and_then(initial_value); + match init { + Some(v) => out.push_str(&format!(" {vname} : {ty} := {v};\n")), + None => out.push_str(&format!(" {vname} : {ty};\n")), + } + any = true; + } + } + out.push_str("END_VAR\n"); + if any { + out + } else { + String::new() + } +} + +/// Render a PLCopen `` element as an ST type string. +fn type_name(type_node: roxmltree::Node) -> String { + let Some(inner) = type_node.children().find(|n| n.is_element()) else { + return "BOOL".to_string(); + }; + let tag = inner.tag_name().name(); + match tag { + "derived" => inner.attribute("name").unwrap_or("DERIVED").to_string(), + "array" => { + let dim = inner.children().find(|n| n.has_tag_name("dimension")); + let (lo, hi) = dim + .map(|d| { + ( + d.attribute("lower").unwrap_or("0").to_string(), + d.attribute("upper").unwrap_or("0").to_string(), + ) + }) + .unwrap_or_else(|| ("0".to_string(), "0".to_string())); + let base = inner + .children() + .find(|n| n.has_tag_name("baseType")) + .map(type_name) + .unwrap_or_else(|| "INT".to_string()); + format!("ARRAY[{lo}..{hi}] OF {base}") + } + "string" | "wstring" => "STRING".to_string(), + // BOOL, INT, DINT, REAL, TIME, ... — the tag name is the ST type. + other => other.to_ascii_uppercase(), + } +} + +/// Extract an initial value as an ST literal (quoting strings). +fn initial_value(iv: roxmltree::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() { + return None; + } + // Numbers / booleans / time literals pass through; everything else is a + // string literal. + let is_scalar = raw.eq_ignore_ascii_case("true") + || raw.eq_ignore_ascii_case("false") + || raw.starts_with(['T', 't', 'D', 'd']) && raw.contains('#') + || raw + .chars() + .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+'); + if is_scalar || raw.starts_with('\'') || raw.starts_with('"') { + Some(raw) + } else { + Some(format!("'{}'", raw.replace('\'', "''"))) + } +} diff --git a/compliance-agent/src/pipeline/plc/rules.rs b/compliance-agent/src/pipeline/plc/rules.rs new file mode 100644 index 0000000..bf1eefd --- /dev/null +++ b/compliance-agent/src/pipeline/plc/rules.rs @@ -0,0 +1,632 @@ +//! Semantic control-logic security rules over the Structured Text AST. +//! +//! Each rule walks the parsed [`Pou`] and yields [`RuleHit`]s the scanner turns +//! into findings. Rules reason over structure (declarations, assignments, calls, +//! array accesses, division, jumps) rather than raw text, so they see through +//! formatting and comments. + +use std::collections::{HashMap, HashSet}; + +use compliance_core::models::Severity; + +use super::ast::*; + +/// One rule match within a POU. +pub struct RuleHit { + pub line: u32, + pub severity: Severity, + pub rule_id: &'static str, + pub title: String, + pub description: String, + pub cwe: Option<&'static str>, + pub remediation: &'static str, +} + +/// Run every rule over a POU. +pub fn analyze(pou: &Pou) -> Vec { + let mut hits = Vec::new(); + let ctx = Ctx::build(pou); + + // Declaration-level rules. + for v in &pou.vars { + if let Some(init) = &v.init { + check_credential_binding(&v.name, init, &pou.name, &mut hits); + check_default_password(init, &v.name, &pou.name, &mut hits); + } + } + + // Body walk. + walk(&pou.body, pou, &ctx, &GuardSet::default(), &mut hits); + hits +} + +/// Per-POU context precomputed once. +struct Ctx { + /// Names declared in VAR_INPUT (untrusted / externally driven). + input_vars: HashSet, + /// Array variable name → declared (lo, hi) bounds. + arrays: HashMap, +} + +impl Ctx { + fn build(pou: &Pou) -> Self { + let mut input_vars = HashSet::new(); + let mut arrays = HashMap::new(); + for v in &pou.vars { + if v.section == VarSection::Input { + input_vars.insert(v.name.to_ascii_lowercase()); + } + if let Some(b) = v.array_bounds { + arrays.insert(v.name.to_ascii_lowercase(), b); + } + } + Self { input_vars, arrays } + } +} + +/// Variables proven non-zero on the current control-flow path (from enclosing +/// `IF`/`WHILE` conditions), so guarded divisions aren't false-flagged. +#[derive(Default, Clone)] +struct GuardSet { + nonzero: HashSet, +} + +impl GuardSet { + fn with(&self, names: Vec) -> Self { + let mut g = self.clone(); + g.nonzero.extend(names); + g + } + fn is_nonzero(&self, name: &str) -> bool { + self.nonzero.contains(name) + } +} + +/// Variable names a condition proves non-zero (`v <> 0`, `v > 0`, `v >= 1`, +/// `v < 0`, and conjunctions thereof). +fn guards_from_cond(cond: &Expr) -> Vec { + let mut out = Vec::new(); + collect_nonzero(cond, &mut out); + out +} + +fn collect_nonzero(e: &Expr, out: &mut Vec) { + let Expr::Binary { op, lhs, rhs, .. } = e else { + return; + }; + let is_zero = |x: &Expr| { + matches!(x, Expr::Int(0, _)) || matches!(x, Expr::Real(r, _) if r.abs() < f64::EPSILON) + }; + let int_of = |x: &Expr| match x { + Expr::Int(n, _) => Some(*n), + _ => None, + }; + match op { + BinOp::And => { + collect_nonzero(lhs, out); + collect_nonzero(rhs, out); + } + BinOp::Ne => { + if let (Some(v), true) = (lhs.as_ident(), is_zero(rhs)) { + out.push(v.to_ascii_lowercase()); + } + if let (true, Some(v)) = (is_zero(lhs), rhs.as_ident()) { + out.push(v.to_ascii_lowercase()); + } + } + BinOp::Gt | BinOp::Lt => { + // v > 0 or v < 0 + if let (Some(v), true) = (lhs.as_ident(), is_zero(rhs)) { + out.push(v.to_ascii_lowercase()); + } + } + BinOp::Ge => { + // v >= n, n >= 1 + if let (Some(v), Some(n)) = (lhs.as_ident(), int_of(rhs)) { + if n >= 1 { + out.push(v.to_ascii_lowercase()); + } + } + } + _ => {} + } +} + +// ── the walker ───────────────────────────────────────────────────── +fn walk(stmts: &[Stmt], pou: &Pou, ctx: &Ctx, guards: &GuardSet, hits: &mut Vec) { + for s in stmts { + match s { + Stmt::Assign { + target, + value, + line, + } => { + check_safety_bypass(target, value, *line, &pou.name, hits); + // A string bound to a secret-looking target is a credential. + if let Some(name) = flatten_ident(target) { + check_credential_binding(&name, value, &pou.name, hits); + check_default_password(value, &name, &pou.name, hits); + } + walk_expr(target, pou, ctx, guards, hits); + walk_expr(value, pou, ctx, guards, hits); + } + Stmt::Call { callee, args, line } => { + check_insecure_comm(callee, args, *line, &pou.name, hits); + check_credentials_in_call(callee, args, *line, &pou.name, hits); + for a in args { + walk_expr(&a.value, pou, ctx, guards, hits); + } + } + Stmt::Jump { label, line } => hits.push(RuleHit { + line: *line, + severity: Severity::Medium, + rule_id: "plc-unstructured-jump", + title: "Unstructured jump (JMP) in control logic".to_string(), + description: format!( + "POU `{}` uses `JMP {label}`. Unstructured jumps make control flow hard to \ + verify and can bypass safety interlocks or leave outputs in an undefined \ + state on unexpected paths.", + pou.name + ), + cwe: Some("CWE-691"), + remediation: "Replace JMP with structured constructs (IF/CASE/loops); reserve \ + jumps for well-reviewed state machines only.", + }), + Stmt::If { + branches, + else_body, + .. + } => { + for (cond, body) in branches { + walk_expr(cond, pou, ctx, guards, hits); + let child = guards.with(guards_from_cond(cond)); + walk(body, pou, ctx, &child, hits); + } + if let Some(b) = else_body { + walk(b, pou, ctx, guards, hits); + } + } + Stmt::Case { + selector, + arms, + else_body, + .. + } => { + walk_expr(selector, pou, ctx, guards, hits); + for (labels, body) in arms { + for l in labels { + walk_expr(l, pou, ctx, guards, hits); + } + walk(body, pou, ctx, guards, hits); + } + if let Some(b) = else_body { + walk(b, pou, ctx, guards, hits); + } + } + Stmt::For { + from, to, by, body, .. + } => { + walk_expr(from, pou, ctx, guards, hits); + walk_expr(to, pou, ctx, guards, hits); + if let Some(b) = by { + walk_expr(b, pou, ctx, guards, hits); + } + walk(body, pou, ctx, guards, hits); + } + Stmt::While { cond, body, .. } => { + walk_expr(cond, pou, ctx, guards, hits); + let child = guards.with(guards_from_cond(cond)); + walk(body, pou, ctx, &child, hits); + } + Stmt::Repeat { body, until, .. } => { + walk(body, pou, ctx, guards, hits); + walk_expr(until, pou, ctx, guards, hits); + } + Stmt::Return { .. } | Stmt::Exit { .. } | Stmt::Label { .. } => {} + } + } +} + +fn walk_expr(e: &Expr, pou: &Pou, ctx: &Ctx, guards: &GuardSet, hits: &mut Vec) { + match e { + Expr::Index { base, index, line } => { + check_array_bounds(base, index, *line, ctx, &pou.name, hits); + walk_expr(base, pou, ctx, guards, hits); + walk_expr(index, pou, ctx, guards, hits); + } + Expr::Binary { op, lhs, rhs, line } => { + if matches!(op, BinOp::Div | BinOp::Mod) { + check_division(rhs, *line, &pou.name, guards, hits); + } + walk_expr(lhs, pou, ctx, guards, hits); + walk_expr(rhs, pou, ctx, guards, hits); + } + Expr::Unary { expr, .. } => walk_expr(expr, pou, ctx, guards, hits), + Expr::Member { base, .. } => walk_expr(base, pou, ctx, guards, hits), + Expr::Call { args, .. } => { + for a in args { + walk_expr(&a.value, pou, ctx, guards, hits); + } + } + _ => {} + } +} + +// ── individual rules ─────────────────────────────────────────────── +const SECRET_HINTS: &[&str] = &[ + "password", + "passwd", + "pwd", + "secret", + "apikey", + "api_key", + "token", + "credential", + "privkey", + "private_key", + "passphrase", +]; + +const DEFAULT_PASSWORDS: &[&str] = &[ + "admin", + "administrator", + "password", + "passwd", + "1234", + "12345", + "123456", + "0000", + "1111", + "root", + "default", + "admin123", + "changeme", + "letmein", + "guest", + "user", + "system", + "plc", + "codesys", +]; + +const COMM_FB_HINTS: &[&str] = &[ + "modbus", "tcp", "udp", "socket", "mqtt", "opcua", "opc_ua", "ethernet", "ethip", "enip", + "dnp3", "ftp", "telnet", "http", "send", "connect", "sock", "comm", "profinet", "s7", +]; + +/// Insecure cleartext service ports. +const INSECURE_PORTS: &[i64] = &[21, 23, 80, 502, 20000, 44818, 102]; + +fn check_credential_binding(var_name: &str, value: &Expr, pou: &str, hits: &mut Vec) { + let name = var_name.to_ascii_lowercase(); + let looks_secret = SECRET_HINTS.iter().any(|h| name.contains(h)); + if looks_secret { + if let Expr::Str(s, line) = value { + if !s.is_empty() { + hits.push(RuleHit { + line: *line, + severity: Severity::High, + rule_id: "plc-hardcoded-credential", + title: "Hardcoded credential in PLC program".to_string(), + description: format!( + "POU `{pou}` binds a hardcoded secret to `{var_name}`. Credentials \ + embedded in control logic are extracted trivially from a project export \ + or a firmware dump and cannot be rotated without a redeploy." + ), + cwe: Some("CWE-798"), + remediation: "Store secrets outside the program (secure parameter store / \ + operator-entered, retained-but-protected memory); never commit \ + them to the POU.", + }); + } + } + } +} + +fn check_default_password(value: &Expr, var_name: &str, pou: &str, hits: &mut Vec) { + if let Expr::Str(s, line) = value { + let lower = s.to_ascii_lowercase(); + if DEFAULT_PASSWORDS.contains(&lower.as_str()) { + hits.push(RuleHit { + line: *line, + severity: Severity::Critical, + rule_id: "plc-default-password", + title: "Default/weak password in PLC program".to_string(), + description: format!( + "POU `{pou}` uses the well-known default/weak password `{s}` (bound to \ + `{var_name}`). Default PLC credentials are the first thing an attacker tries." + ), + cwe: Some("CWE-1393"), + remediation: + "Require a strong, unique, operator-set password; block commissioning \ + until the default is changed.", + }); + } + } +} + +fn check_credentials_in_call( + callee: &str, + args: &[CallArg], + line: u32, + pou: &str, + hits: &mut Vec, +) { + for a in args { + if let Some(name) = &a.name { + let n = name.to_ascii_lowercase(); + if SECRET_HINTS.iter().any(|h| n.contains(h)) { + if let Expr::Str(s, l) = &a.value { + if !s.is_empty() { + hits.push(RuleHit { + line: *l, + severity: Severity::High, + rule_id: "plc-hardcoded-credential", + title: "Hardcoded credential passed to a function block".to_string(), + description: format!( + "POU `{pou}` passes a hardcoded secret as `{name}` to `{callee}`." + ), + cwe: Some("CWE-798"), + remediation: "Supply credentials from protected configuration at \ + runtime, not as a literal argument.", + }); + } + } + } + } + } + let _ = line; +} + +fn check_safety_bypass(target: &Expr, value: &Expr, line: u32, pou: &str, hits: &mut Vec) { + let Some(name) = flatten_ident(target) else { + return; + }; + let n = name.to_ascii_lowercase(); + let safety = [ + "safety", + "estop", + "e_stop", + "emergency", + "interlock", + "guard", + "permit", + ] + .iter() + .any(|h| n.contains(h)); + let watchdog = n.contains("watchdog") || n.contains("wdt"); + // A safety enable / interlock / watchdog signal driven to FALSE or 0 in + // application logic is a bypass (e.g. `Safety_Enable := FALSE`, `Watchdog_Kick := 0`). + let disabling = matches!(value, Expr::Bool(false, _)) || matches!(value, Expr::Int(0, _)); + if (safety || watchdog) && disabling { + hits.push(RuleHit { + line, + severity: Severity::Critical, + rule_id: "plc-safety-bypass", + title: "Safety interlock / watchdog disabled in logic".to_string(), + description: format!( + "POU `{pou}` disables a safety-related signal (`{name}`) in program logic. \ + Bypassing interlocks or watchdogs in code defeats the plant's protective \ + functions and is a direct hazard." + ), + cwe: Some("CWE-1384"), + remediation: "Never disable safety functions from application logic; safety must be \ + handled by a certified safety controller / hard-wired circuit.", + }); + } +} + +fn check_array_bounds( + base: &Expr, + index: &Expr, + line: u32, + ctx: &Ctx, + pou: &str, + hits: &mut Vec, +) { + // Only reason about arrays we know the bounds of. + let Some(arr_name) = base.as_ident() else { + return; + }; + if !ctx.arrays.contains_key(&arr_name.to_ascii_lowercase()) { + return; + } + // Index by an untrusted input variable → potential out-of-bounds access. + if let Some(idx_name) = index.as_ident() { + if ctx.input_vars.contains(&idx_name.to_ascii_lowercase()) { + hits.push(RuleHit { + line, + severity: Severity::High, + rule_id: "plc-array-unchecked-index", + title: "Array indexed by unvalidated input".to_string(), + description: format!( + "POU `{pou}` indexes array `{arr_name}` with the input variable `{idx_name}` \ + without a validated bounds check. An out-of-range index corrupts adjacent \ + memory or faults the PLC (loss of control)." + ), + cwe: Some("CWE-129"), + remediation: "Clamp or validate the index against the array bounds (e.g. \ + `LIMIT`/explicit `IF idx >= lo AND idx <= hi`) before the access.", + }); + } + } +} + +fn check_division( + divisor: &Expr, + line: u32, + pou: &str, + guards: &GuardSet, + hits: &mut Vec, +) { + // A divisor proven non-zero by an enclosing guard is safe. + if let Expr::Ident(name, _) = divisor { + if guards.is_nonzero(&name.to_ascii_lowercase()) { + return; + } + } + // Flag division by a variable (could be zero); nonzero literals are fine. + let risky = matches!( + divisor, + Expr::Ident(_, _) | Expr::Member { .. } | Expr::Index { .. } | Expr::Int(0, _) + ); + if risky { + hits.push(RuleHit { + line, + severity: Severity::Medium, + rule_id: "plc-division-by-zero", + title: "Division by a variable without a zero-guard".to_string(), + description: format!( + "POU `{pou}` divides by a variable that is not proven non-zero. A zero divisor \ + raises a PLC exception and can halt the scan cycle (denial of control)." + ), + cwe: Some("CWE-369"), + remediation: "Guard the divisor (`IF d <> 0 THEN …`) or use a safe-divide helper that \ + returns a defined value for a zero denominator.", + }); + } +} + +fn check_insecure_comm( + callee: &str, + args: &[CallArg], + line: u32, + pou: &str, + hits: &mut Vec, +) { + let c = callee.to_ascii_lowercase(); + let is_comm = COMM_FB_HINTS.iter().any(|h| c.contains(h)); + if !is_comm { + return; + } + // Auth/encryption explicitly disabled. + for a in args { + if let Some(name) = &a.name { + let n = name.to_ascii_lowercase(); + let security_flag = ["auth", "secure", "encrypt", "tls", "ssl", "authentication"] + .iter() + .any(|h| n.contains(h)); + if security_flag && matches!(a.value, Expr::Bool(false, _)) { + hits.push(RuleHit { + line, + severity: Severity::High, + rule_id: "plc-insecure-comm", + title: "Network communication with security disabled".to_string(), + description: format!( + "POU `{pou}` calls `{callee}` with `{name} := FALSE`, disabling \ + authentication/encryption on an industrial network link." + ), + cwe: Some("CWE-319"), + remediation: "Enable authentication + transport encryption; segment OT \ + networks and restrict the endpoint to trusted peers.", + }); + } + } + // Well-known cleartext port literal. + if let Expr::Int(p, _) = &a.value { + if INSECURE_PORTS.contains(p) { + hits.push(RuleHit { + line, + severity: Severity::Medium, + rule_id: "plc-insecure-protocol-port", + title: "Cleartext industrial protocol port".to_string(), + description: format!( + "POU `{pou}` opens `{callee}` on port {p}, a well-known cleartext OT \ + protocol port with no built-in authentication or encryption." + ), + cwe: Some("CWE-319"), + remediation: "Front the protocol with a secure gateway/VPN, or use the \ + authenticated/encrypted variant; never expose it to untrusted \ + networks.", + }); + } + } + } + let _ = line; +} + +/// The dotted/base identifier of an lvalue expression (`a`, `a.b` → `a.b`, +/// `a[i]` → `a`), for name-based rules. +fn flatten_ident(e: &Expr) -> Option { + match e { + Expr::Ident(n, _) => Some(n.clone()), + Expr::Member { base, field, .. } => flatten_ident(base).map(|b| format!("{b}.{field}")), + Expr::Index { base, .. } => flatten_ident(base), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pipeline::plc::parser; + + const VULN: &str = r#" +FUNCTION_BLOCK CommCtrl +VAR_INPUT + cmdIndex : INT; +END_VAR +VAR + Password : STRING := 'admin123'; + buffer : ARRAY[0..15] OF INT; + Safety_Enable : BOOL := TRUE; + divisor : INT; + result : INT; +END_VAR + Safety_Enable := FALSE; + result := 100 / divisor; + buffer[cmdIndex] := 1; + Modbus_Connect(IP := '192.168.0.10', PORT := 502, AUTH := FALSE); + IF cmdIndex > 100 THEN + JMP fault; + END_IF; +fault: + result := 0; +END_FUNCTION_BLOCK +"#; + + fn rule_ids(src: &str) -> Vec<&'static str> { + parser::parse(src) + .iter() + .flat_map(analyze) + .map(|h| h.rule_id) + .collect() + } + + #[test] + fn vulnerable_program_triggers_every_rule() { + let ids = rule_ids(VULN); + for expected in [ + "plc-hardcoded-credential", + "plc-default-password", + "plc-safety-bypass", + "plc-division-by-zero", + "plc-array-unchecked-index", + "plc-insecure-comm", + "plc-insecure-protocol-port", + "plc-unstructured-jump", + ] { + assert!( + ids.contains(&expected), + "expected rule {expected}, got {ids:?}" + ); + } + } + + #[test] + fn clean_program_has_no_findings() { + let clean = r#" +PROGRAM Clean +VAR + a : INT := 5; + b : INT := 3; + total : INT; +END_VAR + IF b <> 0 THEN + total := a / b; + END_IF; +END_PROGRAM +"#; + assert!(rule_ids(clean).is_empty(), "clean program should be quiet"); + } +} diff --git a/examples/plc-demo/conveyor.xml b/examples/plc-demo/conveyor.xml new file mode 100644 index 0000000..ae3c293 --- /dev/null +++ b/examples/plc-demo/conveyor.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Belt[Slot] := 1; +Ftp_Send(HOST := '192.168.1.5', PORT := 21, ENCRYPT := FALSE); + + + + + + + diff --git a/examples/plc-demo/pump_station.st b/examples/plc-demo/pump_station.st new file mode 100644 index 0000000..c386369 --- /dev/null +++ b/examples/plc-demo/pump_station.st @@ -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 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