//! 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"); } }