feat(ics): OPC UA reachability probe (#148)
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>
This commit is contained in:
Sharang Parnerkar
2026-07-16 18:29:43 +02:00
co-authored by Claude Opus 4.8
parent 77eced8314
commit 306f7c5616
2 changed files with 184 additions and 6 deletions
+53 -6
View File
@@ -3,9 +3,10 @@
//! 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.
//! process. Modbus/TCP and OPC UA are implemented; EtherNet-IP is a follow-on.
pub mod modbus;
pub mod opcua;
use std::time::Duration;
@@ -13,14 +14,23 @@ use compliance_core::models::{Finding, ScanType, Severity};
use crate::pipeline::dedup;
/// Default Modbus/TCP port.
/// 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 and return findings.
/// `endpoint` is the target's live-URL / host reference.
/// 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, port) = parse_endpoint(endpoint);
let probe = modbus::probe(&host, port, budget).await;
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.
@@ -90,6 +100,43 @@ pub async fn probe_target(endpoint: &str, repo_id: &str, budget: Duration) -> Ve
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.