//! 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, } /// 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, /// DAST scan of the provisioned web endpoint, if it ran. pub dast: Option, } /// 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::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 { 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("( provisioner: &P, http: &reqwest::Client, cfg: &PlcRuntimeConfig, program: &PlcProgram, target_id: &str, ) -> Result { 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 { 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 { 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"), "", ) .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"), "").expect("w"); assert!(extract_program(&s.0).is_none()); } /// A fake provisioner recording provision/teardown calls, for lifecycle tests. struct FakeSoftPlc { provisions: Arc, teardowns: Arc, fail_provision: bool, } impl SoftPlc for FakeSoftPlc { async fn provision(&self, _target_id: &str) -> Result { 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" ); } }