refactor(werkbank): extract soft-PLC provisioning + ICS probe into werkbank-exec (WB-04a) (#208)
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

This commit was merged in pull request #208.
This commit is contained in:
2026-07-17 12:56:15 +00:00
parent 91a87677bc
commit 70a4ee55ab
20 changed files with 155 additions and 41 deletions
+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"])
);
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Minimal EtherNet/IP (CIP) reachability probe.
//!
//! Sends an EtherNet/IP encapsulation **ListIdentity** command (0x0063) over TCP
//! 44818 and checks for a valid encapsulation reply — confirming a CIP device
//! without opening a session or writing anything.
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
/// Outcome of an EtherNet/IP handshake probe.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct EnipProbe {
/// A TCP connection to the port was established.
pub reachable: bool,
/// The endpoint returned a valid EtherNet/IP encapsulation reply.
pub is_enip: bool,
}
/// Probe an EtherNet/IP endpoint with a ListIdentity request. Read-only.
pub async fn probe(host: &str, port: u16, budget: Duration) -> EnipProbe {
let mut out = EnipProbe::default();
let Ok(Ok(mut stream)) = timeout(budget, TcpStream::connect((host, port))).await else {
return out;
};
out.reachable = true;
// Encapsulation header (24 bytes): command(2) length(2) session(4) status(4)
// context(8) options(4). ListIdentity = command 0x0063, everything else zero.
let mut req = vec![0u8; 24];
req[0..2].copy_from_slice(&0x0063u16.to_le_bytes());
if timeout(budget, stream.write_all(&req))
.await
.ok()
.and_then(Result::ok)
.is_none()
{
return out;
}
let mut hdr = [0u8; 24];
if timeout(budget, stream.read_exact(&mut hdr))
.await
.ok()
.and_then(Result::ok)
.is_none()
{
return out;
}
let command = u16::from_le_bytes([hdr[0], hdr[1]]);
let status = u32::from_le_bytes([hdr[8], hdr[9], hdr[10], hdr[11]]);
// Echoed command + success status = a valid EtherNet/IP encapsulation reply.
if command == 0x0063 && status == 0 {
out.is_enip = true;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener;
async fn mock_server() -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.expect("accept");
let mut req = [0u8; 24];
if sock.read_exact(&mut req).await.is_err() {
return;
}
// Reply: echo command 0x0063, status 0, no data.
let mut hdr = vec![0u8; 24];
hdr[0..2].copy_from_slice(&0x0063u16.to_le_bytes());
let _ = sock.write_all(&hdr).await;
});
addr
}
#[tokio::test]
async fn probe_detects_an_ethernetip_device() {
let addr = mock_server().await;
let p = probe(&addr.ip().to_string(), addr.port(), Duration::from_secs(2)).await;
assert!(p.reachable && p.is_enip);
}
#[tokio::test]
async fn probe_reports_unreachable_for_a_closed_port() {
let p = probe("127.0.0.1", 1, Duration::from_millis(500)).await;
assert!(!p.reachable && !p.is_enip);
}
}
+306
View File
@@ -0,0 +1,306 @@
//! Dynamic ICS (industrial control system) probing for PLC/SPS targets.
//!
//! Where the control-logic scanner is static (over ST / PLCopen XML), this probes
//! the *running* device over industrial protocols and reports exposed /
//! unauthenticated control interfaces. It is read-only: it never writes to a live
//! process. Modbus/TCP and OPC UA are implemented; EtherNet-IP is a follow-on.
pub mod ethernetip;
pub mod modbus;
pub mod opcua;
pub mod portscan;
use std::time::Duration;
use compliance_core::models::{Finding, ScanType, Severity};
use crate::fingerprint as dedup;
/// Well-known deep-probe ports (each independent of any WebVisu HTTP port).
const MODBUS_PORT: u16 = 502;
const OPCUA_PORT: u16 = 4840;
const ENIP_PORT: u16 = 44818;
/// Probe a PLC/SPS device's industrial-protocol surface and return findings.
/// Read-only. Deep-probes Modbus/TCP, OPC UA and EtherNet/IP, plus a service
/// discovery scan of the remaining OT / insecure-management ports. `endpoint` is
/// the target's live-URL / host reference.
pub async fn probe_target(endpoint: &str, repo_id: &str, budget: Duration) -> Vec<Finding> {
let (host, modbus_port) = parse_endpoint(endpoint);
let mut findings = modbus_findings(&host, modbus_port, repo_id, budget).await;
findings.extend(opcua_findings(&host, OPCUA_PORT, repo_id, budget).await);
findings.extend(enip_findings(&host, ENIP_PORT, repo_id, budget).await);
findings.extend(portscan_findings(&host, repo_id, budget).await);
findings
}
/// Findings from probing the Modbus/TCP surface.
async fn modbus_findings(host: &str, port: u16, repo_id: &str, budget: Duration) -> Vec<Finding> {
let probe = modbus::probe(host, port, budget).await;
let mut findings = Vec::new();
if !probe.speaks_modbus {
// Not reachable, or the port does not speak Modbus — nothing to report.
return findings;
}
let target = format!("{host}:{port}");
// Reachable Modbus/TCP = unauthenticated, cleartext control access by design.
let fp = dedup::compute_fingerprint(&[repo_id, "ics-modbus-exposed", &target]);
let mut f = Finding::new(
repo_id.to_string(),
fp,
"ics-probe".to_string(),
ScanType::IcsProbe,
"Modbus/TCP control interface exposed without authentication".to_string(),
format!(
"The device at {target} answers Modbus/TCP requests. Modbus/TCP has no \
authentication or encryption in the protocol, so any host that can reach this \
port can read and write process variables (coils/registers) and disrupt the \
controlled process."
),
Severity::Critical,
);
f.rule_id = Some("ics-modbus-exposed".to_string());
f.cwe = Some("CWE-306".to_string());
f.remediation = Some(
"Restrict the Modbus/TCP port to a trusted control network (segmentation / \
firewall / VPN), never expose it to IT or the internet, and prefer an authenticated \
transport (e.g. Modbus/TLS) or a secure protocol gateway where available."
.to_string(),
);
findings.push(f);
if let Some(dev) = &probe.device {
let details = [
dev.vendor.as_deref(),
dev.product.as_deref(),
dev.revision.as_deref(),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" / ");
let fp = dedup::compute_fingerprint(&[repo_id, "ics-device-disclosure", &target]);
let mut f = Finding::new(
repo_id.to_string(),
fp,
"ics-probe".to_string(),
ScanType::IcsProbe,
"PLC device identity disclosed over Modbus".to_string(),
format!(
"The device at {target} discloses its identity via Modbus Read Device \
Identification: {details}. This aids fingerprinting and targeting of \
known-vulnerable firmware/runtime versions."
),
Severity::Low,
);
f.rule_id = Some("ics-device-disclosure".to_string());
f.cwe = Some("CWE-200".to_string());
f.remediation = Some(
"Limit network reach to the device; Modbus device identification cannot be \
disabled, so exposure is bounded by network segmentation."
.to_string(),
);
findings.push(f);
}
// Exposed process points: coils / holding registers that a read enumerated
// and that, over unauthenticated Modbus/TCP, are also writable. This is the
// concrete attack surface behind the exposure — the live variables an
// attacker can overwrite. (Read-only to detect: we never write.)
let coils = probe.coils_readable.unwrap_or(0);
let registers = probe.holding_registers_readable.unwrap_or(0);
if coils > 0 || registers > 0 {
let fp = dedup::compute_fingerprint(&[repo_id, "ics-modbus-exposed-points", &target]);
let mut f = Finding::new(
repo_id.to_string(),
fp,
"ics-probe".to_string(),
ScanType::IcsProbe,
"Writable process points exposed over unauthenticated Modbus/TCP".to_string(),
format!(
"Reading the device at {target} enumerated {coils} coil(s) and {registers} \
holding register(s). Coils and holding registers are read/write process points \
in Modbus, so any host that can reach this port can not only read but overwrite \
live process state (force coils, change setpoints) without authentication."
),
Severity::High,
);
f.rule_id = Some("ics-modbus-exposed-points".to_string());
f.cwe = Some("CWE-306".to_string());
f.remediation = Some(
"Segment the Modbus/TCP port to a trusted control network; where the device \
supports it use Modbus/TLS or an authenticating protocol gateway; restrict which \
function codes and register ranges are reachable from outside the control zone."
.to_string(),
);
findings.push(f);
}
findings
}
/// Findings from probing the OPC UA surface (default port 4840). A reachability
/// probe only: it flags an exposed OPC UA server for review of its security
/// policy / authentication (deep SecurityPolicy analysis is a follow-on).
async fn opcua_findings(host: &str, port: u16, repo_id: &str, budget: Duration) -> Vec<Finding> {
let probe = opcua::probe(host, port, budget).await;
let mut findings = Vec::new();
if !probe.is_opcua {
return findings;
}
let target = format!("{host}:{port}");
let fp = dedup::compute_fingerprint(&[repo_id, "ics-opcua-exposed", &target]);
let mut f = Finding::new(
repo_id.to_string(),
fp,
"ics-probe".to_string(),
ScanType::IcsProbe,
"OPC UA server exposed on the network".to_string(),
format!(
"An OPC UA server answers at {target}. Verify it enforces message security \
(a SecurityPolicy other than None) and rejects anonymous sessions — the common \
default of SecurityPolicy None + an Anonymous user token allows unauthenticated, \
unencrypted read/write of the server's address space."
),
Severity::Medium,
);
f.rule_id = Some("ics-opcua-exposed".to_string());
f.cwe = Some("CWE-319".to_string());
f.remediation = Some(
"Restrict OPC UA (4840) to a trusted network; require a signed & encrypted \
SecurityPolicy (Basic256Sha256 or better) with certificate / username \
authentication, and disable the Anonymous user token."
.to_string(),
);
findings.push(f);
findings
}
/// Findings from probing the EtherNet/IP (CIP) surface (default port 44818).
async fn enip_findings(host: &str, port: u16, repo_id: &str, budget: Duration) -> Vec<Finding> {
let probe = ethernetip::probe(host, port, budget).await;
if !probe.is_enip {
return Vec::new();
}
let target = format!("{host}:{port}");
let fp = dedup::compute_fingerprint(&[repo_id, "ics-ethernetip-exposed", &target]);
let mut f = Finding::new(
repo_id.to_string(),
fp,
"ics-probe".to_string(),
ScanType::IcsProbe,
"EtherNet/IP (CIP) interface exposed on the network".to_string(),
format!(
"The device at {target} answers EtherNet/IP (CIP) requests. EtherNet/IP has no \
authentication in the base protocol, so a host that can reach it can enumerate \
and interact with the device's control objects."
),
Severity::High,
);
f.rule_id = Some("ics-ethernetip-exposed".to_string());
f.cwe = Some("CWE-306".to_string());
f.remediation = Some(
"Restrict EtherNet/IP (44818/2222) to a trusted control network; use CIP Security \
(encryption + authentication) on devices that support it."
.to_string(),
);
vec![f]
}
/// Findings from the service-discovery port scan of the remaining OT /
/// insecure-management surface.
async fn portscan_findings(host: &str, repo_id: &str, budget: Duration) -> Vec<Finding> {
let open = portscan::scan(host, portscan::KNOWN_PORTS, budget).await;
open.into_iter()
.map(|kp| {
let target = format!("{host}:{}", kp.port);
let (title, severity, cwe, description) = match kp.kind {
portscan::PortKind::Ics => (
format!("ICS service exposed: {}", kp.service),
Severity::High,
"CWE-306",
format!(
"{target} exposes {} ({}). Industrial protocols are typically \
unauthenticated, so network reach implies control access.",
kp.service, kp.note
),
),
portscan::PortKind::InsecureMgmt => (
format!("Cleartext service exposed: {}", kp.service),
Severity::Medium,
"CWE-319",
format!(
"{target} exposes {} ({}), which transmits credentials and data in \
cleartext.",
kp.service, kp.note
),
),
};
let fp = dedup::compute_fingerprint(&[repo_id, "ics-service-exposed", &target]);
let mut f = Finding::new(
repo_id.to_string(),
fp,
"ics-probe".to_string(),
ScanType::IcsProbe,
title,
description,
severity,
);
f.rule_id = Some("ics-service-exposed".to_string());
f.cwe = Some(cwe.to_string());
f.remediation = Some(
"Restrict the service to a trusted network segment; disable it if unused; \
replace cleartext protocols (Telnet/FTP) with SSH/SFTP."
.to_string(),
);
f
})
.collect()
}
/// Extract `(host, port)` from a target reference. Modbus lives on its own port
/// (502 by default), independent of any HTTP/WebVisu URL, so unless the reference
/// explicitly carries `modbus://host:port` or a bare `host:port`, we probe 502.
fn parse_endpoint(endpoint: &str) -> (String, u16) {
let s = endpoint.trim();
let (scheme, rest) = match s.split_once("://") {
Some((sch, r)) => (Some(sch.to_ascii_lowercase()), r),
None => (None, s),
};
let hostport = rest.split(['/', '?']).next().unwrap_or(rest);
let (host, port) = match hostport.rsplit_once(':') {
Some((h, p)) => (h.to_string(), p.parse::<u16>().ok()),
None => (hostport.to_string(), None),
};
let port = match (scheme.as_deref(), port) {
// Explicit Modbus port, or a bare host:port the user chose.
(Some("modbus"), Some(p)) | (None, Some(p)) => p,
// An http(s)/WebVisu URL (or no port): Modbus is on its own port.
_ => MODBUS_PORT,
};
(host, port)
}
#[cfg(test)]
mod tests {
use super::parse_endpoint;
#[test]
fn endpoint_parsing_picks_the_modbus_port() {
assert_eq!(parse_endpoint("10.0.0.5"), ("10.0.0.5".into(), 502));
assert_eq!(parse_endpoint("10.0.0.5:1502"), ("10.0.0.5".into(), 1502));
assert_eq!(
parse_endpoint("modbus://plc.local:5020"),
("plc.local".into(), 5020)
);
// A WebVisu URL: the http port is ignored; Modbus is on 502.
assert_eq!(
parse_endpoint("http://plc.local:8080/webvisu"),
("plc.local".into(), 502)
);
assert_eq!(
parse_endpoint("https://plc.local/"),
("plc.local".into(), 502)
);
}
}
+262
View File
@@ -0,0 +1,262 @@
//! Minimal Modbus/TCP client for dynamic ICS probing.
//!
//! Modbus/TCP (port 502) has no authentication or encryption in the protocol, so
//! an endpoint that answers requests is, by design, open to any host that can
//! reach it. The probe only *reads* — a Read Holding Registers request and a Read
//! Device Identification request — and never writes to the live process.
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
/// Outcome of probing a Modbus/TCP endpoint.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ModbusProbe {
/// A TCP connection to the port was established.
pub reachable: bool,
/// The endpoint answered a Modbus request (a normal reply or a Modbus
/// exception) — i.e. it speaks Modbus, unauthenticated.
pub speaks_modbus: bool,
/// Device identity, if disclosed via Read Device Identification (FC 43 / 14).
pub device: Option<DeviceId>,
/// Coils returned by a Read Coils of the first block, if that address range
/// exists. Coils are read/write process bits, so an exposed block is an
/// unauthenticated write surface on the live process.
pub coils_readable: Option<u16>,
/// Holding registers returned by a Read Holding Registers of the first block,
/// if that range exists. Holding registers are read/write process words.
pub holding_registers_readable: Option<u16>,
}
/// Vendor / product / revision from Read Device Identification.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct DeviceId {
pub vendor: Option<String>,
pub product: Option<String>,
pub revision: Option<String>,
}
/// How many coils / holding registers to request when enumerating the exposed
/// process surface. Read-only: a normal reply means the block exists and is,
/// over unauthenticated Modbus/TCP, also writable.
const ENUM_QTY: u16 = 16;
/// Probe a Modbus/TCP endpoint. Read-only: issues Read Holding Registers, Read
/// Coils, and Read Device Identification requests; never writes to the device.
pub async fn probe(host: &str, port: u16, budget: Duration) -> ModbusProbe {
let mut out = ModbusProbe::default();
let Ok(Ok(mut stream)) = timeout(budget, TcpStream::connect((host, port))).await else {
return out; // unreachable
};
out.reachable = true;
// Read Holding Registers (FC 0x03), unit 1, addr 0 — a benign read that also
// enumerates the exposed register block.
let rhr = [0x03u8, 0x00, 0x00, (ENUM_QTY >> 8) as u8, ENUM_QTY as u8];
if let Some(resp) = txn(&mut stream, 1, &rhr, budget).await {
// A normal reply (0x03) or an exception (0x83) both prove it speaks Modbus.
if matches!(resp.first(), Some(0x03) | Some(0x83)) {
out.speaks_modbus = true;
}
if resp.first() == Some(&0x03) {
out.holding_registers_readable = Some(register_count_from_reply(&resp));
}
}
// Read Coils (FC 0x01), addr 0 — enumerates the exposed coil (bit) block.
let rc = [0x01u8, 0x00, 0x00, (ENUM_QTY >> 8) as u8, ENUM_QTY as u8];
if let Some(resp) = txn(&mut stream, 1, &rc, budget).await {
if matches!(resp.first(), Some(0x01) | Some(0x81)) {
out.speaks_modbus = true;
}
if resp.first() == Some(&0x01) {
out.coils_readable = Some(coil_count_from_reply(&resp));
}
}
// Read Device Identification (FC 0x2B / MEI 0x0E), basic (0x01), object 0.
let rdi = [0x2Bu8, 0x0E, 0x01, 0x00];
if let Some(resp) = txn(&mut stream, 1, &rdi, budget).await {
if resp.first() == Some(&0x2B) {
out.speaks_modbus = true;
out.device = parse_device_id(&resp);
}
}
out
}
/// Coils reported by a Read Coils reply `[0x01, byte_count, data…]` (8 per byte).
fn coil_count_from_reply(pdu: &[u8]) -> u16 {
pdu.get(1).map(|&b| u16::from(b) * 8).unwrap_or(0)
}
/// Registers reported by a Read Holding Registers reply `[0x03, byte_count,
/// data…]` (2 bytes per register).
fn register_count_from_reply(pdu: &[u8]) -> u16 {
pdu.get(1).map(|&b| u16::from(b) / 2).unwrap_or(0)
}
/// Send one Modbus PDU and return the response PDU (function code + data), or
/// `None` on timeout / malformed reply.
async fn txn(stream: &mut TcpStream, unit: u8, pdu: &[u8], budget: Duration) -> Option<Vec<u8>> {
// MBAP header: transaction id (2) + protocol id (2) = 0 + length (2) + unit (1),
// then the PDU. `length` counts the unit byte plus the PDU.
let len = (pdu.len() + 1) as u16;
let mut frame = Vec::with_capacity(7 + pdu.len());
frame.extend_from_slice(&[0x00, 0x01]); // transaction id
frame.extend_from_slice(&[0x00, 0x00]); // protocol id
frame.extend_from_slice(&len.to_be_bytes());
frame.push(unit);
frame.extend_from_slice(pdu);
timeout(budget, stream.write_all(&frame)).await.ok()?.ok()?;
let mut hdr = [0u8; 7];
timeout(budget, stream.read_exact(&mut hdr))
.await
.ok()?
.ok()?;
// Reject non-Modbus replies (protocol id must be 0).
if hdr[2] != 0 || hdr[3] != 0 {
return None;
}
let plen = u16::from_be_bytes([hdr[4], hdr[5]]) as usize;
if !(2..=260).contains(&plen) {
return None;
}
let mut body = vec![0u8; plen - 1]; // minus the unit id already in hdr[6]
timeout(budget, stream.read_exact(&mut body))
.await
.ok()?
.ok()?;
Some(body)
}
/// Parse vendor / product / revision from a Read Device Identification PDU:
/// `[0x2B, 0x0E, readDevIdCode, conformity, moreFollows, nextObjId, numObjects,
/// (objId, len, bytes…)…]`.
fn parse_device_id(pdu: &[u8]) -> Option<DeviceId> {
if pdu.len() < 7 {
return None;
}
let num = pdu[6] as usize;
let mut i = 7;
let mut dev = DeviceId::default();
for _ in 0..num {
if i + 2 > pdu.len() {
break;
}
let id = pdu[i];
let l = pdu[i + 1] as usize;
i += 2;
if i + l > pdu.len() {
break;
}
let val = String::from_utf8_lossy(&pdu[i..i + l]).trim().to_string();
i += l;
match id {
0x00 => dev.vendor = Some(val),
0x01 => dev.product = Some(val),
0x02 => dev.revision = Some(val),
_ => {}
}
}
if dev == DeviceId::default() {
None
} else {
Some(dev)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener;
/// A one-shot mock Modbus/TCP server that answers a Read Holding Registers
/// request and a Read Device Identification request on one connection.
async fn mock_server(with_device: bool) -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.expect("accept");
loop {
let mut hdr = [0u8; 7];
if sock.read_exact(&mut hdr).await.is_err() {
break;
}
let plen = u16::from_be_bytes([hdr[4], hdr[5]]) as usize;
let mut pdu = vec![0u8; plen - 1];
if sock.read_exact(&mut pdu).await.is_err() {
break;
}
let reply_pdu: Vec<u8> = match pdu.first() {
Some(0x03) => vec![0x03, 0x02, 0x00, 0x00], // 1 register (byte_count 2)
Some(0x01) => vec![0x01, 0x02, 0xFF, 0xFF], // 16 coils (byte_count 2)
Some(0x2B) if with_device => vec![
0x2B, 0x0E, 0x01, 0x81, 0x00, 0x00, 0x02, // 2 objects
0x00, 0x04, b'A', b'C', b'M', b'E', // vendor
0x01, 0x03, b'P', b'L', b'C', // product
],
_ => vec![pdu[0] | 0x80, 0x01], // exception
};
let len = (reply_pdu.len() + 1) as u16;
let mut frame = vec![hdr[0], hdr[1], 0x00, 0x00];
frame.extend_from_slice(&len.to_be_bytes());
frame.push(hdr[6]);
frame.extend_from_slice(&reply_pdu);
if sock.write_all(&frame).await.is_err() {
break;
}
}
});
addr
}
#[tokio::test]
async fn probe_detects_a_modbus_endpoint_and_reads_device_id() {
let addr = mock_server(true).await;
let p = probe(&addr.ip().to_string(), addr.port(), Duration::from_secs(2)).await;
assert!(p.reachable && p.speaks_modbus);
let dev = p.device.expect("device id");
assert_eq!(dev.vendor.as_deref(), Some("ACME"));
assert_eq!(dev.product.as_deref(), Some("PLC"));
}
#[tokio::test]
async fn probe_enumerates_exposed_process_points() {
let addr = mock_server(false).await;
let p = probe(&addr.ip().to_string(), addr.port(), Duration::from_secs(2)).await;
assert!(p.speaks_modbus);
// The mock returns a 2-byte holding-register block (1 register) and a
// 2-byte coil block (16 coils).
assert_eq!(p.holding_registers_readable, Some(1));
assert_eq!(p.coils_readable, Some(16));
}
#[test]
fn reply_counts_decode_byte_counts() {
assert_eq!(register_count_from_reply(&[0x03, 0x08]), 4); // 8 bytes → 4 regs
assert_eq!(coil_count_from_reply(&[0x01, 0x03]), 24); // 3 bytes → 24 coils
assert_eq!(register_count_from_reply(&[0x03]), 0); // malformed → 0
}
#[tokio::test]
async fn probe_reports_unreachable_for_a_closed_port() {
// 127.0.0.1:1 is (almost certainly) closed.
let p = probe("127.0.0.1", 1, Duration::from_millis(500)).await;
assert!(!p.reachable && !p.speaks_modbus);
}
#[test]
fn parses_device_identification_objects() {
let pdu = [
0x2B, 0x0E, 0x01, 0x81, 0x00, 0x00, 0x01, // 1 object
0x02, 0x05, b'v', b'1', b'.', b'2', b'3', // revision
];
let dev = parse_device_id(&pdu).expect("device");
assert_eq!(dev.revision.as_deref(), Some("v1.23"));
assert!(dev.vendor.is_none());
}
}
+131
View File
@@ -0,0 +1,131 @@
//! Minimal OPC UA reachability probe.
//!
//! Speaks just the OPC UA Connection Protocol (UACP) handshake — a `HEL` (Hello)
//! message, expecting an `ACK` (or `ERR`) reply — to confirm an OPC UA server is
//! listening (default port 4840). It does **not** open a secure channel or make
//! service calls; deep analysis of the server's SecurityPolicy / user-token
//! policies (the common `None` + `Anonymous` misconfiguration) is a follow-on best
//! done with a full OPC UA stack.
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
/// Outcome of an OPC UA handshake probe.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct OpcUaProbe {
/// A TCP connection to the port was established.
pub reachable: bool,
/// The endpoint replied to the UACP Hello (`ACK`) or rejected it (`ERR`) —
/// either way it speaks OPC UA.
pub is_opcua: bool,
}
/// Probe an OPC UA endpoint with a UACP Hello. Read-only handshake only.
pub async fn probe(host: &str, port: u16, budget: Duration) -> OpcUaProbe {
let mut out = OpcUaProbe::default();
let Ok(Ok(mut stream)) = timeout(budget, TcpStream::connect((host, port))).await else {
return out;
};
out.reachable = true;
let hello = hello_message(&format!("opc.tcp://{host}:{port}"));
if timeout(budget, stream.write_all(&hello))
.await
.ok()
.and_then(Result::ok)
.is_none()
{
return out;
}
// Read the 3-byte message type of the reply: ACK (accepted) or ERR (rejected
// our hello) both prove the peer speaks the OPC UA connection protocol.
let mut mt = [0u8; 3];
if timeout(budget, stream.read_exact(&mut mt))
.await
.ok()
.and_then(Result::ok)
.is_none()
{
return out;
}
if &mt == b"ACK" || &mt == b"ERR" {
out.is_opcua = true;
}
out
}
/// Build a UACP `HEL` (Hello) message advertising our buffer sizes + endpoint URL.
fn hello_message(endpoint_url: &str) -> Vec<u8> {
let url = endpoint_url.as_bytes();
let mut m = Vec::with_capacity(32 + url.len());
m.extend_from_slice(b"HELF");
m.extend_from_slice(&0u32.to_le_bytes()); // message size — patched below
m.extend_from_slice(&0u32.to_le_bytes()); // ProtocolVersion
m.extend_from_slice(&65536u32.to_le_bytes()); // ReceiveBufferSize
m.extend_from_slice(&65536u32.to_le_bytes()); // SendBufferSize
m.extend_from_slice(&0u32.to_le_bytes()); // MaxMessageSize (0 = no limit)
m.extend_from_slice(&0u32.to_le_bytes()); // MaxChunkCount
m.extend_from_slice(&(url.len() as i32).to_le_bytes()); // EndpointUrl length
m.extend_from_slice(url);
let size = m.len() as u32;
m[4..8].copy_from_slice(&size.to_le_bytes());
m
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener;
/// A mock OPC UA server that reads the Hello and replies with an `ACK` frame.
async fn mock_server() -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.expect("accept");
// Read the Hello header (8 bytes) to learn the size, then drain it.
let mut hdr = [0u8; 8];
if sock.read_exact(&mut hdr).await.is_err() {
return;
}
let size = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]) as usize;
let mut rest = vec![0u8; size.saturating_sub(8)];
let _ = sock.read_exact(&mut rest).await;
// Reply: ACK + size + 5 u32 fields.
let mut ack = Vec::new();
ack.extend_from_slice(b"ACKF");
ack.extend_from_slice(&28u32.to_le_bytes());
for _ in 0..5 {
ack.extend_from_slice(&0u32.to_le_bytes());
}
let _ = sock.write_all(&ack).await;
});
addr
}
#[tokio::test]
async fn probe_detects_an_opcua_server() {
let addr = mock_server().await;
let p = probe(&addr.ip().to_string(), addr.port(), Duration::from_secs(2)).await;
assert!(p.reachable && p.is_opcua);
}
#[tokio::test]
async fn probe_reports_unreachable_for_a_closed_port() {
let p = probe("127.0.0.1", 1, Duration::from_millis(500)).await;
assert!(!p.reachable && !p.is_opcua);
}
#[test]
fn hello_message_is_well_formed() {
let m = hello_message("opc.tcp://h:4840");
assert_eq!(&m[0..4], b"HELF");
// The embedded size equals the actual length.
let size = u32::from_le_bytes([m[4], m[5], m[6], m[7]]) as usize;
assert_eq!(size, m.len());
}
}
+137
View File
@@ -0,0 +1,137 @@
//! TCP service discovery for a device.
//!
//! Connect-scans a curated set of OT/ICS and insecure-management ports and reports
//! the ones that are open. The deep protocol probes own Modbus (502), OPC UA
//! (4840) and EtherNet/IP (44818); this surfaces the *rest* of the industrial and
//! cleartext-management surface (Siemens S7, DNP3, CODESYS programming, Telnet, …).
use std::time::Duration;
use futures_util::future::join_all;
use tokio::net::TcpStream;
use tokio::time::timeout;
/// Whether an open port is an industrial protocol or an insecure management service.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortKind {
/// An industrial control protocol (typically unauthenticated).
Ics,
/// A cleartext management service (credentials/data in the clear).
InsecureMgmt,
}
/// A well-known port worth flagging when open.
#[derive(Debug, Clone, Copy)]
pub struct KnownPort {
pub port: u16,
pub service: &'static str,
pub kind: PortKind,
pub note: &'static str,
}
/// The curated scan list. Excludes 502 / 4840 / 44818 — those have dedicated deep
/// probes (Modbus, OPC UA, EtherNet/IP) that report richer findings.
pub const KNOWN_PORTS: &[KnownPort] = &[
KnownPort {
port: 102,
service: "S7comm / ISO-TSAP",
kind: PortKind::Ics,
note: "Siemens S7 PLC communication",
},
KnownPort {
port: 20000,
service: "DNP3",
kind: PortKind::Ics,
note: "SCADA / DNP3",
},
KnownPort {
port: 1911,
service: "Niagara Fox",
kind: PortKind::Ics,
note: "Tridium Niagara building automation",
},
KnownPort {
port: 11740,
service: "CODESYS",
kind: PortKind::Ics,
note: "CODESYS programming protocol",
},
KnownPort {
port: 1962,
service: "PCWorx",
kind: PortKind::Ics,
note: "Phoenix Contact PCWorx",
},
KnownPort {
port: 9600,
service: "OMRON FINS",
kind: PortKind::Ics,
note: "Omron FINS",
},
KnownPort {
port: 789,
service: "Red Lion Crimson",
kind: PortKind::Ics,
note: "Red Lion controllers",
},
KnownPort {
port: 23,
service: "Telnet",
kind: PortKind::InsecureMgmt,
note: "cleartext remote shell",
},
KnownPort {
port: 21,
service: "FTP",
kind: PortKind::InsecureMgmt,
note: "cleartext file transfer",
},
];
/// Connect-scan `ports` on `host` (concurrently) and return those that accept a
/// TCP connection.
pub async fn scan<'a>(host: &str, ports: &'a [KnownPort], budget: Duration) -> Vec<&'a KnownPort> {
let checks = ports.iter().map(|kp| async move {
let open = timeout(budget, TcpStream::connect((host, kp.port)))
.await
.map(|r| r.is_ok())
.unwrap_or(false);
(kp, open)
});
join_all(checks)
.await
.into_iter()
.filter_map(|(kp, open)| open.then_some(kp))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener;
#[tokio::test]
async fn scan_reports_only_open_ports() {
// Bind one port (open) and pick another that is closed.
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let open_port = listener.local_addr().expect("addr").port();
let ports = [
KnownPort {
port: open_port,
service: "test-open",
kind: PortKind::Ics,
note: "",
},
KnownPort {
port: 1,
service: "test-closed",
kind: PortKind::InsecureMgmt,
note: "",
},
];
let found = scan("127.0.0.1", &ports, Duration::from_millis(500)).await;
let services: Vec<&str> = found.iter().map(|p| p.service).collect();
assert_eq!(services, vec!["test-open"]);
}
}
+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;
+422
View File
@@ -0,0 +1,422 @@
//! 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"
);
}
}
+254
View File
@@ -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=<prog_file>` → `GET /start_plc`.
use std::time::Duration;
use crate::error::ExecError;
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<(), ExecError> {
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(|_| 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
/// 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<(), 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?;
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<(), ExecError> {
let resp = http
.post(format!("{base_url}/login"))
.form(&[("username", user), ("password", password)])
.send()
.await?;
if resp.status().is_server_error() {
return Err(ExecError::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<String, ExecError> {
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(|| {
ExecError::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<(), ExecError> {
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(ExecError::Other(format!(
"OpenPLC save-program failed: HTTP {}",
resp.status()
)));
}
Ok(())
}
/// `GET /compile-program?file=<prog_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<(), ExecError> {
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(ExecError::Other(
"OpenPLC compilation finished with errors".to_string(),
)),
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<(), ExecError> {
let resp = http.get(format!("{base_url}/start_plc")).send().await?;
if resp.status().is_server_error() {
return Err(ExecError::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<String> {
// The OpenPLC template renders `value='<name>.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 = "<form><input type='hidden' value='483927.st' id='prog_file' \
name='prog_file'/></form>";
assert_eq!(parse_prog_file(html), Some("483927.st".to_string()));
}
#[test]
fn parses_prog_file_name_then_value() {
let html = r#"<input name="prog_file" id="prog_file" value="12.st" />"#;
assert_eq!(parse_prog_file(html), Some("12.st".to_string()));
}
#[test]
fn parse_prog_file_none_when_absent() {
assert_eq!(parse_prog_file("<html>no form here</html>"), 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!"));
}
}
+306
View File
@@ -0,0 +1,306 @@
//! 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::ExecError;
/// 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<Output = Result<ProvisionedRuntime, ExecError>> + Send;
/// Tear an instance down. Best-effort and idempotent — never fails the scan.
fn teardown(&self, handle: &ProvisionedRuntime)
-> impl std::future::Future<Output = ()> + 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<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.
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(ExecError::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-<epoch>-<target12>-<rand6>`.
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<u64> {
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<String> {
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<String> {
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<String> {
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<std::process::Output, ExecError> {
tokio::process::Command::new("docker")
.args(args)
.output()
.await
.map_err(ExecError::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()));
}
}