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
307 lines
13 KiB
Rust
307 lines
13 KiB
Rust
//! 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)
|
|
);
|
|
}
|
|
}
|