diff --git a/.env.example b/.env.example index f5e9f4f..88b8b83 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,19 @@ SCAN_SCHEDULE=0 0 */6 * * * CVE_MONITOR_SCHEDULE=0 0 0 * * * GIT_CLONE_BASE_PATH=/tmp/compliance-scanner/repos +# Dynamic PLC testing — ephemeral soft-PLC provisioning (#183). Off unless +# enabled; requires the agent container to have Docker access (socket mount). +# When on, a PLC/SPS target with control logic but no reachable device gets its +# logic instantiated on a throwaway OpenPLC, probed, then torn down. +PLC_RUNTIME_ENABLED=0 +PLC_RUNTIME_IMAGE=registry.meghsakha.com/openplc:latest +PLC_RUNTIME_NETWORK=certifai +PLC_RUNTIME_MEMORY=512m +PLC_RUNTIME_CPUS=0.5 +PLC_RUNTIME_MAX_LIFETIME_SECS=180 +PLC_RUNTIME_OPENPLC_USER=openplc +PLC_RUNTIME_OPENPLC_PASSWORD=openplc + # Dashboard DASHBOARD_PORT=8080 AGENT_API_URL=http://localhost:3001 diff --git a/Cargo.toml b/Cargo.toml index 0e38bbd..01c6a2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } chrono = { version = "0.4", features = ["serde"] } mongodb = { version = "3", features = ["rustls-tls", "compat-3-0-0"] } -reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart"], default-features = false } +reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart", "cookies"], default-features = false } thiserror = "2" sha2 = "0.10" hex = "0.4" diff --git a/compliance-agent/src/config.rs b/compliance-agent/src/config.rs index a754975..fc7d2d2 100644 --- a/compliance-agent/src/config.rs +++ b/compliance-agent/src/config.rs @@ -1,3 +1,4 @@ +use compliance_core::config::PlcRuntimeConfig; use compliance_core::AgentConfig; use secrecy::SecretString; @@ -63,5 +64,28 @@ pub fn load_config() -> Result { pentest_imap_password: env_secret_opt("PENTEST_IMAP_PASSWORD"), admin_api_token: env_secret_opt("ADMIN_API_TOKEN"), tenant_registry_url: env_var_opt("TENANT_REGISTRY_URL"), + plc_runtime: load_plc_runtime_config(), }) } + +/// Build the ephemeral soft-PLC provisioning config from the environment, +/// falling back to [`PlcRuntimeConfig::default`] for any unset knob. Disabled +/// unless `PLC_RUNTIME_ENABLED` is truthy — it requires Docker access. +fn load_plc_runtime_config() -> PlcRuntimeConfig { + let d = PlcRuntimeConfig::default(); + PlcRuntimeConfig { + enabled: env_var_opt("PLC_RUNTIME_ENABLED") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(d.enabled), + image: env_var_opt("PLC_RUNTIME_IMAGE").unwrap_or(d.image), + network: env_var_opt("PLC_RUNTIME_NETWORK").unwrap_or(d.network), + memory: env_var_opt("PLC_RUNTIME_MEMORY").unwrap_or(d.memory), + cpus: env_var_opt("PLC_RUNTIME_CPUS").unwrap_or(d.cpus), + max_lifetime_secs: env_var_opt("PLC_RUNTIME_MAX_LIFETIME_SECS") + .and_then(|v| v.parse().ok()) + .unwrap_or(d.max_lifetime_secs), + openplc_user: env_var_opt("PLC_RUNTIME_OPENPLC_USER").unwrap_or(d.openplc_user), + openplc_password: env_secret_opt("PLC_RUNTIME_OPENPLC_PASSWORD") + .unwrap_or(d.openplc_password), + } +} diff --git a/compliance-agent/src/pentest/cleanup.rs b/compliance-agent/src/pentest/cleanup.rs index dce1c61..c58c6e9 100644 --- a/compliance-agent/src/pentest/cleanup.rs +++ b/compliance-agent/src/pentest/cleanup.rs @@ -342,6 +342,7 @@ mod tests { pentest_imap_password: None, admin_api_token: None, tenant_registry_url: None, + plc_runtime: compliance_core::PlcRuntimeConfig::default(), } } diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index c48bc18..e413705 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -404,6 +404,21 @@ impl PipelineOrchestrator { let ics = plan.has(ScanType::IcsProbe); if plc { new_count += self.run_plc_scan(target, &target_id, scan_run_id).await?; + // Provision-and-test (#183): with the control logic but no reachable + // device, instantiate it on an ephemeral soft-PLC and probe that + // instead of the customer's OT network. Opt-in (needs Docker) and only + // when there is no live URL to probe directly. Never fails the scan. + if self.config.plc_runtime.enabled && target.live_url().is_none() { + match self + .run_provisioned_plc_test(target, &target_id, scan_run_id) + .await + { + Ok(n) => new_count += n, + Err(e) => { + tracing::warn!(target_id = %target_id, error = %e, "provision-and-test failed") + } + } + } } if ics { new_count += self.run_ics_probe(target, &target_id, scan_run_id).await?; @@ -540,6 +555,77 @@ impl PipelineOrchestrator { Ok(new_count) } + /// Provision-and-test (#183): instantiate the target's control logic on an + /// ephemeral soft-PLC (OpenPLC), start it, probe the provisioned Modbus + /// endpoint, and tear the instance down. Used when a PLC/SPS target has the + /// control logic but no reachable live device to probe directly. Guarded by + /// `plc_runtime.enabled` (needs Docker); persists the same [`ScanType::IcsProbe`] + /// findings as a live probe. + async fn run_provisioned_plc_test( + &self, + target: &OnboardedTarget, + target_id: &str, + scan_run_id: &str, + ) -> Result { + self.update_phase(scan_run_id, "plc_provision").await; + + // Locate a loadable control-logic program among the PLC-source artifacts + // (same selection as the static PLC scan: dedicated PLC projects plus code + // artifacts holding PLCopen XML / ST exports). + let ctx = crate::ingest::IngestContext::from_config(&self.config, target_id); + let ingest_set = crate::ingest::ingest_all(target, &ctx)?; + let program = target + .artifacts + .iter() + .filter(|a| { + matches!( + a.kind, + ArtifactKind::PlcProject | ArtifactKind::GitRepo | ArtifactKind::SourceArchive + ) + }) + .find_map(|a| { + let path = ingest_set.get(&a.id).and_then(|ia| ia.working_path.clone())?; + crate::pipeline::plc::runtime::extract_program(&path) + }); + let Some(program) = program else { + tracing::info!(target_id, "provision-and-test: no loadable control-logic program"); + return Ok(0); + }; + + let http = crate::pipeline::plc::runtime::http_client()?; + let provisioner = + crate::pipeline::plc::runtime::DockerSoftPlc::new(self.config.plc_runtime.clone()); + let findings = crate::pipeline::plc::runtime::provision_and_test( + &provisioner, + &http, + &self.config.plc_runtime, + &program, + target_id, + ) + .await?; + tracing::info!( + target_id, + found = findings.len(), + "provision-and-test 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) + } + /// Probe a running PLC/SPS device over industrial protocols (Modbus/TCP, …) /// and persist findings for exposed / unauthenticated control access. The /// probe is read-only; it targets the Modbus port of the target's live URL. diff --git a/compliance-agent/src/pipeline/plc/mod.rs b/compliance-agent/src/pipeline/plc/mod.rs index e58e766..c09dda9 100644 --- a/compliance-agent/src/pipeline/plc/mod.rs +++ b/compliance-agent/src/pipeline/plc/mod.rs @@ -9,6 +9,7 @@ pub mod lexer; pub mod parser; pub mod plcopen; pub mod rules; +pub mod runtime; pub mod sbom; use std::path::Path; diff --git a/compliance-agent/src/pipeline/plc/runtime/mod.rs b/compliance-agent/src/pipeline/plc/runtime/mod.rs new file mode 100644 index 0000000..f7bf07c --- /dev/null +++ b/compliance-agent/src/pipeline/plc/runtime/mod.rs @@ -0,0 +1,350 @@ +//! 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::Finding; +use compliance_core::PlcRuntimeConfig; + +use crate::error::AgentError; + +pub use provision::{DockerSoftPlc, ProvisionedRuntime, SoftPlc}; + +/// 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(AgentError::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, AgentError> { + 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(Vec::new()) + } + } +} + +/// The load → start → probe 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, AgentError> { + 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::pipeline::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" + ); + Ok(findings) +} + +#[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(AgentError::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.is_empty(), "deadline path yields no findings"); + 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" + ); + } +} diff --git a/compliance-agent/src/pipeline/plc/runtime/openplc.rs b/compliance-agent/src/pipeline/plc/runtime/openplc.rs new file mode 100644 index 0000000..a6536e7 --- /dev/null +++ b/compliance-agent/src/pipeline/plc/runtime/openplc.rs @@ -0,0 +1,254 @@ +//! Loading a control-logic program into a provisioned OpenPLC (#183, sub-task 2). +//! +//! Drives the OpenPLC v3 web UI over HTTP to turn a static control-logic artifact +//! into a *running* PLC: log in, upload the program, save it, compile it (MatIEC), +//! and start the runtime — at which point OpenPLC opens its Modbus/TCP server on +//! 502 and the ICS probe has something to talk to. The endpoint sequence mirrors +//! the OpenPLC web UI: `POST /login` → `POST /upload-program` (which hands back a +//! server-assigned `prog_file`) → `POST /upload-program-action` → +//! `GET /compile-program?file=` → `GET /start_plc`. + +use std::time::Duration; + +use crate::error::AgentError; + +use super::PlcProgram; + +/// Default OpenPLC program name/description recorded in its UI. +const PROG_NAME: &str = "certifai-provisioned"; +const PROG_DESCR: &str = "Uploaded by the Certifai provision-and-test scan"; + +/// Poll interval while waiting for readiness / compilation. +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +/// Wait until the OpenPLC web UI answers (any non-5xx reply to `/login`), or the +/// budget elapses. A freshly-started container needs a few seconds to boot. +pub async fn wait_ready( + http: &reqwest::Client, + base_url: &str, + budget: Duration, +) -> Result<(), AgentError> { + let login = format!("{base_url}/login"); + let outcome = tokio::time::timeout(budget, async { + loop { + if let Ok(resp) = http.get(&login).send().await { + if !resp.status().is_server_error() { + return; + } + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }) + .await; + outcome.map_err(|_| AgentError::Other(format!("OpenPLC at {base_url} did not become ready"))) +} + +/// Log in, upload the program, compile it, and start the runtime. On success the +/// OpenPLC Modbus/TCP server is listening on 502. +pub async fn load_and_start( + http: &reqwest::Client, + base_url: &str, + user: &str, + password: &str, + program: &PlcProgram, + compile_budget: Duration, +) -> Result<(), AgentError> { + login(http, base_url, user, password).await?; + let prog_file = upload_program(http, base_url, program).await?; + save_program(http, base_url, &prog_file).await?; + compile(http, base_url, &prog_file, compile_budget).await?; + start(http, base_url).await?; + Ok(()) +} + +/// `POST /login` — establishes the session cookie (the client must have a cookie +/// store; see the provision-and-test entry point). +async fn login( + http: &reqwest::Client, + base_url: &str, + user: &str, + password: &str, +) -> Result<(), AgentError> { + let resp = http + .post(format!("{base_url}/login")) + .form(&[("username", user), ("password", password)]) + .send() + .await?; + if resp.status().is_server_error() { + return Err(AgentError::Other(format!( + "OpenPLC login failed: HTTP {}", + resp.status() + ))); + } + Ok(()) +} + +/// `POST /upload-program` (multipart `file`) — OpenPLC stores the program under a +/// server-assigned name and returns it in a hidden `prog_file` form field, which +/// we parse out for the follow-up save/compile steps. +async fn upload_program( + http: &reqwest::Client, + base_url: &str, + program: &PlcProgram, +) -> Result { + let part = reqwest::multipart::Part::text(program.source.clone()) + .file_name(program.file_name.clone()) + .mime_str("application/octet-stream")?; + let form = reqwest::multipart::Form::new().part("file", part); + let resp = http + .post(format!("{base_url}/upload-program")) + .multipart(form) + .send() + .await?; + let html = resp.text().await?; + parse_prog_file(&html).ok_or_else(|| { + AgentError::Other("OpenPLC upload did not return a prog_file handle".to_string()) + }) +} + +/// `POST /upload-program-action` — records the uploaded program in OpenPLC's +/// program list. `epoch_time` must be close to the server's clock (OpenPLC +/// rejects stale timestamps), so we send the current time. +async fn save_program( + http: &reqwest::Client, + base_url: &str, + prog_file: &str, +) -> Result<(), AgentError> { + let epoch = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + .to_string(); + let resp = http + .post(format!("{base_url}/upload-program-action")) + .form(&[ + ("prog_name", PROG_NAME), + ("prog_descr", PROG_DESCR), + ("prog_file", prog_file), + ("epoch_time", &epoch), + ]) + .send() + .await?; + if resp.status().is_server_error() { + return Err(AgentError::Other(format!( + "OpenPLC save-program failed: HTTP {}", + resp.status() + ))); + } + Ok(()) +} + +/// `GET /compile-program?file=` then poll `/compilation-logs` until +/// MatIEC reports it finished (or the budget elapses). Errors if compilation +/// finishes with errors — a program that won't compile can't be started. +async fn compile( + http: &reqwest::Client, + base_url: &str, + prog_file: &str, + budget: Duration, +) -> Result<(), AgentError> { + http.get(format!("{base_url}/compile-program")) + .query(&[("file", prog_file)]) + .send() + .await?; + + let logs_url = format!("{base_url}/compilation-logs"); + let outcome = tokio::time::timeout(budget, async { + loop { + if let Ok(resp) = http.get(&logs_url).send().await { + if let Ok(text) = resp.text().await { + if compilation_finished(&text) { + return !compilation_failed(&text); + } + } + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }) + .await; + match outcome { + Ok(true) => Ok(()), + Ok(false) => Err(AgentError::Other( + "OpenPLC compilation finished with errors".to_string(), + )), + Err(_) => Err(AgentError::Other( + "OpenPLC compilation did not finish in time".to_string(), + )), + } +} + +/// `GET /start_plc` — starts the runtime, opening Modbus/TCP on 502. +async fn start(http: &reqwest::Client, base_url: &str) -> Result<(), AgentError> { + let resp = http.get(format!("{base_url}/start_plc")).send().await?; + if resp.status().is_server_error() { + return Err(AgentError::Other(format!( + "OpenPLC start_plc failed: HTTP {}", + resp.status() + ))); + } + Ok(()) +} + +/// Extract the server-assigned `prog_file` from the `/upload-program` response, +/// which embeds it in a hidden input. Attribute order varies, so accept both +/// `value=… name='prog_file'` and `name='prog_file' … value=…`. +fn parse_prog_file(html: &str) -> Option { + // The OpenPLC template renders `value='.st' id='prog_file' + // name='prog_file'`. Match the value bound to that input, either order. + let value_then_name = + regex::Regex::new(r#"(?is)value=['"]([^'"]+)['"][^>]*name=['"]prog_file['"]"#).ok()?; + if let Some(c) = value_then_name.captures(html) { + return c.get(1).map(|m| m.as_str().to_string()); + } + let name_then_value = + regex::Regex::new(r#"(?is)name=['"]prog_file['"][^>]*value=['"]([^'"]+)['"]"#).ok()?; + name_then_value + .captures(html) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) +} + +/// Whether the MatIEC compilation log shows the build has finished (either way). +fn compilation_finished(log: &str) -> bool { + log.contains("Compilation finished") +} + +/// Whether a finished compilation ended in failure. +fn compilation_failed(log: &str) -> bool { + log.contains("Compilation finished with errors") +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn parses_prog_file_value_then_name() { + let html = "
"; + assert_eq!(parse_prog_file(html), Some("483927.st".to_string())); + } + + #[test] + fn parses_prog_file_name_then_value() { + let html = r#""#; + assert_eq!(parse_prog_file(html), Some("12.st".to_string())); + } + + #[test] + fn parse_prog_file_none_when_absent() { + assert_eq!(parse_prog_file("no form here"), None); + } + + #[test] + fn compilation_predicates() { + assert!(!compilation_finished("Compiling...")); + assert!(compilation_finished( + "...\nCompilation finished successfully!\n" + )); + assert!(compilation_finished("Compilation finished with errors!")); + assert!(compilation_failed("Compilation finished with errors!")); + assert!(!compilation_failed("Compilation finished successfully!")); + } +} diff --git a/compliance-agent/src/pipeline/plc/runtime/provision.rs b/compliance-agent/src/pipeline/plc/runtime/provision.rs new file mode 100644 index 0000000..45c3ad2 --- /dev/null +++ b/compliance-agent/src/pipeline/plc/runtime/provision.rs @@ -0,0 +1,300 @@ +//! Ephemeral soft-PLC container lifecycle (#183, sub-task 1 + 5). +//! +//! Provisions a throwaway OpenPLC container per scan, isolated on the agent's own +//! Docker network with hard resource caps and **no host port exposure**, then +//! guarantees teardown. The container is reachable in-cluster only, by its name +//! (the shared user-defined network's embedded DNS resolves it); it is never +//! published to the host. +//! +//! The `docker` argv is produced by pure functions so provisioning is unit-tested +//! without a Docker daemon — only the thin [`run_docker`] wrapper touches the OS. +//! It requires the agent's runtime to have Docker access (a socket mount), which +//! is why the whole path is gated behind [`PlcRuntimeConfig::enabled`]. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use compliance_core::PlcRuntimeConfig; + +use crate::error::AgentError; + +/// The Modbus/TCP port an OpenPLC instance opens once a program is running. +const MODBUS_PORT: u16 = 502; +/// The OpenPLC web-UI / WebVisu port. +const WEBVISU_PORT: u16 = 8080; + +/// Label key marking a container as an ephemeral PLC runtime we own. +const OWNER_LABEL_KEY: &str = "certifai.ephemeral"; +/// Label value for our ephemeral PLC runtimes. +const OWNER_LABEL_VALUE: &str = "plc-runtime"; + +/// A running ephemeral soft-PLC instance. Reachable in-cluster by `name`. +#[derive(Debug, Clone)] +pub struct ProvisionedRuntime { + /// The container name — also its in-network DNS alias. + pub name: String, + /// `name:502` — the Modbus/TCP endpoint the ICS probe targets. + pub modbus_endpoint: String, + /// `http://name:8080` — the WebVisu / OpenPLC web UI. + pub webvisu_url: String, +} + +/// A source of ephemeral soft-PLC instances. Abstracted so the provision-and-test +/// orchestration is unit-testable with a fake that never touches Docker. +pub trait SoftPlc { + /// Start a fresh instance for a target and return its handle. + fn provision( + &self, + target_id: &str, + ) -> impl std::future::Future> + Send; + + /// Tear an instance down. Best-effort and idempotent — never fails the scan. + fn teardown( + &self, + handle: &ProvisionedRuntime, + ) -> impl std::future::Future + Send; +} + +/// Provisions OpenPLC instances by shelling out to the Docker CLI. +pub struct DockerSoftPlc { + cfg: PlcRuntimeConfig, +} + +impl DockerSoftPlc { + /// Build a provisioner from the PLC-runtime config. + pub fn new(cfg: PlcRuntimeConfig) -> Self { + Self { cfg } + } +} + +impl SoftPlc for DockerSoftPlc { + async fn provision(&self, target_id: &str) -> Result { + // Best-effort sweep of any container leaked by a crashed earlier run + // before we add another. Only removes instances past their max lifetime, + // so it can never disturb a concurrent run. + reap_stale(&self.cfg, now_epoch()).await; + + let name = instance_name(target_id, now_epoch(), &random_suffix()); + let args = run_args(&self.cfg, &name, target_id); + let out = run_docker(&args).await?; + if !out.status.success() { + return Err(AgentError::Other(format!( + "docker run for soft-PLC {name} failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ))); + } + Ok(ProvisionedRuntime { + modbus_endpoint: format!("{name}:{MODBUS_PORT}"), + webvisu_url: format!("http://{name}:{WEBVISU_PORT}"), + name, + }) + } + + async fn teardown(&self, handle: &ProvisionedRuntime) { + match run_docker(&rm_args(&handle.name)).await { + Ok(out) if out.status.success() => { + tracing::info!(instance = %handle.name, "soft-PLC instance torn down"); + } + Ok(out) => tracing::warn!( + instance = %handle.name, + "soft-PLC teardown non-zero exit: {}", + String::from_utf8_lossy(&out.stderr).trim() + ), + Err(e) => tracing::warn!(instance = %handle.name, error = %e, "soft-PLC teardown failed"), + } + } +} + +/// Seconds since the Unix epoch (0 if the clock is before 1970, which never +/// happens in practice). +fn now_epoch() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// A short random, docker-name-safe suffix. +fn random_suffix() -> String { + uuid::Uuid::new_v4().simple().to_string() +} + +/// A unique, docker-safe container name that encodes the creation epoch (for the +/// stale reaper) and the target it belongs to. Shape: +/// `certifai-plc---`. +fn instance_name(target_id: &str, epoch: u64, rand: &str) -> String { + let short: String = target_id + .chars() + .filter(char::is_ascii_alphanumeric) + .take(12) + .collect(); + let rand: String = rand + .chars() + .filter(char::is_ascii_alphanumeric) + .take(6) + .collect(); + format!("certifai-plc-{epoch}-{short}-{rand}") +} + +/// The creation epoch encoded in an instance name, if it is one of ours. +fn parse_epoch(name: &str) -> Option { + name.strip_prefix("certifai-plc-")? + .split('-') + .next()? + .parse() + .ok() +} + +/// The `docker run` argv for an ephemeral soft-PLC: detached, joined to the +/// agent's network, resource-capped, hardened, labelled for reaping, and — by +/// omitting any `-p` — never published to the host. +fn run_args(cfg: &PlcRuntimeConfig, name: &str, target_id: &str) -> Vec { + vec![ + "run".into(), + "-d".into(), + "--name".into(), + name.into(), + "--network".into(), + cfg.network.clone(), + "--memory".into(), + cfg.memory.clone(), + "--cpus".into(), + cfg.cpus.clone(), + "--pids-limit".into(), + "512".into(), + "--security-opt".into(), + "no-new-privileges".into(), + "--stop-timeout".into(), + "5".into(), + "--label".into(), + format!("{OWNER_LABEL_KEY}={OWNER_LABEL_VALUE}"), + "--label".into(), + format!("certifai.target={target_id}"), + cfg.image.clone(), + ] +} + +/// The `docker rm -f` argv that stops and removes an instance. +fn rm_args(name: &str) -> Vec { + vec!["rm".into(), "-f".into(), name.into()] +} + +/// The `docker ps` argv listing the names of every ephemeral PLC container we own. +fn reap_list_args() -> Vec { + vec![ + "ps".into(), + "-a".into(), + "--filter".into(), + format!("label={OWNER_LABEL_KEY}={OWNER_LABEL_VALUE}"), + "--format".into(), + "{{.Names}}".into(), + ] +} + +/// Remove any ephemeral PLC container older than twice the configured max +/// lifetime — i.e. one a crashed run leaked. The generous threshold guarantees a +/// container from a *live* run (still within its own deadline) is never swept. +/// Best-effort: any Docker error (e.g. no daemon) is ignored. +async fn reap_stale(cfg: &PlcRuntimeConfig, now: u64) { + let cutoff = cfg.max_lifetime_secs.saturating_mul(2); + let Ok(out) = run_docker(&reap_list_args()).await else { + return; + }; + if !out.status.success() { + return; + } + let names = String::from_utf8_lossy(&out.stdout); + for name in names.lines().map(str::trim).filter(|n| !n.is_empty()) { + let Some(epoch) = parse_epoch(name) else { + continue; + }; + if now.saturating_sub(epoch) > cutoff { + tracing::warn!(instance = %name, "reaping stale soft-PLC instance"); + let _ = run_docker(&rm_args(name)).await; + } + } +} + +/// Run a `docker` subcommand, capturing its output. +async fn run_docker(args: &[String]) -> Result { + tokio::process::Command::new("docker") + .args(args) + .output() + .await + .map_err(AgentError::Io) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + + fn cfg() -> PlcRuntimeConfig { + PlcRuntimeConfig { + enabled: true, + image: "registry.example.com/openplc:latest".into(), + network: "certifai".into(), + memory: "512m".into(), + cpus: "0.5".into(), + max_lifetime_secs: 180, + ..PlcRuntimeConfig::default() + } + } + + #[test] + fn instance_name_is_unique_docker_safe_and_reaper_parseable() { + let a = instance_name("64f0aabbccddeeff00112233", 1_700_000_000, "abcdef123456"); + assert_eq!(a, "certifai-plc-1700000000-64f0aabbccdd-abcdef"); + assert_eq!(parse_epoch(&a), Some(1_700_000_000)); + // Docker names: only [A-Za-z0-9_.-]. + assert!(a + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))); + // A different random suffix yields a different name for the same target. + let b = instance_name("64f0aabbccddeeff00112233", 1_700_000_000, "zzzzzz999999"); + assert_ne!(a, b); + } + + #[test] + fn parse_epoch_rejects_foreign_names() { + assert_eq!(parse_epoch("some-other-container"), None); + assert_eq!(parse_epoch("certifai-plc-notanumber-x"), None); + } + + #[test] + fn run_args_cap_resources_harden_label_and_never_publish_a_port() { + let args = run_args(&cfg(), "certifai-plc-1-t-r", "target-123"); + // No host port publishing. + assert!(!args.iter().any(|a| a == "-p" || a == "--publish")); + // Detached. + assert!(args.contains(&"-d".to_string())); + // Joined to the agent's own network. + let net = args.iter().position(|a| a == "--network").expect("network"); + assert_eq!(args[net + 1], "certifai"); + // Resource caps. + let mem = args.iter().position(|a| a == "--memory").expect("memory"); + assert_eq!(args[mem + 1], "512m"); + let cpu = args.iter().position(|a| a == "--cpus").expect("cpus"); + assert_eq!(args[cpu + 1], "0.5"); + assert!(args.iter().any(|a| a == "--pids-limit")); + // Hardening. + let so = args.iter().position(|a| a == "--security-opt").expect("secopt"); + assert_eq!(args[so + 1], "no-new-privileges"); + // Ownership + target labels for reaping / attribution. + assert!(args.contains(&"certifai.ephemeral=plc-runtime".to_string())); + assert!(args.contains(&"certifai.target=target-123".to_string())); + // Image is last. + assert_eq!(args.last().map(String::as_str), Some("registry.example.com/openplc:latest")); + } + + #[test] + fn rm_args_force_remove() { + assert_eq!(rm_args("x"), vec!["rm", "-f", "x"]); + } + + #[test] + fn reap_list_filters_by_owner_label() { + let args = reap_list_args(); + assert!(args.contains(&"label=certifai.ephemeral=plc-runtime".to_string())); + assert!(args.contains(&"{{.Names}}".to_string())); + } +} diff --git a/compliance-agent/tests/common/mod.rs b/compliance-agent/tests/common/mod.rs index 9d0934e..fce6fa2 100644 --- a/compliance-agent/tests/common/mod.rs +++ b/compliance-agent/tests/common/mod.rs @@ -70,6 +70,7 @@ impl TestServer { pentest_imap_password: None, admin_api_token: None, tenant_registry_url: None, + plc_runtime: compliance_core::PlcRuntimeConfig::default(), }; let agent = ComplianceAgent::new(config, db_pool); diff --git a/compliance-core/src/config.rs b/compliance-core/src/config.rs index db88fef..0686533 100644 --- a/compliance-core/src/config.rs +++ b/compliance-core/src/config.rs @@ -49,6 +49,57 @@ pub struct AgentConfig { /// of tenants to iterate. When `None` or unreachable, scheduler /// falls back to `SCHEDULER_TENANT_IDS` env (M7.2-C). pub tenant_registry_url: Option, + /// Ephemeral soft-PLC provisioning for dynamic PLC testing (#183). Off by + /// default: it needs Docker access in the agent's runtime, which is a + /// deployment opt-in. + pub plc_runtime: PlcRuntimeConfig, +} + +/// Configuration for the ephemeral soft-PLC "provision-and-test" path (#183). +/// +/// When a PLC/SPS target ships control logic but no reachable live device, the +/// agent can instantiate that logic itself: spin up a throwaway soft-PLC +/// (OpenPLC) container in-cluster, load the program, start the runtime, probe it +/// over industrial protocols, then tear it down. This struct carries the knobs +/// for that container's lifecycle and the OpenPLC web-UI credentials used to +/// upload the program. +#[derive(Clone, Debug)] +pub struct PlcRuntimeConfig { + /// Master switch. Provision-and-test does nothing unless this is set — it + /// shells out to `docker`, which requires the agent container to have Docker + /// access (socket mount), an explicit deployment decision. + pub enabled: bool, + /// Container image for the ephemeral soft-PLC (OpenPLC). + pub image: String, + /// Docker network the instance joins. Must be the agent's own network so it + /// is reachable in-cluster by container name and never published to the host. + pub network: String, + /// Memory cap passed to `docker run --memory` (e.g. `512m`). + pub memory: String, + /// CPU cap passed to `docker run --cpus` (e.g. `0.5`). + pub cpus: String, + /// Hard ceiling on a provisioned instance's lifetime. Teardown is guaranteed + /// no later than this even if a load/probe step hangs. + pub max_lifetime_secs: u64, + /// OpenPLC web-UI username for the program upload (image default `openplc`). + pub openplc_user: String, + /// OpenPLC web-UI password (image default `openplc`). + pub openplc_password: SecretString, +} + +impl Default for PlcRuntimeConfig { + fn default() -> Self { + Self { + enabled: false, + image: "registry.meghsakha.com/openplc:latest".to_string(), + network: "certifai".to_string(), + memory: "512m".to_string(), + cpus: "0.5".to_string(), + max_lifetime_secs: 180, + openplc_user: "openplc".to_string(), + openplc_password: SecretString::from("openplc".to_string()), + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/compliance-core/src/lib.rs b/compliance-core/src/lib.rs index 0d88a58..d43a542 100644 --- a/compliance-core/src/lib.rs +++ b/compliance-core/src/lib.rs @@ -13,6 +13,6 @@ pub mod auth; #[cfg(feature = "axum")] pub mod tenant_ctx; -pub use config::{AgentConfig, DashboardConfig}; +pub use config::{AgentConfig, DashboardConfig, PlcRuntimeConfig}; pub use error::CoreError; pub use tenant::{OrgRole, TenantContext, TenantStatus};