Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dadccb9264 |
Generated
+8
-15
@@ -679,7 +679,6 @@ dependencies = [
|
|||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"roxmltree",
|
|
||||||
"secrecy",
|
"secrecy",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2104,7 +2103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3699,7 +3698,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.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4629,12 +4628,6 @@ 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"
|
||||||
@@ -4686,7 +4679,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.4.15",
|
"linux-raw-sys 0.4.15",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4699,7 +4692,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5222,7 +5215,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.5.0",
|
"heck 0.4.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
@@ -5577,10 +5570,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.1",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6753,7 +6746,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.61.2",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ 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,7 +10,6 @@ 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,11 +449,6 @@ 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);
|
||||||
@@ -483,52 +478,6 @@ 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.
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
//! 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,
|
|
||||||
}
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
//! 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
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
//! 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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,766 +0,0 @@
|
|||||||
//! 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
//! 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('\'', "''")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,632 +0,0 @@
|
|||||||
//! 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
(*
|
|
||||||
* 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
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
(*
|
|
||||||
* 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