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
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:
@@ -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
|
||||
|
||||
@@ -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,95 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
//! 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::pipeline::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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
//! 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"]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
//! 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::AgentError;
|
||||
|
||||
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, AgentError> {
|
||||
reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(AgentError::Http)
|
||||
}
|
||||
|
||||
/// Pick the control-logic program to run from an ingested PLC source tree.
|
||||
///
|
||||
/// OpenPLC runs one program, so we choose the best single candidate: a complete
|
||||
/// Structured Text program (one carrying a `CONFIGURATION` block) is ideal;
|
||||
/// failing that the largest ST file; failing that a PLCopen XML export. Returns
|
||||
/// `None` when the tree holds no loadable control logic.
|
||||
pub fn extract_program(root: &Path) -> Option<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, AgentError> {
|
||||
let handle = provisioner.provision(target_id).await?;
|
||||
tracing::info!(
|
||||
target_id,
|
||||
instance = %handle.name,
|
||||
modbus = %handle.modbus_endpoint,
|
||||
"provisioned ephemeral soft-PLC"
|
||||
);
|
||||
|
||||
let deadline = Duration::from_secs(cfg.max_lifetime_secs);
|
||||
let result = tokio::time::timeout(
|
||||
deadline,
|
||||
run_dynamic_test(http, cfg, program, target_id, &handle),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Guaranteed teardown — runs on success, error, and deadline expiry. The
|
||||
// inner future is panic-free (the workspace lint bans unwrap/expect), so no
|
||||
// unwind can skip this; a container leaked by an agent *crash* is swept by
|
||||
// the next run's stale reaper.
|
||||
provisioner.teardown(&handle).await;
|
||||
|
||||
match result {
|
||||
Ok(inner) => inner,
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
target_id,
|
||||
instance = %handle.name,
|
||||
"provision-and-test hit the lifetime deadline; torn down"
|
||||
);
|
||||
Ok(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, AgentError> {
|
||||
let ready_budget = Duration::from_secs((cfg.max_lifetime_secs / 3).clamp(10, 60));
|
||||
openplc::wait_ready(http, &handle.webvisu_url, ready_budget).await?;
|
||||
|
||||
let compile_budget = Duration::from_secs((cfg.max_lifetime_secs / 2).clamp(20, 120));
|
||||
openplc::load_and_start(
|
||||
http,
|
||||
&handle.webvisu_url,
|
||||
&cfg.openplc_user,
|
||||
cfg.openplc_password.expose_secret(),
|
||||
program,
|
||||
compile_budget,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Give the runtime a moment to open the Modbus/TCP server before probing.
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
let probe_budget = Duration::from_secs(5);
|
||||
let findings =
|
||||
crate::pipeline::ics::probe_target(&handle.modbus_endpoint, target_id, probe_budget).await;
|
||||
tracing::info!(
|
||||
target_id,
|
||||
instance = %handle.name,
|
||||
found = findings.len(),
|
||||
"provision-and-test probe complete"
|
||||
);
|
||||
|
||||
// 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, AgentError> {
|
||||
self.provisions.fetch_add(1, Ordering::SeqCst);
|
||||
if self.fail_provision {
|
||||
return Err(AgentError::Other("provision failed".into()));
|
||||
}
|
||||
// Unreachable address so run_dynamic_test blocks on readiness until the
|
||||
// deadline fires — exercising the teardown-on-deadline path.
|
||||
Ok(ProvisionedRuntime {
|
||||
name: "fake-plc".into(),
|
||||
modbus_endpoint: "fake-plc:502".into(),
|
||||
webvisu_url: "http://fake-plc.invalid:8080".into(),
|
||||
})
|
||||
}
|
||||
async fn teardown(&self, _handle: &ProvisionedRuntime) {
|
||||
self.teardowns.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn short_cfg() -> PlcRuntimeConfig {
|
||||
PlcRuntimeConfig {
|
||||
enabled: true,
|
||||
max_lifetime_secs: 1, // keep the deadline path fast
|
||||
..PlcRuntimeConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn teardown_runs_even_when_the_test_never_completes() {
|
||||
let provisions = Arc::new(AtomicUsize::new(0));
|
||||
let teardowns = Arc::new(AtomicUsize::new(0));
|
||||
let fake = FakeSoftPlc {
|
||||
provisions: provisions.clone(),
|
||||
teardowns: teardowns.clone(),
|
||||
fail_provision: false,
|
||||
};
|
||||
let http = http_client().expect("client");
|
||||
let prog = PlcProgram {
|
||||
file_name: "p.st".into(),
|
||||
source: "PROGRAM P\nEND_PROGRAM\n".into(),
|
||||
};
|
||||
let out = provision_and_test(&fake, &http, &short_cfg(), &prog, "t1")
|
||||
.await
|
||||
.expect("ok on deadline");
|
||||
assert!(out.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
//! 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::AgentError;
|
||||
|
||||
use super::PlcProgram;
|
||||
|
||||
/// Default OpenPLC program name/description recorded in its UI.
|
||||
const PROG_NAME: &str = "certifai-provisioned";
|
||||
const PROG_DESCR: &str = "Uploaded by the Certifai provision-and-test scan";
|
||||
|
||||
/// Poll interval while waiting for readiness / compilation.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Wait until the OpenPLC web UI answers (any non-5xx reply to `/login`), or the
|
||||
/// budget elapses. A freshly-started container needs a few seconds to boot.
|
||||
pub async fn wait_ready(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
budget: Duration,
|
||||
) -> Result<(), AgentError> {
|
||||
let login = format!("{base_url}/login");
|
||||
let outcome = tokio::time::timeout(budget, async {
|
||||
loop {
|
||||
if let Ok(resp) = http.get(&login).send().await {
|
||||
if !resp.status().is_server_error() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
outcome.map_err(|_| AgentError::Other(format!("OpenPLC at {base_url} did not become ready")))
|
||||
}
|
||||
|
||||
/// Log in, upload the program, compile it, and start the runtime. On success the
|
||||
/// OpenPLC Modbus/TCP server is listening on 502.
|
||||
pub async fn load_and_start(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
user: &str,
|
||||
password: &str,
|
||||
program: &PlcProgram,
|
||||
compile_budget: Duration,
|
||||
) -> Result<(), AgentError> {
|
||||
login(http, base_url, user, password).await?;
|
||||
let prog_file = upload_program(http, base_url, program).await?;
|
||||
save_program(http, base_url, &prog_file).await?;
|
||||
compile(http, base_url, &prog_file, compile_budget).await?;
|
||||
start(http, base_url).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `POST /login` — establishes the session cookie (the client must have a cookie
|
||||
/// store; see the provision-and-test entry point).
|
||||
async fn login(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
user: &str,
|
||||
password: &str,
|
||||
) -> Result<(), AgentError> {
|
||||
let resp = http
|
||||
.post(format!("{base_url}/login"))
|
||||
.form(&[("username", user), ("password", password)])
|
||||
.send()
|
||||
.await?;
|
||||
if resp.status().is_server_error() {
|
||||
return Err(AgentError::Other(format!(
|
||||
"OpenPLC login failed: HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `POST /upload-program` (multipart `file`) — OpenPLC stores the program under a
|
||||
/// server-assigned name and returns it in a hidden `prog_file` form field, which
|
||||
/// we parse out for the follow-up save/compile steps.
|
||||
async fn upload_program(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
program: &PlcProgram,
|
||||
) -> Result<String, AgentError> {
|
||||
let part = reqwest::multipart::Part::text(program.source.clone())
|
||||
.file_name(program.file_name.clone())
|
||||
.mime_str("application/octet-stream")?;
|
||||
let form = reqwest::multipart::Form::new().part("file", part);
|
||||
let resp = http
|
||||
.post(format!("{base_url}/upload-program"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
let html = resp.text().await?;
|
||||
parse_prog_file(&html).ok_or_else(|| {
|
||||
AgentError::Other("OpenPLC upload did not return a prog_file handle".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// `POST /upload-program-action` — records the uploaded program in OpenPLC's
|
||||
/// program list. `epoch_time` must be close to the server's clock (OpenPLC
|
||||
/// rejects stale timestamps), so we send the current time.
|
||||
async fn save_program(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
prog_file: &str,
|
||||
) -> Result<(), AgentError> {
|
||||
let epoch = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
.to_string();
|
||||
let resp = http
|
||||
.post(format!("{base_url}/upload-program-action"))
|
||||
.form(&[
|
||||
("prog_name", PROG_NAME),
|
||||
("prog_descr", PROG_DESCR),
|
||||
("prog_file", prog_file),
|
||||
("epoch_time", &epoch),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
if resp.status().is_server_error() {
|
||||
return Err(AgentError::Other(format!(
|
||||
"OpenPLC save-program failed: HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `GET /compile-program?file=<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<(), AgentError> {
|
||||
http.get(format!("{base_url}/compile-program"))
|
||||
.query(&[("file", prog_file)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let logs_url = format!("{base_url}/compilation-logs");
|
||||
let outcome = tokio::time::timeout(budget, async {
|
||||
loop {
|
||||
if let Ok(resp) = http.get(&logs_url).send().await {
|
||||
if let Ok(text) = resp.text().await {
|
||||
if compilation_finished(&text) {
|
||||
return !compilation_failed(&text);
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match outcome {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => Err(AgentError::Other(
|
||||
"OpenPLC compilation finished with errors".to_string(),
|
||||
)),
|
||||
Err(_) => Err(AgentError::Other(
|
||||
"OpenPLC compilation did not finish in time".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /start_plc` — starts the runtime, opening Modbus/TCP on 502.
|
||||
async fn start(http: &reqwest::Client, base_url: &str) -> Result<(), AgentError> {
|
||||
let resp = http.get(format!("{base_url}/start_plc")).send().await?;
|
||||
if resp.status().is_server_error() {
|
||||
return Err(AgentError::Other(format!(
|
||||
"OpenPLC start_plc failed: HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract the server-assigned `prog_file` from the `/upload-program` response,
|
||||
/// which embeds it in a hidden input. Attribute order varies, so accept both
|
||||
/// `value=… name='prog_file'` and `name='prog_file' … value=…`.
|
||||
fn parse_prog_file(html: &str) -> Option<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!"));
|
||||
}
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
//! Ephemeral soft-PLC container lifecycle (#183, sub-task 1 + 5).
|
||||
//!
|
||||
//! Provisions a throwaway OpenPLC container per scan, isolated on the agent's own
|
||||
//! Docker network with hard resource caps and **no host port exposure**, then
|
||||
//! guarantees teardown. The container is reachable in-cluster only, by its name
|
||||
//! (the shared user-defined network's embedded DNS resolves it); it is never
|
||||
//! published to the host.
|
||||
//!
|
||||
//! The `docker` argv is produced by pure functions so provisioning is unit-tested
|
||||
//! without a Docker daemon — only the thin [`run_docker`] wrapper touches the OS.
|
||||
//! It requires the agent's runtime to have Docker access (a socket mount), which
|
||||
//! is why the whole path is gated behind [`PlcRuntimeConfig::enabled`].
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use compliance_core::PlcRuntimeConfig;
|
||||
|
||||
use crate::error::AgentError;
|
||||
|
||||
/// The Modbus/TCP port an OpenPLC instance opens once a program is running.
|
||||
const MODBUS_PORT: u16 = 502;
|
||||
/// The OpenPLC web-UI / WebVisu port.
|
||||
const WEBVISU_PORT: u16 = 8080;
|
||||
|
||||
/// Label key marking a container as an ephemeral PLC runtime we own.
|
||||
const OWNER_LABEL_KEY: &str = "certifai.ephemeral";
|
||||
/// Label value for our ephemeral PLC runtimes.
|
||||
const OWNER_LABEL_VALUE: &str = "plc-runtime";
|
||||
|
||||
/// A running ephemeral soft-PLC instance. Reachable in-cluster by `name`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProvisionedRuntime {
|
||||
/// The container name — also its in-network DNS alias.
|
||||
pub name: String,
|
||||
/// `name:502` — the Modbus/TCP endpoint the ICS probe targets.
|
||||
pub modbus_endpoint: String,
|
||||
/// `http://name:8080` — the WebVisu / OpenPLC web UI.
|
||||
pub webvisu_url: String,
|
||||
}
|
||||
|
||||
/// A source of ephemeral soft-PLC instances. Abstracted so the provision-and-test
|
||||
/// orchestration is unit-testable with a fake that never touches Docker.
|
||||
pub trait SoftPlc {
|
||||
/// Start a fresh instance for a target and return its handle.
|
||||
fn provision(
|
||||
&self,
|
||||
target_id: &str,
|
||||
) -> impl std::future::Future<Output = Result<ProvisionedRuntime, AgentError>> + 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, AgentError> {
|
||||
// Best-effort sweep of any container leaked by a crashed earlier run
|
||||
// before we add another. Only removes instances past their max lifetime,
|
||||
// so it can never disturb a concurrent run.
|
||||
reap_stale(&self.cfg, now_epoch()).await;
|
||||
|
||||
let name = instance_name(target_id, now_epoch(), &random_suffix());
|
||||
let args = run_args(&self.cfg, &name, target_id);
|
||||
let out = run_docker(&args).await?;
|
||||
if !out.status.success() {
|
||||
return Err(AgentError::Other(format!(
|
||||
"docker run for soft-PLC {name} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
)));
|
||||
}
|
||||
Ok(ProvisionedRuntime {
|
||||
modbus_endpoint: format!("{name}:{MODBUS_PORT}"),
|
||||
webvisu_url: format!("http://{name}:{WEBVISU_PORT}"),
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
async fn teardown(&self, handle: &ProvisionedRuntime) {
|
||||
match run_docker(&rm_args(&handle.name)).await {
|
||||
Ok(out) if out.status.success() => {
|
||||
tracing::info!(instance = %handle.name, "soft-PLC instance torn down");
|
||||
}
|
||||
Ok(out) => tracing::warn!(
|
||||
instance = %handle.name,
|
||||
"soft-PLC teardown non-zero exit: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!(instance = %handle.name, error = %e, "soft-PLC teardown failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch (0 if the clock is before 1970, which never
|
||||
/// happens in practice).
|
||||
fn now_epoch() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A short random, docker-name-safe suffix.
|
||||
fn random_suffix() -> String {
|
||||
uuid::Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
/// A unique, docker-safe container name that encodes the creation epoch (for the
|
||||
/// stale reaper) and the target it belongs to. Shape:
|
||||
/// `certifai-plc-<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, AgentError> {
|
||||
tokio::process::Command::new("docker")
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AgentError::Io)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> PlcRuntimeConfig {
|
||||
PlcRuntimeConfig {
|
||||
enabled: true,
|
||||
image: "registry.example.com/openplc:latest".into(),
|
||||
network: "certifai".into(),
|
||||
memory: "512m".into(),
|
||||
cpus: "0.5".into(),
|
||||
max_lifetime_secs: 180,
|
||||
..PlcRuntimeConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_name_is_unique_docker_safe_and_reaper_parseable() {
|
||||
let a = instance_name("64f0aabbccddeeff00112233", 1_700_000_000, "abcdef123456");
|
||||
assert_eq!(a, "certifai-plc-1700000000-64f0aabbccdd-abcdef");
|
||||
assert_eq!(parse_epoch(&a), Some(1_700_000_000));
|
||||
// Docker names: only [A-Za-z0-9_.-].
|
||||
assert!(a
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')));
|
||||
// A different random suffix yields a different name for the same target.
|
||||
let b = instance_name("64f0aabbccddeeff00112233", 1_700_000_000, "zzzzzz999999");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_epoch_rejects_foreign_names() {
|
||||
assert_eq!(parse_epoch("some-other-container"), None);
|
||||
assert_eq!(parse_epoch("certifai-plc-notanumber-x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_args_cap_resources_harden_label_and_never_publish_a_port() {
|
||||
let args = run_args(&cfg(), "certifai-plc-1-t-r", "target-123");
|
||||
// No host port publishing.
|
||||
assert!(!args.iter().any(|a| a == "-p" || a == "--publish"));
|
||||
// Detached.
|
||||
assert!(args.contains(&"-d".to_string()));
|
||||
// Joined to the agent's own network.
|
||||
let net = args.iter().position(|a| a == "--network").expect("network");
|
||||
assert_eq!(args[net + 1], "certifai");
|
||||
// Resource caps.
|
||||
let mem = args.iter().position(|a| a == "--memory").expect("memory");
|
||||
assert_eq!(args[mem + 1], "512m");
|
||||
let cpu = args.iter().position(|a| a == "--cpus").expect("cpus");
|
||||
assert_eq!(args[cpu + 1], "0.5");
|
||||
assert!(args.iter().any(|a| a == "--pids-limit"));
|
||||
// Hardening.
|
||||
let so = args
|
||||
.iter()
|
||||
.position(|a| a == "--security-opt")
|
||||
.expect("secopt");
|
||||
assert_eq!(args[so + 1], "no-new-privileges");
|
||||
// Ownership + target labels for reaping / attribution.
|
||||
assert!(args.contains(&"certifai.ephemeral=plc-runtime".to_string()));
|
||||
assert!(args.contains(&"certifai.target=target-123".to_string()));
|
||||
// Image is last.
|
||||
assert_eq!(
|
||||
args.last().map(String::as_str),
|
||||
Some("registry.example.com/openplc:latest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rm_args_force_remove() {
|
||||
assert_eq!(rm_args("x"), vec!["rm", "-f", "x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reap_list_filters_by_owner_label() {
|
||||
let args = reap_list_args();
|
||||
assert!(args.contains(&"label=certifai.ephemeral=plc-runtime".to_string()));
|
||||
assert!(args.contains(&"{{.Names}}".to_string()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user