feat(plc): dynamic PLC testing via ephemeral soft-PLC (#193)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m55s
CI / Deploy Dashboard (push) Successful in 2m37s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m49s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m55s
CI / Deploy Dashboard (push) Successful in 2m37s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m49s
This commit was merged in pull request #193.
This commit is contained in:
@@ -404,6 +404,21 @@ impl PipelineOrchestrator {
|
||||
let ics = plan.has(ScanType::IcsProbe);
|
||||
if plc {
|
||||
new_count += self.run_plc_scan(target, &target_id, scan_run_id).await?;
|
||||
// Provision-and-test (#183): with the control logic but no reachable
|
||||
// device, instantiate it on an ephemeral soft-PLC and probe that
|
||||
// instead of the customer's OT network. Opt-in (needs Docker) and only
|
||||
// when there is no live URL to probe directly. Never fails the scan.
|
||||
if self.config.plc_runtime.enabled && target.live_url().is_none() {
|
||||
match self
|
||||
.run_provisioned_plc_test(target, &target_id, scan_run_id)
|
||||
.await
|
||||
{
|
||||
Ok(n) => new_count += n,
|
||||
Err(e) => {
|
||||
tracing::warn!(target_id = %target_id, error = %e, "provision-and-test failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ics {
|
||||
new_count += self.run_ics_probe(target, &target_id, scan_run_id).await?;
|
||||
@@ -540,6 +555,98 @@ impl PipelineOrchestrator {
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// Provision-and-test (#183): instantiate the target's control logic on an
|
||||
/// ephemeral soft-PLC (OpenPLC), start it, probe the provisioned Modbus
|
||||
/// endpoint, and tear the instance down. Used when a PLC/SPS target has the
|
||||
/// control logic but no reachable live device to probe directly. Guarded by
|
||||
/// `plc_runtime.enabled` (needs Docker); persists the same [`ScanType::IcsProbe`]
|
||||
/// findings as a live probe.
|
||||
async fn run_provisioned_plc_test(
|
||||
&self,
|
||||
target: &OnboardedTarget,
|
||||
target_id: &str,
|
||||
scan_run_id: &str,
|
||||
) -> Result<u32, AgentError> {
|
||||
self.update_phase(scan_run_id, "plc_provision").await;
|
||||
|
||||
// Locate a loadable control-logic program among the PLC-source artifacts
|
||||
// (same selection as the static PLC scan: dedicated PLC projects plus code
|
||||
// artifacts holding PLCopen XML / ST exports).
|
||||
let ctx = crate::ingest::IngestContext::from_config(&self.config, target_id);
|
||||
let ingest_set = crate::ingest::ingest_all(target, &ctx)?;
|
||||
let program = target
|
||||
.artifacts
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
matches!(
|
||||
a.kind,
|
||||
ArtifactKind::PlcProject | ArtifactKind::GitRepo | ArtifactKind::SourceArchive
|
||||
)
|
||||
})
|
||||
.find_map(|a| {
|
||||
let path = ingest_set
|
||||
.get(&a.id)
|
||||
.and_then(|ia| ia.working_path.clone())?;
|
||||
crate::pipeline::plc::runtime::extract_program(&path)
|
||||
});
|
||||
let Some(program) = program else {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
"provision-and-test: no loadable control-logic program"
|
||||
);
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
let http = crate::pipeline::plc::runtime::http_client()?;
|
||||
let provisioner =
|
||||
crate::pipeline::plc::runtime::DockerSoftPlc::new(self.config.plc_runtime.clone());
|
||||
let outcome = crate::pipeline::plc::runtime::provision_and_test(
|
||||
&provisioner,
|
||||
&http,
|
||||
&self.config.plc_runtime,
|
||||
&program,
|
||||
target_id,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
target_id,
|
||||
found = outcome.findings.len(),
|
||||
dast = outcome.dast.is_some(),
|
||||
"provision-and-test complete"
|
||||
);
|
||||
|
||||
let mut new_count = 0u32;
|
||||
for mut finding in outcome.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;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the DAST scan of the provisioned web endpoint, linked to this
|
||||
// scan run (mirrors `maybe_trigger_dast`).
|
||||
if let Some(dast) = outcome.dast {
|
||||
let mut scan_run = dast.scan_run;
|
||||
scan_run.sast_scan_run_id = Some(scan_run_id.to_string());
|
||||
if let Err(e) = self.db.dast_scan_runs().insert_one(&scan_run).await {
|
||||
tracing::warn!(target_id, error = %e, "failed to store provisioned DAST scan run");
|
||||
}
|
||||
for finding in &dast.findings {
|
||||
if let Err(e) = self.db.dast_findings().insert_one(finding).await {
|
||||
tracing::warn!(target_id, error = %e, "failed to store provisioned DAST finding");
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user