feat(pipeline): PLC/SPS control-logic security scanner (IEC 61131-3) (#162)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m48s
CI / Deploy Dashboard (push) Successful in 2m51s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m2s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m48s
CI / Deploy Dashboard (push) Successful in 2m51s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m2s
This commit was merged in pull request #162.
This commit is contained in:
Generated
+15
-8
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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,
|
||||
/// heuristics otherwise), and store the detected classification on the target.
|
||||
/// Best-effort — never fails the scan.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('\'', "''")))
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user