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
+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"]);
}
}