From 70a4ee55ab3b51a07fddebf0ae568d248e07c255 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Fri, 17 Jul 2026 12:56:15 +0000 Subject: [PATCH] refactor(werkbank): extract soft-PLC provisioning + ICS probe into werkbank-exec (WB-04a) (#208) --- .gitea/workflows/ci.yml | 6 ++-- Cargo.lock | 20 ++++++++++++ Cargo.toml | 1 + compliance-agent/Cargo.toml | 3 ++ compliance-agent/src/error.rs | 3 ++ compliance-agent/src/pipeline/mod.rs | 1 - compliance-agent/src/pipeline/orchestrator.rs | 11 +++---- compliance-agent/src/pipeline/plc/mod.rs | 1 - werkbank-exec/Cargo.toml | 23 +++++++++++++ werkbank-exec/src/error.rs | 16 ++++++++++ werkbank-exec/src/fingerprint.rs | 32 +++++++++++++++++++ .../src}/ics/ethernetip.rs | 0 .../pipeline => werkbank-exec/src}/ics/mod.rs | 2 +- .../src}/ics/modbus.rs | 0 .../src}/ics/opcua.rs | 0 .../src}/ics/portscan.rs | 0 werkbank-exec/src/lib.rs | 18 +++++++++++ .../runtime => werkbank-exec/src/plc}/mod.rs | 17 +++++----- .../src/plc}/openplc.rs | 30 ++++++++--------- .../src/plc}/provision.rs | 12 +++---- 20 files changed, 155 insertions(+), 41 deletions(-) create mode 100644 werkbank-exec/Cargo.toml create mode 100644 werkbank-exec/src/error.rs create mode 100644 werkbank-exec/src/fingerprint.rs rename {compliance-agent/src/pipeline => werkbank-exec/src}/ics/ethernetip.rs (100%) rename {compliance-agent/src/pipeline => werkbank-exec/src}/ics/mod.rs (99%) rename {compliance-agent/src/pipeline => werkbank-exec/src}/ics/modbus.rs (100%) rename {compliance-agent/src/pipeline => werkbank-exec/src}/ics/opcua.rs (100%) rename {compliance-agent/src/pipeline => werkbank-exec/src}/ics/portscan.rs (100%) create mode 100644 werkbank-exec/src/lib.rs rename {compliance-agent/src/pipeline/plc/runtime => werkbank-exec/src/plc}/mod.rs (97%) rename {compliance-agent/src/pipeline/plc/runtime => werkbank-exec/src/plc}/openplc.rs (92%) rename {compliance-agent/src/pipeline/plc/runtime => werkbank-exec/src/plc}/provision.rs (98%) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4ae9b7a..93667a8 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -107,6 +107,8 @@ jobs: run: cargo clippy -p compliance-dashboard --features web --no-default-features -- -D warnings - name: Clippy (mcp) run: cargo clippy -p compliance-mcp -- -D warnings + - name: Clippy (werkbank-exec) + run: cargo clippy -p werkbank-exec -- -D warnings # Security audit - name: Security Audit @@ -115,8 +117,8 @@ jobs: RUSTC_WRAPPER: "" # Tests (reuses compilation artifacts from clippy) - - name: Tests (core + agent) - run: cargo test -p compliance-core -p compliance-agent --lib + - name: Tests (core + agent + werkbank-exec) + run: cargo test -p compliance-core -p compliance-agent -p werkbank-exec --lib - name: Tests (dashboard server) run: cargo test -p compliance-dashboard --features server --no-default-features - name: Tests (dashboard web) diff --git a/Cargo.lock b/Cargo.lock index aef91e7..280e7f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -699,6 +699,7 @@ dependencies = [ "urlencoding", "uuid", "walkdir", + "werkbank-exec", "zip", ] @@ -6714,6 +6715,25 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "werkbank-exec" +version = "0.1.0" +dependencies = [ + "compliance-core", + "compliance-dast", + "futures-util", + "hex", + "regex", + "reqwest", + "secrecy", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", + "walkdir", +] + [[package]] name = "which" version = "6.0.3" diff --git a/Cargo.toml b/Cargo.toml index 01c6a2c..7ea8120 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "compliance-dast", "compliance-mcp", "compliance-smoke", + "werkbank-exec", ] resolver = "2" diff --git a/compliance-agent/Cargo.toml b/compliance-agent/Cargo.toml index d18b5d1..431c0c6 100644 --- a/compliance-agent/Cargo.toml +++ b/compliance-agent/Cargo.toml @@ -10,6 +10,9 @@ workspace = true compliance-core = { workspace = true, features = ["mongodb", "telemetry", "axum"] } compliance-graph = { path = "../compliance-graph" } compliance-dast = { path = "../compliance-dast" } +# Shared dynamic-execution logic (soft-PLC provisioning + ICS probing), also +# used by the Werkbank runner. +werkbank-exec = { path = "../werkbank-exec" } # Native firmware build/target detection for bare-metal & RTOS artifacts. # Same-company IP, used directly (not via CLI) so the whole tramiton suite is # available to the onboarding classifier. NOTE: CI must be able to fetch this diff --git a/compliance-agent/src/error.rs b/compliance-agent/src/error.rs index cee4751..41a5df7 100644 --- a/compliance-agent/src/error.rs +++ b/compliance-agent/src/error.rs @@ -27,6 +27,9 @@ pub enum AgentError { #[error("Configuration error: {0}")] Config(String), + #[error("Dynamic-execution error: {0}")] + Exec(#[from] werkbank_exec::ExecError), + #[error("{0}")] Other(String), } diff --git a/compliance-agent/src/pipeline/mod.rs b/compliance-agent/src/pipeline/mod.rs index 0f37f7b..a53f0f9 100644 --- a/compliance-agent/src/pipeline/mod.rs +++ b/compliance-agent/src/pipeline/mod.rs @@ -5,7 +5,6 @@ pub mod firmware_sbom; pub mod git; pub mod gitleaks; mod graph_build; -pub mod ics; mod issue_creation; pub mod lint; pub mod orchestrator; diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index 9030b86..093b041 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -587,7 +587,7 @@ impl PipelineOrchestrator { let path = ingest_set .get(&a.id) .and_then(|ia| ia.working_path.clone())?; - crate::pipeline::plc::runtime::extract_program(&path) + werkbank_exec::plc::extract_program(&path) }); let Some(program) = program else { tracing::info!( @@ -597,10 +597,9 @@ impl PipelineOrchestrator { 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 outcome = crate::pipeline::plc::runtime::provision_and_test( + let http = werkbank_exec::plc::http_client()?; + let provisioner = werkbank_exec::plc::DockerSoftPlc::new(self.config.plc_runtime.clone()); + let outcome = werkbank_exec::plc::provision_and_test( &provisioner, &http, &self.config.plc_runtime, @@ -663,7 +662,7 @@ impl PipelineOrchestrator { }; // Short per-request budget so an unreachable device doesn't stall the scan. let budget = std::time::Duration::from_secs(5); - let findings = crate::pipeline::ics::probe_target(&endpoint, target_id, budget).await; + let findings = werkbank_exec::ics::probe_target(&endpoint, target_id, budget).await; tracing::info!( target_id, endpoint = %endpoint, diff --git a/compliance-agent/src/pipeline/plc/mod.rs b/compliance-agent/src/pipeline/plc/mod.rs index c09dda9..e58e766 100644 --- a/compliance-agent/src/pipeline/plc/mod.rs +++ b/compliance-agent/src/pipeline/plc/mod.rs @@ -9,7 +9,6 @@ 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/werkbank-exec/Cargo.toml b/werkbank-exec/Cargo.toml new file mode 100644 index 0000000..a0765ef --- /dev/null +++ b/werkbank-exec/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "werkbank-exec" +version = "0.1.0" +edition = "2021" +description = "Shared dynamic-execution logic: soft-PLC provisioning + industrial-protocol probing, used by the compliance agent and the Werkbank runner." + +[lints] +workspace = true + +[dependencies] +compliance-core = { workspace = true } +compliance-dast = { path = "../compliance-dast" } +tokio = { workspace = true } +reqwest = { workspace = true } +uuid = { workspace = true } +regex = { workspace = true } +secrecy = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +walkdir = "2" +futures-util = "0.3" diff --git a/werkbank-exec/src/error.rs b/werkbank-exec/src/error.rs new file mode 100644 index 0000000..226a564 --- /dev/null +++ b/werkbank-exec/src/error.rs @@ -0,0 +1,16 @@ +//! Error type for the dynamic-execution logic. + +/// Anything that can go wrong provisioning and testing a soft-PLC. The compliance +/// agent maps this into its own `AgentError` at the call boundary. +#[derive(thiserror::Error, Debug)] +pub enum ExecError { + /// An HTTP request (to OpenPLC) failed. + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + /// A local IO / process error (e.g. invoking `docker`). + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + /// Any other failure, with a message. + #[error("{0}")] + Other(String), +} diff --git a/werkbank-exec/src/fingerprint.rs b/werkbank-exec/src/fingerprint.rs new file mode 100644 index 0000000..a2e6e05 --- /dev/null +++ b/werkbank-exec/src/fingerprint.rs @@ -0,0 +1,32 @@ +//! Finding fingerprint helper (a SHA-256 over the salient parts), shared by the +//! probe modules for stable dedup keys. Mirrors the agent's `dedup` helper. + +use sha2::{Digest, Sha256}; + +/// A stable fingerprint over the given parts (order-sensitive, separated so +/// `["ab","c"]` and `["a","bc"]` differ). +pub fn compute_fingerprint(parts: &[&str]) -> String { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update(part.as_bytes()); + hasher.update(b"|"); + } + hex::encode(hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_and_hex() { + let a = compute_fingerprint(&["repo", "rule", "1"]); + assert_eq!(a, compute_fingerprint(&["repo", "rule", "1"])); + assert_eq!(a.len(), 64); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!( + compute_fingerprint(&["ab", "c"]), + compute_fingerprint(&["a", "bc"]) + ); + } +} diff --git a/compliance-agent/src/pipeline/ics/ethernetip.rs b/werkbank-exec/src/ics/ethernetip.rs similarity index 100% rename from compliance-agent/src/pipeline/ics/ethernetip.rs rename to werkbank-exec/src/ics/ethernetip.rs diff --git a/compliance-agent/src/pipeline/ics/mod.rs b/werkbank-exec/src/ics/mod.rs similarity index 99% rename from compliance-agent/src/pipeline/ics/mod.rs rename to werkbank-exec/src/ics/mod.rs index 33ff3c8..ed574b0 100644 --- a/compliance-agent/src/pipeline/ics/mod.rs +++ b/werkbank-exec/src/ics/mod.rs @@ -14,7 +14,7 @@ use std::time::Duration; use compliance_core::models::{Finding, ScanType, Severity}; -use crate::pipeline::dedup; +use crate::fingerprint as dedup; /// Well-known deep-probe ports (each independent of any WebVisu HTTP port). const MODBUS_PORT: u16 = 502; diff --git a/compliance-agent/src/pipeline/ics/modbus.rs b/werkbank-exec/src/ics/modbus.rs similarity index 100% rename from compliance-agent/src/pipeline/ics/modbus.rs rename to werkbank-exec/src/ics/modbus.rs diff --git a/compliance-agent/src/pipeline/ics/opcua.rs b/werkbank-exec/src/ics/opcua.rs similarity index 100% rename from compliance-agent/src/pipeline/ics/opcua.rs rename to werkbank-exec/src/ics/opcua.rs diff --git a/compliance-agent/src/pipeline/ics/portscan.rs b/werkbank-exec/src/ics/portscan.rs similarity index 100% rename from compliance-agent/src/pipeline/ics/portscan.rs rename to werkbank-exec/src/ics/portscan.rs diff --git a/werkbank-exec/src/lib.rs b/werkbank-exec/src/lib.rs new file mode 100644 index 0000000..f4842ab --- /dev/null +++ b/werkbank-exec/src/lib.rs @@ -0,0 +1,18 @@ +//! Shared dynamic-execution logic for Werkbank. +//! +//! The soft-PLC provisioning + industrial-protocol probing that turns a control- +//! logic artifact into findings: provision an ephemeral OpenPLC, load the program, +//! start it, probe it over Modbus/OPC-UA/EtherNet-IP, DAST its web endpoint, tear +//! it down. Extracted from the compliance agent (#183) so both the agent (in +//! process) and the Werkbank runner (WB-04) run identical logic. +//! +//! - [`ics`] — read-only industrial-protocol probing. +//! - [`plc`] — ephemeral soft-PLC provisioning + the provision-and-test loop. + +pub mod error; +pub mod ics; +pub mod plc; + +mod fingerprint; + +pub use error::ExecError; diff --git a/compliance-agent/src/pipeline/plc/runtime/mod.rs b/werkbank-exec/src/plc/mod.rs similarity index 97% rename from compliance-agent/src/pipeline/plc/runtime/mod.rs rename to werkbank-exec/src/plc/mod.rs index 4565980..35b287e 100644 --- a/compliance-agent/src/pipeline/plc/runtime/mod.rs +++ b/werkbank-exec/src/plc/mod.rs @@ -24,7 +24,7 @@ use compliance_core::models::dast::{DastFinding, DastScanRun, DastTarget, DastTa use compliance_core::models::Finding; use compliance_core::PlcRuntimeConfig; -use crate::error::AgentError; +use crate::error::ExecError; pub use provision::{DockerSoftPlc, ProvisionedRuntime, SoftPlc}; @@ -61,12 +61,12 @@ pub struct PlcProgram { /// 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 { +pub fn http_client() -> Result { reqwest::Client::builder() .cookie_store(true) .timeout(Duration::from_secs(30)) .build() - .map_err(AgentError::Http) + .map_err(ExecError::Http) } /// Pick the control-logic program to run from an ingested PLC source tree. @@ -151,7 +151,7 @@ pub async fn provision_and_test( cfg: &PlcRuntimeConfig, program: &PlcProgram, target_id: &str, -) -> Result { +) -> Result { let handle = provisioner.provision(target_id).await?; tracing::info!( target_id, @@ -193,7 +193,7 @@ async fn run_dynamic_test( program: &PlcProgram, target_id: &str, handle: &ProvisionedRuntime, -) -> Result { +) -> 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?; @@ -212,8 +212,7 @@ async fn run_dynamic_test( 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; + let findings = crate::ics::probe_target(&handle.modbus_endpoint, target_id, probe_budget).await; tracing::info!( target_id, instance = %handle.name, @@ -348,10 +347,10 @@ mod tests { } impl SoftPlc for FakeSoftPlc { - async fn provision(&self, _target_id: &str) -> Result { + 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())); + 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. diff --git a/compliance-agent/src/pipeline/plc/runtime/openplc.rs b/werkbank-exec/src/plc/openplc.rs similarity index 92% rename from compliance-agent/src/pipeline/plc/runtime/openplc.rs rename to werkbank-exec/src/plc/openplc.rs index a6536e7..4026809 100644 --- a/compliance-agent/src/pipeline/plc/runtime/openplc.rs +++ b/werkbank-exec/src/plc/openplc.rs @@ -10,7 +10,7 @@ use std::time::Duration; -use crate::error::AgentError; +use crate::error::ExecError; use super::PlcProgram; @@ -27,7 +27,7 @@ pub async fn wait_ready( http: &reqwest::Client, base_url: &str, budget: Duration, -) -> Result<(), AgentError> { +) -> Result<(), ExecError> { let login = format!("{base_url}/login"); let outcome = tokio::time::timeout(budget, async { loop { @@ -40,7 +40,7 @@ pub async fn wait_ready( } }) .await; - outcome.map_err(|_| AgentError::Other(format!("OpenPLC at {base_url} did not become ready"))) + outcome.map_err(|_| ExecError::Other(format!("OpenPLC at {base_url} did not become ready"))) } /// Log in, upload the program, compile it, and start the runtime. On success the @@ -52,7 +52,7 @@ pub async fn load_and_start( password: &str, program: &PlcProgram, compile_budget: Duration, -) -> Result<(), AgentError> { +) -> Result<(), ExecError> { 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?; @@ -68,14 +68,14 @@ async fn login( base_url: &str, user: &str, password: &str, -) -> Result<(), AgentError> { +) -> Result<(), ExecError> { 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!( + return Err(ExecError::Other(format!( "OpenPLC login failed: HTTP {}", resp.status() ))); @@ -90,7 +90,7 @@ async fn upload_program( http: &reqwest::Client, base_url: &str, program: &PlcProgram, -) -> Result { +) -> Result { let part = reqwest::multipart::Part::text(program.source.clone()) .file_name(program.file_name.clone()) .mime_str("application/octet-stream")?; @@ -102,7 +102,7 @@ async fn upload_program( .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()) + ExecError::Other("OpenPLC upload did not return a prog_file handle".to_string()) }) } @@ -113,7 +113,7 @@ async fn save_program( http: &reqwest::Client, base_url: &str, prog_file: &str, -) -> Result<(), AgentError> { +) -> Result<(), ExecError> { let epoch = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) @@ -130,7 +130,7 @@ async fn save_program( .send() .await?; if resp.status().is_server_error() { - return Err(AgentError::Other(format!( + return Err(ExecError::Other(format!( "OpenPLC save-program failed: HTTP {}", resp.status() ))); @@ -146,7 +146,7 @@ async fn compile( base_url: &str, prog_file: &str, budget: Duration, -) -> Result<(), AgentError> { +) -> Result<(), ExecError> { http.get(format!("{base_url}/compile-program")) .query(&[("file", prog_file)]) .send() @@ -168,20 +168,20 @@ async fn compile( .await; match outcome { Ok(true) => Ok(()), - Ok(false) => Err(AgentError::Other( + Ok(false) => Err(ExecError::Other( "OpenPLC compilation finished with errors".to_string(), )), - Err(_) => Err(AgentError::Other( + Err(_) => Err(ExecError::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> { +async fn start(http: &reqwest::Client, base_url: &str) -> Result<(), ExecError> { let resp = http.get(format!("{base_url}/start_plc")).send().await?; if resp.status().is_server_error() { - return Err(AgentError::Other(format!( + return Err(ExecError::Other(format!( "OpenPLC start_plc failed: HTTP {}", resp.status() ))); diff --git a/compliance-agent/src/pipeline/plc/runtime/provision.rs b/werkbank-exec/src/plc/provision.rs similarity index 98% rename from compliance-agent/src/pipeline/plc/runtime/provision.rs rename to werkbank-exec/src/plc/provision.rs index ce5a79c..cd74bf4 100644 --- a/compliance-agent/src/pipeline/plc/runtime/provision.rs +++ b/werkbank-exec/src/plc/provision.rs @@ -15,7 +15,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use compliance_core::PlcRuntimeConfig; -use crate::error::AgentError; +use crate::error::ExecError; /// The Modbus/TCP port an OpenPLC instance opens once a program is running. const MODBUS_PORT: u16 = 502; @@ -45,7 +45,7 @@ pub trait SoftPlc { fn provision( &self, target_id: &str, - ) -> impl std::future::Future> + Send; + ) -> impl std::future::Future> + Send; /// Tear an instance down. Best-effort and idempotent — never fails the scan. fn teardown(&self, handle: &ProvisionedRuntime) @@ -65,7 +65,7 @@ impl DockerSoftPlc { } impl SoftPlc for DockerSoftPlc { - async fn provision(&self, target_id: &str) -> Result { + 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. @@ -75,7 +75,7 @@ impl SoftPlc for DockerSoftPlc { let args = run_args(&self.cfg, &name, target_id); let out = run_docker(&args).await?; if !out.status.success() { - return Err(AgentError::Other(format!( + return Err(ExecError::Other(format!( "docker run for soft-PLC {name} failed: {}", String::from_utf8_lossy(&out.stderr).trim() ))); @@ -215,12 +215,12 @@ async fn reap_stale(cfg: &PlcRuntimeConfig, now: u64) { } /// Run a `docker` subcommand, capturing its output. -async fn run_docker(args: &[String]) -> Result { +async fn run_docker(args: &[String]) -> Result { tokio::process::Command::new("docker") .args(args) .output() .await - .map_err(AgentError::Io) + .map_err(ExecError::Io) } #[cfg(test)]