From fcd49ecdf75dc8e6045ffd3e19b6067a67ab3d29 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:52:42 +0200 Subject: [PATCH 1/3] feat(pipeline): PLC/SPS control-logic security scanner (IEC 61131-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ScanType::PlcControlLogic — the missing piece for PlcSps targets, which previously classified but ran no scan. New `pipeline::plc`: - A real IEC 61131-3 Structured Text front end: lexer + recursive-descent parser → AST (POUs, typed VAR sections, statements, expressions). Tolerant recovery so odd constructs never sink a file. - PLCopen XML extractor: pulls each ST POU's interface vars + `` body and reconstructs equivalent ST, so raw `.st` files and PLCopen projects share one analysis path. - Eight semantic, guard-aware rules over the AST → findings: hardcoded credentials, default/weak passwords, safety-interlock/watchdog bypass, array indexed by unvalidated input, division without a zero-guard (suppressed when an enclosing `IF <> 0` proves it), insecure comm (auth/encryption disabled), and cleartext OT protocol ports, plus unstructured JMP. Each carries CWE + remediation. - `PlcControlLogicScanner` (Scanner impl) walks the project tree and emits `Finding`s (dedup fingerprint, file, line, severity). Wired into `run_target_pipeline`: when the scan plan includes PlcControlLogic, `run_plc_scan` ingests the PlcProject artifact, analyzes it, and persists the findings (findings_count handled by run_target). Demo fixtures under examples/plc-demo/ (a vulnerable pump-station `.st` + a PLCopen `conveyor.xml`). Tests: parser, all-rules-fire, guarded-clean-is-quiet, and an end-to-end tree scan — 5 passing. Adds `roxmltree` (read-only XML) for PLCopen parsing. Co-Authored-By: Claude Opus 4.8 --- 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 | 156 ++++ 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 ++ 12 files changed, 2463 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 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..b23991e --- /dev/null +++ b/compliance-agent/src/pipeline/plc/mod.rs @@ -0,0 +1,156 @@ +//! 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"); + } +} 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..700f097 --- /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"); + let disabling = matches!(value, Expr::Bool(false, _)) + || matches!(value, Expr::Int(0, _)) + || (watchdog && 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 -- 2.54.0 From 5e983d699f91d592c694c4006a304a128803ea4a Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:20:54 +0200 Subject: [PATCH 2/3] fix(plc): drop redundant watchdog clause in safety-bypass rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI clippy (rust 1.94.0, overly_complex_bool_expr) flagged the disabling check as a logic bug: the `watchdog && matches!(value, Int(0))` term is fully subsumed by the preceding `matches!(value, Int(0))`. Simplify to `Bool(false) || Int(0)` — behavior is unchanged (a safety/watchdog signal driven to FALSE or 0 is still a bypass), and `watchdog` stays used in the outer guard. All 5 PLC tests still pass. Co-Authored-By: Claude Opus 4.8 --- compliance-agent/src/pipeline/plc/rules.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compliance-agent/src/pipeline/plc/rules.rs b/compliance-agent/src/pipeline/plc/rules.rs index 700f097..bf1eefd 100644 --- a/compliance-agent/src/pipeline/plc/rules.rs +++ b/compliance-agent/src/pipeline/plc/rules.rs @@ -395,9 +395,9 @@ fn check_safety_bypass(target: &Expr, value: &Expr, line: u32, pou: &str, hits: .iter() .any(|h| n.contains(h)); let watchdog = n.contains("watchdog") || n.contains("wdt"); - let disabling = matches!(value, Expr::Bool(false, _)) - || matches!(value, Expr::Int(0, _)) - || (watchdog && matches!(value, Expr::Int(0, _))); + // 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, -- 2.54.0 From f8861419cb732ac7c78fef56d2fe7aae68a3a2d8 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:25:31 +0200 Subject: [PATCH 3/3] test(plc): add realistic OpenPLC-style traffic-light sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second demo fixture (public-sample shape) to complement the all-rules pump_station.st: a timed pedestrian-crossing state machine adapted from the OpenPLC traffic-light example, extended with a SCADA/Modbus uplink and a maintenance override. Mostly sound control logic with three planted, field-realistic defects (hardcoded SCADA password, cleartext Modbus master, maintenance mode that drops the pedestrian safety permit). The regression test asserts the scanner surfaces those defects while staying quiet on the guarded duty-cycle division and the JMP-free CASE machine — demonstrating low false positives on real-world-shaped code. Co-Authored-By: Claude Opus 4.8 --- compliance-agent/src/pipeline/plc/mod.rs | 44 ++++++++++++ examples/plc-demo/traffic_light.st | 92 ++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 examples/plc-demo/traffic_light.st diff --git a/compliance-agent/src/pipeline/plc/mod.rs b/compliance-agent/src/pipeline/plc/mod.rs index b23991e..f64b676 100644 --- a/compliance-agent/src/pipeline/plc/mod.rs +++ b/compliance-agent/src/pipeline/plc/mod.rs @@ -153,4 +153,48 @@ mod tests { .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/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 -- 2.54.0