refactor(werkbank): extract soft-PLC provisioning + ICS probe into werkbank-exec (WB-04a) #208

Merged
sharang merged 1 commits from feat/werkbank-exec-crate into main 2026-07-17 12:56:16 +00:00
20 changed files with 155 additions and 41 deletions
Showing only changes of commit e5f4b562c3 - Show all commits
+4 -2
View File
@@ -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)
Generated
+20
View File
@@ -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"
+1
View File
@@ -7,6 +7,7 @@ members = [
"compliance-dast",
"compliance-mcp",
"compliance-smoke",
"werkbank-exec",
]
resolver = "2"
+3
View File
@@ -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
+3
View File
@@ -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),
}
-1
View File
@@ -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;
@@ -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,
-1
View File
@@ -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;
+23
View File
@@ -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"
+16
View File
@@ -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),
}
+32
View File
@@ -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"])
);
}
}
@@ -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;
+18
View File
@@ -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;
@@ -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<reqwest::Client, AgentError> {
pub fn http_client() -> Result<reqwest::Client, ExecError> {
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<P: SoftPlc>(
cfg: &PlcRuntimeConfig,
program: &PlcProgram,
target_id: &str,
) -> Result<ProvisionOutcome, AgentError> {
) -> Result<ProvisionOutcome, ExecError> {
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<ProvisionOutcome, AgentError> {
) -> 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?;
@@ -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<ProvisionedRuntime, AgentError> {
async fn provision(&self, _target_id: &str) -> Result<ProvisionedRuntime, ExecError> {
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.
@@ -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<String, AgentError> {
) -> Result<String, ExecError> {
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()
)));
@@ -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<Output = Result<ProvisionedRuntime, AgentError>> + Send;
) -> impl std::future::Future<Output = Result<ProvisionedRuntime, ExecError>> + 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<ProvisionedRuntime, AgentError> {
async fn provision(&self, target_id: &str) -> Result<ProvisionedRuntime, ExecError> {
// 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<std::process::Output, AgentError> {
async fn run_docker(args: &[String]) -> Result<std::process::Output, ExecError> {
tokio::process::Command::new("docker")
.args(args)
.output()
.await
.map_err(AgentError::Io)
.map_err(ExecError::Io)
}
#[cfg(test)]