feat(plc): ephemeral soft-PLC provisioning + program load (#183)
CI / Check (pull_request) Failing after 1m31s
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
CI / Check (pull_request) Failing after 1m31s
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
Dynamic PLC testing without reaching the customer's device: when a PLC/SPS target ships control logic but no reachable live URL, instantiate that logic ourselves on a throwaway OpenPLC container in-cluster, load + start it, probe the provisioned Modbus endpoint, and tear it down. No customer network access, sandboxed, and reproducible. This is the phase-1 foundation of epic #183 (OpenPLC substrate). It covers: - provision: ephemeral container lifecycle (docker CLI). Resource-capped (memory/cpus/pids), hardened (no-new-privileges), labelled, joined to the agent's own network with no host port exposure, and swept by a stale reaper for anything a crashed run leaks. The `docker` argv is built by pure functions so it is unit-tested without a daemon. - openplc: drives the OpenPLC web UI to load a program — login → upload → save → compile (MatIEC) → start_plc (which opens Modbus/TCP 502). - runtime::provision_and_test: composes them under a hard deadline with guaranteed teardown on every path (success / error / timeout), then runs the existing ICS probe against the provisioned endpoint. extract_program picks the best loadable program (complete ST > largest ST > PLCopen XML). - orchestrator: for a PlcSps target with control logic and no live URL, run provision-and-test after the static PLC scan. Gated by PlcRuntimeConfig (PLC_RUNTIME_ENABLED, default off — needs Docker access in the agent). DAST-against-WebVisu and CODESYS-runtime fidelity are follow-ups. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1aba85b28e
commit
1ae6025286
@@ -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,77 @@ 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 findings = crate::pipeline::plc::runtime::provision_and_test(
|
||||
&provisioner,
|
||||
&http,
|
||||
&self.config.plc_runtime,
|
||||
&program,
|
||||
target_id,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
target_id,
|
||||
found = findings.len(),
|
||||
"provision-and-test 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)
|
||||
}
|
||||
|
||||
/// 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