//! 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 is implemented first; OPC UA / EtherNet-IP are follow-ons. pub mod modbus; use std::time::Duration; use compliance_core::models::{Finding, ScanType, Severity}; use crate::pipeline::dedup; /// Default Modbus/TCP port. const MODBUS_PORT: u16 = 502; /// Probe a PLC/SPS device's industrial-protocol surface and return findings. /// `endpoint` is the target's live-URL / host reference. pub async fn probe_target(endpoint: &str, repo_id: &str, budget: Duration) -> Vec { let (host, port) = parse_endpoint(endpoint); 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); } findings } /// 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) ); } }