Compare commits

...
Author SHA1 Message Date
Sharang ParnerkarandClaude Opus 4.8 f8861419cb test(plc): add realistic OpenPLC-style traffic-light sample
CI / Check (pull_request) Successful in 5m20s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-07-16 10:25:31 +02:00
Sharang ParnerkarandClaude Opus 4.8 5e983d699f fix(plc): drop redundant watchdog clause in safety-bypass rule
CI / Check (pull_request) Has been cancelled
CI / Detect Changes (pull_request) Has been cancelled
CI / Deploy Agent (pull_request) Has been cancelled
CI / Deploy Dashboard (pull_request) Has been cancelled
CI / Deploy Docs (pull_request) Has been cancelled
CI / Deploy MCP (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-07-16 10:20:54 +02:00
Sharang ParnerkarandClaude Opus 4.8 fcd49ecdf7 feat(pipeline): PLC/SPS control-logic security scanner (IEC 61131-3)
CI / Check (pull_request) Failing after 2m31s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
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 + `<ST>` 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 <d> <> 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 <noreply@anthropic.com>
2026-07-16 09:52:42 +02:00
13 changed files with 2599 additions and 8 deletions
Generated
+15 -8
View File
@@ -679,6 +679,7 @@ dependencies = [
"rand 0.9.2", "rand 0.9.2",
"regex", "regex",
"reqwest", "reqwest",
"roxmltree",
"secrecy", "secrecy",
"serde", "serde",
"serde_json", "serde_json",
@@ -2103,7 +2104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -3698,7 +3699,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4628,6 +4629,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]] [[package]]
name = "rust-stemmers" name = "rust-stemmers"
version = "1.2.0" version = "1.2.0"
@@ -4679,7 +4686,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys 0.4.15",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -4692,7 +4699,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -5215,7 +5222,7 @@ version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
dependencies = [ dependencies = [
"heck 0.4.1", "heck 0.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn",
@@ -5570,10 +5577,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.3.4", "getrandom 0.4.1",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6746,7 +6753,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
+2
View File
@@ -42,6 +42,8 @@ tokio-cron-scheduler = "0.13"
dotenvy = "0.15" dotenvy = "0.15"
hmac = "0.12" hmac = "0.12"
walkdir = "2" walkdir = "2"
# Read-only XML tree parsing for PLCopen project files (POU extraction).
roxmltree = "0.20"
base64 = "0.22" base64 = "0.22"
urlencoding = "2" urlencoding = "2"
futures-util = "0.3" futures-util = "0.3"
+1
View File
@@ -10,6 +10,7 @@ pub mod lint;
pub mod orchestrator; pub mod orchestrator;
pub mod patterns; pub mod patterns;
pub mod plan; pub mod plan;
pub mod plc;
mod pr_review; mod pr_review;
pub mod repo_view; pub mod repo_view;
pub mod sbom; pub mod sbom;
@@ -449,6 +449,11 @@ impl PipelineOrchestrator {
// wizard-created targets, not just migrated ones. // wizard-created targets, not just migrated ones.
self.ensure_dast_target(target, &plan).await; 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() { match target.code_artifact() {
Some(code) if code.kind == ArtifactKind::GitRepo => { Some(code) if code.kind == ArtifactKind::GitRepo => {
let repo = RepoView::from_target(target, code); 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<u32, AgentError> {
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, /// Ingest the target's artifacts, classify (tramiton for firmware/RTOS/Yocto,
/// heuristics otherwise), and store the detected classification on the target. /// heuristics otherwise), and store the detected classification on the target.
/// Best-effort — never fails the scan. /// Best-effort — never fails the scan.
+226
View File
@@ -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<VarDecl>,
/// The statement body.
pub body: Vec<Stmt>,
/// 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 (`:= <expr>`).
pub init: Option<Expr>,
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<Stmt>)>,
else_body: Option<Vec<Stmt>>,
line: u32,
},
Case {
selector: Expr,
/// (label expressions, body) per CASE arm.
arms: Vec<(Vec<Expr>, Vec<Stmt>)>,
else_body: Option<Vec<Stmt>>,
line: u32,
},
For {
var: String,
from: Expr,
to: Expr,
by: Option<Expr>,
body: Vec<Stmt>,
line: u32,
},
While {
cond: Expr,
body: Vec<Stmt>,
line: u32,
},
Repeat {
body: Vec<Stmt>,
until: Expr,
line: u32,
},
/// A bare call statement, e.g. `TON1(IN := x, PT := T#5s);`.
Call {
callee: String,
args: Vec<CallArg>,
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<String>,
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<Expr>,
index: Box<Expr>,
line: u32,
},
/// `base.field`.
Member {
base: Box<Expr>,
field: String,
line: u32,
},
Unary {
op: UnOp,
expr: Box<Expr>,
line: u32,
},
Binary {
op: BinOp,
lhs: Box<Expr>,
rhs: Box<Expr>,
line: u32,
},
/// A function call used as an expression, e.g. `LIMIT(a, b, c)`.
Call {
callee: String,
args: Vec<CallArg>,
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,
}
+372
View File
@@ -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<Keyword> {
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<Token> {
let chars: Vec<char> = 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: <base>#<digits>
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::<u32>().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
}
+200
View File
@@ -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<ScanOutput, CoreError> {
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<Finding> {
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"
);
}
}
+766
View File
@@ -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<Token>,
pos: usize,
}
impl Parser {
pub fn new(toks: Vec<Token>) -> 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<String> {
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<Pou> {
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<Pou> {
let line = self.line();
let name = self.ident().unwrap_or_else(|| "<anonymous>".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_<kind>.
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<VarSection> {
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<VarDecl>) {
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<i64> {
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<Stmt> {
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<Stmt> {
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<Stmt> {
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<Stmt> {
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<Stmt> {
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<Stmt> {
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<Stmt> {
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<CallArg> {
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<Pou> {
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");
}
}
@@ -0,0 +1,149 @@
//! PLCopen XML → Structured Text POUs.
//!
//! A PLCopen project stores each POU as `<pou name=".." pouType="..">` with an
//! `<interface>` (typed variable sections) and a `<body>`. We handle the
//! Structured-Text body form (`<ST>…</ST>`); 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<Pou> {
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
/// `<xhtml>` and may contain multiple text runs).
fn collect_text(node: roxmltree::Node) -> String {
node.descendants()
.filter_map(|n| n.text())
.collect::<String>()
}
/// Build an ST `VAR … END_VAR` block from a POU's `<interface>` 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 `<type>` 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<String> {
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('\'', "''")))
}
}
+632
View File
@@ -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<RuleHit> {
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<String>,
/// Array variable name → declared (lo, hi) bounds.
arrays: HashMap<String, (i64, i64)>,
}
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<String>,
}
impl GuardSet {
fn with(&self, names: Vec<String>) -> 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<String> {
let mut out = Vec::new();
collect_nonzero(cond, &mut out);
out
}
fn collect_nonzero(e: &Expr, out: &mut Vec<String>) {
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<RuleHit>) {
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<RuleHit>) {
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<RuleHit>) {
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<RuleHit>) {
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<RuleHit>,
) {
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<RuleHit>) {
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<RuleHit>,
) {
// 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<RuleHit>,
) {
// 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<RuleHit>,
) {
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<String> {
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");
}
}
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Demo PLCopen project — conveyor sorter. Deliberately vulnerable. -->
<project xmlns="http://www.plcopen.org/xml/tc6_0201">
<types>
<pous>
<pou name="ConveyorCtrl" pouType="program">
<interface>
<localVars>
<variable name="AdminPwd">
<type><string/></type>
<initialValue><simpleValue value="password"/></initialValue>
</variable>
<variable name="Belt">
<type>
<array>
<dimension lower="0" upper="3"/>
<baseType><INT/></baseType>
</array>
</type>
</variable>
</localVars>
<inputVars>
<variable name="Slot"><type><INT/></type></variable>
</inputVars>
</interface>
<body>
<ST>
<xhtml xmlns="http://www.w3.org/1999/xhtml">Belt[Slot] := 1;
Ftp_Send(HOST := '192.168.1.5', PORT := 21, ENCRYPT := FALSE);
</xhtml>
</ST>
</body>
</pou>
</pous>
</types>
</project>
+57
View File
@@ -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
+92
View File
@@ -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