//! 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 { 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 { 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::>() .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 { 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 { 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. 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::().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) ); } }