feat(ics): dynamic Modbus/TCP probe for PLC/SPS devices (#172)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
This commit was merged in pull request #172.
This commit is contained in:
@@ -455,8 +455,18 @@ impl PipelineOrchestrator {
|
||||
// SAST pipeline over it. A PLC device is reachable, so DAST still runs
|
||||
// against a WebVisu / exposed endpoint when one is provisioned.
|
||||
let mut new_count = 0u32;
|
||||
if plan.has(ScanType::PlcControlLogic) {
|
||||
let plc = plan.has(ScanType::PlcControlLogic);
|
||||
let ics = plan.has(ScanType::IcsProbe);
|
||||
if plc {
|
||||
new_count += self.run_plc_scan(target, &target_id, scan_run_id).await?;
|
||||
}
|
||||
if ics {
|
||||
new_count += self.run_ics_probe(target, &target_id, scan_run_id).await?;
|
||||
}
|
||||
if plc || ics {
|
||||
// PLC/SPS device: also DAST against a WebVisu / exposed endpoint. The
|
||||
// control-logic scan already consumed the code artifact, so the SAST
|
||||
// pipeline is not re-run.
|
||||
self.update_phase(scan_run_id, "dast_scanning").await;
|
||||
self.maybe_trigger_dast(&target_id, scan_run_id).await;
|
||||
return Ok(new_count);
|
||||
@@ -565,6 +575,46 @@ impl PipelineOrchestrator {
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// Probe a running PLC/SPS device over industrial protocols (Modbus/TCP, …)
|
||||
/// and persist findings for exposed / unauthenticated control access. The
|
||||
/// probe is read-only; it targets the Modbus port of the target's live URL.
|
||||
async fn run_ics_probe(
|
||||
&self,
|
||||
target: &OnboardedTarget,
|
||||
target_id: &str,
|
||||
scan_run_id: &str,
|
||||
) -> Result<u32, AgentError> {
|
||||
self.update_phase(scan_run_id, "ics_probe").await;
|
||||
let Some(endpoint) = target.live_url().map(|a| a.source_ref.clone()) else {
|
||||
tracing::warn!(target_id, "ICS probe: no live URL");
|
||||
return Ok(0);
|
||||
};
|
||||
// Short per-request budget so an unreachable device doesn't stall the scan.
|
||||
let budget = std::time::Duration::from_secs(5);
|
||||
let findings = crate::pipeline::ics::probe_target(&endpoint, target_id, budget).await;
|
||||
tracing::info!(
|
||||
target_id,
|
||||
endpoint = %endpoint,
|
||||
found = findings.len(),
|
||||
"ICS probe complete"
|
||||
);
|
||||
let mut new_count = 0u32;
|
||||
for mut finding in findings {
|
||||
finding.scan_run_id = Some(scan_run_id.to_string());
|
||||
if self
|
||||
.db
|
||||
.findings()
|
||||
.find_one(doc! { "fingerprint": &finding.fingerprint })
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
self.db.findings().insert_one(&finding).await?;
|
||||
new_count += 1;
|
||||
}
|
||||
}
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// Store a control-application SBOM (CODESYS libraries + runtime) for a target
|
||||
/// and match it against known CVEs. Scoped to `package_manager = "codesys"` so
|
||||
/// it refreshes on re-scan and coexists with any firmware/source SBOM. The
|
||||
|
||||
Reference in New Issue
Block a user