CI / Check (pull_request) Failing after 4m3s
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
Adds the first dynamic dimension to PLC/SPS targets: probe the running device over industrial protocols, complementing the static control-logic rules. - New ScanType::IcsProbe (+ phase), offered for PlcSps with a reachable endpoint (opt-in / default-off). - pipeline::ics::modbus — a minimal, read-only Modbus/TCP client: issues Read Holding Registers + Read Device Identification, never writes to the live process. Detects an endpoint that answers unauthenticated Modbus and reads its device identity (vendor/product/revision). - pipeline::ics::probe_target — emits findings: `ics-modbus-exposed` (Critical, CWE-306 — Modbus/TCP has no auth/encryption by protocol design) and `ics-device-disclosure` (Low, CWE-200). Targets the Modbus port (502) of the target's Live URL, independent of any WebVisu HTTP port. - orchestrator: a PLC/SPS target runs the ICS probe when planned (alongside the control-logic scan and DAST). Unit-tested against an in-process mock Modbus server + endpoint-parsing and device-id parsing tests. Docs: new "Dynamic testing — ICS protocol probe" section. First increment of #148 (soft-PLC + industrial-protocol probing); OPC UA / EtherNet-IP and the OpenPLC soft-PLC harness (orca-infra) follow. Tracker #167. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
5.2 KiB
Rust
139 lines
5.2 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 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<Finding> {
|
|
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::<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
|
|
}
|
|
|
|
/// 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)
|
|
);
|
|
}
|
|
}
|