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
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a981311413
commit
fcd49ecdf7
Generated
+15
-8
@@ -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]]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,156 @@
|
|||||||
|
//! PLC control-logic security scanner for IEC 61131-3 targets.
|
||||||
|
//!
|
||||||
|
//! Parses Structured Text (raw `.st`/`.scl`/`.exp` files and PLCopen-XML
|
||||||
|
//! projects) into an AST and runs semantic control-logic security rules over it.
|
||||||
|
//! Implements [`ScanType::PlcControlLogic`].
|
||||||
|
|
||||||
|
pub mod ast;
|
||||||
|
pub mod lexer;
|
||||||
|
pub mod parser;
|
||||||
|
pub mod plcopen;
|
||||||
|
pub mod rules;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use compliance_core::error::CoreError;
|
||||||
|
use compliance_core::models::{Finding, ScanType};
|
||||||
|
use compliance_core::traits::{ScanOutput, Scanner};
|
||||||
|
|
||||||
|
use crate::pipeline::dedup;
|
||||||
|
|
||||||
|
/// Scanner for `ScanType::PlcControlLogic`.
|
||||||
|
pub struct PlcControlLogicScanner;
|
||||||
|
|
||||||
|
impl Scanner for PlcControlLogicScanner {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"plc-control-logic"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_type(&self) -> ScanType {
|
||||||
|
ScanType::PlcControlLogic
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
let disabling = matches!(value, Expr::Bool(false, _))
|
||||||
|
|| matches!(value, Expr::Int(0, _))
|
||||||
|
|| (watchdog && matches!(value, Expr::Int(0, _)));
|
||||||
|
if (safety || watchdog) && disabling {
|
||||||
|
hits.push(RuleHit {
|
||||||
|
line,
|
||||||
|
severity: Severity::Critical,
|
||||||
|
rule_id: "plc-safety-bypass",
|
||||||
|
title: "Safety interlock / watchdog disabled in logic".to_string(),
|
||||||
|
description: format!(
|
||||||
|
"POU `{pou}` disables a safety-related signal (`{name}`) in program logic. \
|
||||||
|
Bypassing interlocks or watchdogs in code defeats the plant's protective \
|
||||||
|
functions and is a direct hazard."
|
||||||
|
),
|
||||||
|
cwe: Some("CWE-1384"),
|
||||||
|
remediation: "Never disable safety functions from application logic; safety must be \
|
||||||
|
handled by a certified safety controller / hard-wired circuit.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_array_bounds(
|
||||||
|
base: &Expr,
|
||||||
|
index: &Expr,
|
||||||
|
line: u32,
|
||||||
|
ctx: &Ctx,
|
||||||
|
pou: &str,
|
||||||
|
hits: &mut Vec<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
|
||||||
Reference in New Issue
Block a user