From a959e8619458d844a663921b237ae5e24b4efc6f Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:40:52 +0200 Subject: [PATCH] feat(ics): EtherNet/IP probe + OT/IT service-discovery port scan (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds out the ICS probe's protocol coverage and adds recon. - pipeline::ics::ethernetip — read-only EtherNet/IP (CIP) reachability probe: a ListIdentity encapsulation request on 44818; emits `ics-ethernetip-exposed` (High, CWE-306). - pipeline::ics::portscan — concurrent TCP connect-scan of a curated OT/ICS + insecure-management port set (S7 102, DNP3 20000, Niagara Fox 1911, CODESYS 11740, PCWorx, OMRON FINS, Red Lion, Telnet 23, FTP 21), excluding the deep-probed 502/4840/44818. Emits `ics-service-exposed` per open port (ICS → High/CWE-306, cleartext mgmt → Medium/CWE-319). - probe_target now runs Modbus + OPC UA + EtherNet/IP deep probes plus the service scan. Unit-tested against in-process mock servers + a bound-port scan. This completes the dynamic ICS probing scope of #148; the OpenPLC/QEMU harnesses are scaffolded in orca-infra for the infra pass. Tracker #167. Co-Authored-By: Claude Opus 4.8 --- .../src/pipeline/ics/ethernetip.rs | 95 ++++++++++++ compliance-agent/src/pipeline/ics/mod.rs | 96 +++++++++++- compliance-agent/src/pipeline/ics/portscan.rs | 137 ++++++++++++++++++ 3 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 compliance-agent/src/pipeline/ics/ethernetip.rs create mode 100644 compliance-agent/src/pipeline/ics/portscan.rs diff --git a/compliance-agent/src/pipeline/ics/ethernetip.rs b/compliance-agent/src/pipeline/ics/ethernetip.rs new file mode 100644 index 0000000..0bb37bf --- /dev/null +++ b/compliance-agent/src/pipeline/ics/ethernetip.rs @@ -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); + } +} diff --git a/compliance-agent/src/pipeline/ics/mod.rs b/compliance-agent/src/pipeline/ics/mod.rs index 31c47f6..3630a73 100644 --- a/compliance-agent/src/pipeline/ics/mod.rs +++ b/compliance-agent/src/pipeline/ics/mod.rs @@ -5,8 +5,10 @@ //! 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; @@ -14,17 +16,21 @@ use compliance_core::models::{Finding, ScanType, Severity}; use crate::pipeline::dedup; -/// Default Modbus/TCP and OPC UA ports (each on its own well-known port, -/// independent of any WebVisu HTTP port). +/// 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 (Modbus/TCP + OPC UA) and -/// return findings. Read-only. `endpoint` is the target's live-URL / host reference. +/// 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 { 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 } @@ -137,6 +143,88 @@ async fn opcua_findings(host: &str, port: u16, repo_id: &str, budget: Duration) 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 { + 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 { + 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. diff --git a/compliance-agent/src/pipeline/ics/portscan.rs b/compliance-agent/src/pipeline/ics/portscan.rs new file mode 100644 index 0000000..5c1eedd --- /dev/null +++ b/compliance-agent/src/pipeline/ics/portscan.rs @@ -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"]); + } +} -- 2.54.0