CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m46s
CI / Deploy Dashboard (push) Successful in 2m53s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m2s
423 lines
15 KiB
Rust
423 lines
15 KiB
Rust
//! Dynamic PLC testing via an ephemeral soft-PLC (#183).
|
|
//!
|
|
//! When a PLC/SPS target ships control logic but no reachable live device, the
|
|
//! agent instantiates that logic itself instead of trying to reach the customer's
|
|
//! OT network: it provisions a throwaway soft-PLC (OpenPLC) container in-cluster,
|
|
//! loads the program, starts the runtime, probes it over industrial protocols,
|
|
//! then tears the instance down. No customer network access, sandboxed, and
|
|
//! reproducible — destructive tests become safe because the target is ours.
|
|
//!
|
|
//! - [`provision`] owns the container lifecycle (sub-task 1 + 5).
|
|
//! - [`openplc`] loads the program into the running instance (sub-task 2).
|
|
//! - [`provision_and_test`] composes them with a hard deadline and guaranteed
|
|
//! teardown, and runs the ICS probe against the provisioned endpoint.
|
|
|
|
pub mod openplc;
|
|
pub mod provision;
|
|
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
use secrecy::ExposeSecret;
|
|
|
|
use compliance_core::models::dast::{DastFinding, DastScanRun, DastTarget, DastTargetType};
|
|
use compliance_core::models::Finding;
|
|
use compliance_core::PlcRuntimeConfig;
|
|
|
|
use crate::error::ExecError;
|
|
|
|
pub use provision::{DockerSoftPlc, ProvisionedRuntime, SoftPlc};
|
|
|
|
/// The result of a DAST scan against a provisioned web endpoint.
|
|
#[derive(Debug)]
|
|
pub struct DastRunResult {
|
|
/// The scan-run record (linked to the onboarded target).
|
|
pub scan_run: DastScanRun,
|
|
/// The DAST findings.
|
|
pub findings: Vec<DastFinding>,
|
|
}
|
|
|
|
/// Everything a provision-and-test run produced: the ICS-probe findings plus, if
|
|
/// it ran, the DAST scan of the provisioned web endpoint. The caller persists
|
|
/// both — keeping this a plain data return means the whole run is portable to a
|
|
/// remote execution backend that just hands the results back.
|
|
#[derive(Debug, Default)]
|
|
pub struct ProvisionOutcome {
|
|
/// ICS-probe findings from the provisioned Modbus endpoint.
|
|
pub findings: Vec<Finding>,
|
|
/// DAST scan of the provisioned web endpoint, if it ran.
|
|
pub dast: Option<DastRunResult>,
|
|
}
|
|
|
|
/// A control-logic program ready to load into a soft-PLC: the source text plus a
|
|
/// cosmetic file name (OpenPLC re-stores it under its own name).
|
|
#[derive(Debug, Clone)]
|
|
pub struct PlcProgram {
|
|
/// The original file name (for the upload form; OpenPLC renames on storage).
|
|
pub file_name: String,
|
|
/// The program source — Structured Text or PLCopen XML.
|
|
pub source: String,
|
|
}
|
|
|
|
/// A cookie-aware HTTP client for the OpenPLC web UI. A fresh client per scan
|
|
/// isolates the OpenPLC session (its Flask login cookie) from every other scan.
|
|
pub fn http_client() -> Result<reqwest::Client, ExecError> {
|
|
reqwest::Client::builder()
|
|
.cookie_store(true)
|
|
.timeout(Duration::from_secs(30))
|
|
.build()
|
|
.map_err(ExecError::Http)
|
|
}
|
|
|
|
/// Pick the control-logic program to run from an ingested PLC source tree.
|
|
///
|
|
/// OpenPLC runs one program, so we choose the best single candidate: a complete
|
|
/// Structured Text program (one carrying a `CONFIGURATION` block) is ideal;
|
|
/// failing that the largest ST file; failing that a PLCopen XML export. Returns
|
|
/// `None` when the tree holds no loadable control logic.
|
|
pub fn extract_program(root: &Path) -> Option<PlcProgram> {
|
|
let mut st: Vec<(String, String)> = Vec::new();
|
|
let mut xml: Vec<(String, String)> = Vec::new();
|
|
for entry in walkdir::WalkDir::new(root)
|
|
.into_iter()
|
|
.filter_map(Result::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 name = path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("program")
|
|
.to_string();
|
|
if is_st {
|
|
st.push((name, content));
|
|
} else if looks_like_plcopen(&content) {
|
|
xml.push((name, content));
|
|
}
|
|
}
|
|
|
|
if let Some((name, source)) = st.iter().find(|(_, c)| has_configuration(c)) {
|
|
return Some(PlcProgram {
|
|
file_name: name.clone(),
|
|
source: source.clone(),
|
|
});
|
|
}
|
|
if let Some((name, source)) = st.iter().max_by_key(|(_, c)| c.len()) {
|
|
return Some(PlcProgram {
|
|
file_name: name.clone(),
|
|
source: source.clone(),
|
|
});
|
|
}
|
|
xml.into_iter()
|
|
.max_by_key(|(_, c)| c.len())
|
|
.map(|(file_name, source)| PlcProgram { file_name, source })
|
|
}
|
|
|
|
/// Whether an ST source is a complete, runnable program (has a `CONFIGURATION`).
|
|
fn has_configuration(source: &str) -> bool {
|
|
source.to_ascii_uppercase().contains("CONFIGURATION")
|
|
}
|
|
|
|
/// Whether an XML file looks like a PLCopen project export.
|
|
fn looks_like_plcopen(source: &str) -> bool {
|
|
let lower = source.to_ascii_lowercase();
|
|
lower.contains("<project") || lower.contains("plcopen")
|
|
}
|
|
|
|
/// Provision an ephemeral soft-PLC, load `program`, start it, probe it over
|
|
/// industrial protocols, and tear it down. Returns the ICS-probe findings.
|
|
///
|
|
/// Teardown is guaranteed: the load/probe work runs under a hard deadline
|
|
/// (`max_lifetime_secs`) and the instance is removed afterwards on every path —
|
|
/// success, error, or deadline expiry.
|
|
pub async fn provision_and_test<P: SoftPlc>(
|
|
provisioner: &P,
|
|
http: &reqwest::Client,
|
|
cfg: &PlcRuntimeConfig,
|
|
program: &PlcProgram,
|
|
target_id: &str,
|
|
) -> Result<ProvisionOutcome, ExecError> {
|
|
let handle = provisioner.provision(target_id).await?;
|
|
tracing::info!(
|
|
target_id,
|
|
instance = %handle.name,
|
|
modbus = %handle.modbus_endpoint,
|
|
"provisioned ephemeral soft-PLC"
|
|
);
|
|
|
|
let deadline = Duration::from_secs(cfg.max_lifetime_secs);
|
|
let result = tokio::time::timeout(
|
|
deadline,
|
|
run_dynamic_test(http, cfg, program, target_id, &handle),
|
|
)
|
|
.await;
|
|
|
|
// Guaranteed teardown — runs on success, error, and deadline expiry. The
|
|
// inner future is panic-free (the workspace lint bans unwrap/expect), so no
|
|
// unwind can skip this; a container leaked by an agent *crash* is swept by
|
|
// the next run's stale reaper.
|
|
provisioner.teardown(&handle).await;
|
|
|
|
match result {
|
|
Ok(inner) => inner,
|
|
Err(_) => {
|
|
tracing::warn!(
|
|
target_id,
|
|
instance = %handle.name,
|
|
"provision-and-test hit the lifetime deadline; torn down"
|
|
);
|
|
Ok(ProvisionOutcome::default())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The load → start → probe → DAST body, run under the caller's deadline.
|
|
async fn run_dynamic_test(
|
|
http: &reqwest::Client,
|
|
cfg: &PlcRuntimeConfig,
|
|
program: &PlcProgram,
|
|
target_id: &str,
|
|
handle: &ProvisionedRuntime,
|
|
) -> Result<ProvisionOutcome, ExecError> {
|
|
let ready_budget = Duration::from_secs((cfg.max_lifetime_secs / 3).clamp(10, 60));
|
|
openplc::wait_ready(http, &handle.webvisu_url, ready_budget).await?;
|
|
|
|
let compile_budget = Duration::from_secs((cfg.max_lifetime_secs / 2).clamp(20, 120));
|
|
openplc::load_and_start(
|
|
http,
|
|
&handle.webvisu_url,
|
|
&cfg.openplc_user,
|
|
cfg.openplc_password.expose_secret(),
|
|
program,
|
|
compile_budget,
|
|
)
|
|
.await?;
|
|
|
|
// Give the runtime a moment to open the Modbus/TCP server before probing.
|
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
|
|
let probe_budget = Duration::from_secs(5);
|
|
let findings = crate::ics::probe_target(&handle.modbus_endpoint, target_id, probe_budget).await;
|
|
tracing::info!(
|
|
target_id,
|
|
instance = %handle.name,
|
|
found = findings.len(),
|
|
"provision-and-test probe complete"
|
|
);
|
|
|
|
// DAST the provisioned web endpoint (independently bounded so it can't eat
|
|
// the whole lifetime). On the OpenPLC substrate this is OpenPLC's own web UI,
|
|
// not a customer HMI — the CODESYS-runtime follow-up raises the fidelity —
|
|
// but it proves the deploy→run→probe→DAST loop end to end.
|
|
let dast_budget = Duration::from_secs((cfg.max_lifetime_secs / 2).clamp(20, 120));
|
|
let dast = match tokio::time::timeout(dast_budget, run_webvisu_dast(handle, target_id)).await {
|
|
Ok(d) => d,
|
|
Err(_) => {
|
|
tracing::warn!(target_id, instance = %handle.name, "provision-and-test DAST timed out");
|
|
None
|
|
}
|
|
};
|
|
|
|
Ok(ProvisionOutcome { findings, dast })
|
|
}
|
|
|
|
/// Run a bounded DAST scan against the provisioned web endpoint and tag the
|
|
/// results with our target id. Best-effort — a DAST failure never fails the run.
|
|
async fn run_webvisu_dast(handle: &ProvisionedRuntime, target_id: &str) -> Option<DastRunResult> {
|
|
let mut dt = DastTarget::new(
|
|
"provisioned-webvisu".to_string(),
|
|
handle.webvisu_url.clone(),
|
|
DastTargetType::WebApp,
|
|
);
|
|
dt.repo_id = Some(target_id.to_string());
|
|
dt.max_crawl_depth = 2; // shallow — the instance is ephemeral
|
|
|
|
let orchestrator = compliance_dast::DastOrchestrator::new(100);
|
|
match orchestrator.run_scan(&dt, Vec::new()).await {
|
|
Ok((mut scan_run, mut findings)) => {
|
|
scan_run.target_id = target_id.to_string();
|
|
for f in &mut findings {
|
|
f.target_id = target_id.to_string();
|
|
}
|
|
tracing::info!(
|
|
target_id,
|
|
instance = %handle.name,
|
|
dast_findings = findings.len(),
|
|
"provision-and-test DAST complete"
|
|
);
|
|
Some(DastRunResult { scan_run, findings })
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(target_id, instance = %handle.name, error = %e, "provision-and-test DAST failed");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
/// A scratch dir removed on drop.
|
|
struct Scratch(std::path::PathBuf);
|
|
impl Scratch {
|
|
fn new() -> Self {
|
|
let p = std::env::temp_dir().join(format!("cs-plc-rt-{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(&p).expect("mkdir");
|
|
Self(p)
|
|
}
|
|
}
|
|
impl Drop for Scratch {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn extract_prefers_a_complete_st_program() {
|
|
let s = Scratch::new();
|
|
std::fs::write(s.0.join("fragment.st"), "PROGRAM P\nEND_PROGRAM\n").expect("w");
|
|
std::fs::write(
|
|
s.0.join("full.st"),
|
|
"PROGRAM Main\nEND_PROGRAM\nCONFIGURATION Config0\n RESOURCE R\nEND_CONFIGURATION\n",
|
|
)
|
|
.expect("w");
|
|
let prog = extract_program(&s.0).expect("program");
|
|
assert_eq!(prog.file_name, "full.st");
|
|
assert!(prog.source.contains("CONFIGURATION"));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_falls_back_to_largest_st_then_plcopen() {
|
|
let s = Scratch::new();
|
|
std::fs::write(s.0.join("small.st"), "PROGRAM A\nEND_PROGRAM\n").expect("w");
|
|
std::fs::write(
|
|
s.0.join("big.st"),
|
|
"PROGRAM B\nVAR x : INT; y : INT; z : INT; END_VAR\nEND_PROGRAM\n",
|
|
)
|
|
.expect("w");
|
|
let prog = extract_program(&s.0).expect("program");
|
|
assert_eq!(
|
|
prog.file_name, "big.st",
|
|
"largest ST wins when none complete"
|
|
);
|
|
|
|
// Only a PLCopen XML present.
|
|
let s2 = Scratch::new();
|
|
std::fs::write(
|
|
s2.0.join("proj.xml"),
|
|
"<?xml version='1.0'?><project xmlns='http://www.plcopen.org/xml/tc6_0201'><pou/></project>",
|
|
)
|
|
.expect("w");
|
|
let prog2 = extract_program(&s2.0).expect("program");
|
|
assert_eq!(prog2.file_name, "proj.xml");
|
|
}
|
|
|
|
#[test]
|
|
fn extract_returns_none_without_control_logic() {
|
|
let s = Scratch::new();
|
|
std::fs::write(s.0.join("readme.md"), "# not a plc program").expect("w");
|
|
std::fs::write(s.0.join("data.xml"), "<config><db/></config>").expect("w");
|
|
assert!(extract_program(&s.0).is_none());
|
|
}
|
|
|
|
/// A fake provisioner recording provision/teardown calls, for lifecycle tests.
|
|
struct FakeSoftPlc {
|
|
provisions: Arc<AtomicUsize>,
|
|
teardowns: Arc<AtomicUsize>,
|
|
fail_provision: bool,
|
|
}
|
|
|
|
impl SoftPlc for FakeSoftPlc {
|
|
async fn provision(&self, _target_id: &str) -> Result<ProvisionedRuntime, ExecError> {
|
|
self.provisions.fetch_add(1, Ordering::SeqCst);
|
|
if self.fail_provision {
|
|
return Err(ExecError::Other("provision failed".into()));
|
|
}
|
|
// Unreachable address so run_dynamic_test blocks on readiness until the
|
|
// deadline fires — exercising the teardown-on-deadline path.
|
|
Ok(ProvisionedRuntime {
|
|
name: "fake-plc".into(),
|
|
modbus_endpoint: "fake-plc:502".into(),
|
|
webvisu_url: "http://fake-plc.invalid:8080".into(),
|
|
})
|
|
}
|
|
async fn teardown(&self, _handle: &ProvisionedRuntime) {
|
|
self.teardowns.fetch_add(1, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
fn short_cfg() -> PlcRuntimeConfig {
|
|
PlcRuntimeConfig {
|
|
enabled: true,
|
|
max_lifetime_secs: 1, // keep the deadline path fast
|
|
..PlcRuntimeConfig::default()
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn teardown_runs_even_when_the_test_never_completes() {
|
|
let provisions = Arc::new(AtomicUsize::new(0));
|
|
let teardowns = Arc::new(AtomicUsize::new(0));
|
|
let fake = FakeSoftPlc {
|
|
provisions: provisions.clone(),
|
|
teardowns: teardowns.clone(),
|
|
fail_provision: false,
|
|
};
|
|
let http = http_client().expect("client");
|
|
let prog = PlcProgram {
|
|
file_name: "p.st".into(),
|
|
source: "PROGRAM P\nEND_PROGRAM\n".into(),
|
|
};
|
|
let out = provision_and_test(&fake, &http, &short_cfg(), &prog, "t1")
|
|
.await
|
|
.expect("ok on deadline");
|
|
assert!(out.findings.is_empty(), "deadline path yields no findings");
|
|
assert!(out.dast.is_none(), "deadline path runs no DAST");
|
|
assert_eq!(provisions.load(Ordering::SeqCst), 1);
|
|
assert_eq!(teardowns.load(Ordering::SeqCst), 1, "teardown must run");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provision_failure_propagates_and_skips_teardown() {
|
|
let provisions = Arc::new(AtomicUsize::new(0));
|
|
let teardowns = Arc::new(AtomicUsize::new(0));
|
|
let fake = FakeSoftPlc {
|
|
provisions: provisions.clone(),
|
|
teardowns: teardowns.clone(),
|
|
fail_provision: true,
|
|
};
|
|
let http = http_client().expect("client");
|
|
let prog = PlcProgram {
|
|
file_name: "p.st".into(),
|
|
source: String::new(),
|
|
};
|
|
let err = provision_and_test(&fake, &http, &short_cfg(), &prog, "t1").await;
|
|
assert!(err.is_err(), "provision failure propagates");
|
|
assert_eq!(provisions.load(Ordering::SeqCst), 1);
|
|
assert_eq!(
|
|
teardowns.load(Ordering::SeqCst),
|
|
0,
|
|
"nothing to tear down when provisioning failed"
|
|
);
|
|
}
|
|
}
|