CI / Check (pull_request) Successful in 5m16s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
Extends the ICS probe with OPC UA (port 4840), a common CODESYS runtime service. A read-only UACP handshake (Hello → Ack/Err) confirms an OPC UA server is listening and flags it for review — the finding notes the common insecure default (SecurityPolicy None + Anonymous user token) that allows unauthenticated, unencrypted access. - pipeline::ics::opcua — minimal UACP Hello/Ack probe (no secure channel). - probe_target now checks Modbus/TCP + OPC UA (no longer early-returns on no-Modbus); emits `ics-opcua-exposed` (Medium, CWE-319). Deep SecurityPolicy / user-token analysis via a full OPC UA stack is a follow-on. Unit-tested against an in-process mock OPC UA server. Tracker #167. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
186 lines
7.3 KiB
Rust
186 lines
7.3 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 modbus;
|
|
pub mod opcua;
|
|
|
|
use std::time::Duration;
|
|
|
|
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).
|
|
const MODBUS_PORT: u16 = 502;
|
|
const OPCUA_PORT: u16 = 4840;
|
|
|
|
/// 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.
|
|
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
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
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
|
|
}
|
|
|
|
/// 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)
|
|
);
|
|
}
|
|
}
|