Phase-1 foundation of epic #183 — dynamic PLC testing via an ephemeral soft-PLC.
What & why
Dynamic testing of a customer's PLC currently needs to reach the running device (a modbus:// / http:// live URL). A real device sits on the customer's OT network, unreachable from our cloud. Provision-and-test removes that requirement: since we already ingest the control-logic artifact for SAST, we instantiate that logic ourselves on a throwaway soft-PLC (OpenPLC) in-cluster, start it, probe it, and tear it down. No customer network access, sandboxed, reproducible, and destructive tests become safe.
Model
control-logic artifact (ST / PLCopen XML)
→ provision an ephemeral OpenPLC container (in-cluster)
→ login → upload → compile (MatIEC) → start_plc (Modbus/TCP 502 opens)
→ run the existing ICS probe against the provisioned endpoint
→ guaranteed teardown
Changes
plc/runtime/provision.rs — ephemeral container lifecycle via the Docker CLI. Resource-capped (--memory/--cpus/--pids-limit), hardened (--security-opt no-new-privileges), labelled, joined to the agent's own network, and never publishes a host port. A stale reaper sweeps any container a crashed run leaks (name-encoded epoch, so a live run is never disturbed). The docker argv is produced by pure functions and unit-tested without a daemon.
plc/runtime/openplc.rs — drives the OpenPLC v3 web UI: POST /login → POST /upload-program (parses the server-assigned prog_file) → POST /upload-program-action → GET /compile-program (polls /compilation-logs) → GET /start_plc.
plc/runtime/mod.rs — provision_and_test composes the above under a hard deadline (max_lifetime_secs) with guaranteed teardown on every path (success / error / timeout), then runs ics::probe_target. extract_program picks the best loadable program: complete ST (has a CONFIGURATION) > 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; findings persist as ScanType::IcsProbe.
config — new PlcRuntimeConfig (PLC_RUNTIME_*). Default off — it shells out to docker, which needs the agent container to have Docker access (a deployment opt-in). Documented in .env.example.
Safety / lifecycle (sub-task 5)
Resource caps + no host exposure + per-instance labels + hard lifetime deadline + guaranteed teardown + stale-orphan reaper. The inner work is panic-free (workspace lint bans unwrap/expect), so no unwind can skip teardown.
Testing
14 unit tests: argv construction (caps/hardening/no-publish/labels), instance naming + epoch parsing, prog_file extraction (both attribute orders), compilation-log predicates, extract_program selection, and — via a fake provisioner — teardown runs even when the test never completes and provision failure propagates without a spurious teardown. cargo clippy -p compliance-agent -- -D warnings clean.
Deploy enablement (not in this PR)
To turn this on in orca-infra, the compliance-agent service needs Docker access (socket mount) + the docker CLI in Dockerfile.agent, then PLC_RUNTIME_ENABLED=1. Tracked as a follow-up.
DAST against the provisioned WebVisu; richer findings mapping (WebVisu XSS/auth-bypass, default creds); CODESYS Control for Linux SL substrate for .projectarchive fidelity (phase 2, licensed).
Phase-1 foundation of epic #183 — dynamic PLC testing via an ephemeral soft-PLC.
## What & why
Dynamic testing of a customer's PLC currently needs to *reach* the running device (a `modbus://` / `http://` live URL). A real device sits on the customer's OT network, unreachable from our cloud. **Provision-and-test** removes that requirement: since we already ingest the control-logic artifact for SAST, we instantiate that logic ourselves on a throwaway soft-PLC (OpenPLC) in-cluster, start it, probe it, and tear it down. No customer network access, sandboxed, reproducible, and destructive tests become safe.
## Model
```
control-logic artifact (ST / PLCopen XML)
→ provision an ephemeral OpenPLC container (in-cluster)
→ login → upload → compile (MatIEC) → start_plc (Modbus/TCP 502 opens)
→ run the existing ICS probe against the provisioned endpoint
→ guaranteed teardown
```
## Changes
- **`plc/runtime/provision.rs`** — ephemeral container lifecycle via the Docker CLI. Resource-capped (`--memory`/`--cpus`/`--pids-limit`), hardened (`--security-opt no-new-privileges`), labelled, joined to the agent's own network, and **never** publishes a host port. A stale reaper sweeps any container a crashed run leaks (name-encoded epoch, so a live run is never disturbed). The `docker` argv is produced by pure functions and unit-tested without a daemon.
- **`plc/runtime/openplc.rs`** — drives the OpenPLC v3 web UI: `POST /login` → `POST /upload-program` (parses the server-assigned `prog_file`) → `POST /upload-program-action` → `GET /compile-program` (polls `/compilation-logs`) → `GET /start_plc`.
- **`plc/runtime/mod.rs`** — `provision_and_test` composes the above under a hard deadline (`max_lifetime_secs`) with **guaranteed teardown on every path** (success / error / timeout), then runs `ics::probe_target`. `extract_program` picks the best loadable program: complete ST (has a `CONFIGURATION`) > 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; findings persist as `ScanType::IcsProbe`.
- **config** — new `PlcRuntimeConfig` (`PLC_RUNTIME_*`). **Default off** — it shells out to `docker`, which needs the agent container to have Docker access (a deployment opt-in). Documented in `.env.example`.
## Safety / lifecycle (sub-task 5)
Resource caps + no host exposure + per-instance labels + hard lifetime deadline + guaranteed teardown + stale-orphan reaper. The inner work is panic-free (workspace lint bans `unwrap`/`expect`), so no unwind can skip teardown.
## Testing
14 unit tests: argv construction (caps/hardening/no-publish/labels), instance naming + epoch parsing, `prog_file` extraction (both attribute orders), compilation-log predicates, `extract_program` selection, and — via a fake provisioner — **teardown runs even when the test never completes** and **provision failure propagates without a spurious teardown**. `cargo clippy -p compliance-agent -- -D warnings` clean.
## Deploy enablement (not in this PR)
To turn this on in `orca-infra`, the `compliance-agent` service needs Docker access (socket mount) + the `docker` CLI in `Dockerfile.agent`, then `PLC_RUNTIME_ENABLED=1`. Tracked as a follow-up.
## Follow-ups (rest of #183)
DAST against the provisioned WebVisu; richer findings mapping (WebVisu XSS/auth-bypass, default creds); CODESYS Control for Linux SL substrate for `.projectarchive` fidelity (phase 2, licensed).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
Completes the provision-and-test loop's dynamic coverage (sub-tasks 3 + 4):
- provision_and_test now returns a ProvisionOutcome { ics findings, DAST run }.
After the Modbus probe it runs a bounded, best-effort DAST scan against the
provisioned web endpoint (independently timed out so it can't consume the whole
instance lifetime), and the orchestrator persists the DAST scan run + findings
linked to the scan run. Kept as a plain data return so the whole run is
portable to a remote execution backend. On the OpenPLC substrate the web
endpoint is OpenPLC's own UI (fidelity caveat documented); the CODESYS-runtime
follow-up raises this to a real WebVisu.
- ICS Modbus probe now enumerates the exposed process surface (read-only): a Read
Coils and a Read Holding Registers of the first block. Coils and holding
registers are read/write process points, so an exposed block is an
unauthenticated *write* surface — reported as `ics-modbus-exposed-points`
(High). Read-only to detect (we never write), so it is safe on the live probe
too, not just the provisioned instance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expanded to complete the dynamic loop (sub-tasks 3 + 4), pushed in 69bce2f:
DAST-against-WebVisu wired into provision_and_test (bounded, best-effort, own timeout); orchestrator persists the DAST scan run + findings linked to the scan run. Returned as plain data (ProvisionOutcome) so the whole run stays portable to a remote execution backend.
Modbus probe deepened: read-only enumeration of exposed coils / holding registers → new ics-modbus-exposed-points (High) finding. Safe on the live probe too (never writes).
Note: deploy enablement (Docker access) is intentionally NOT in this PR. Rather than mount the docker socket, we're moving toward a separate/on-prem execution-runner model (declarative TOML/YAML jobs; results-only egress for customer data residency; shared with QEMU firmware #149). Discussion in progress; the code here is already runner-ready (self-contained job body behind the SoftPlc seam).
Expanded to complete the dynamic loop (sub-tasks 3 + 4), pushed in 69bce2f:
- **DAST-against-WebVisu** wired into provision_and_test (bounded, best-effort, own timeout); orchestrator persists the DAST scan run + findings linked to the scan run. Returned as plain data (ProvisionOutcome) so the whole run stays portable to a remote execution backend.
- **Modbus probe deepened**: read-only enumeration of exposed coils / holding registers → new `ics-modbus-exposed-points` (High) finding. Safe on the live probe too (never writes).
Note: **deploy enablement (Docker access) is intentionally NOT in this PR.** Rather than mount the docker socket, we're moving toward a separate/on-prem **execution-runner** model (declarative TOML/YAML jobs; results-only egress for customer data residency; shared with QEMU firmware #149). Discussion in progress; the code here is already runner-ready (self-contained job body behind the SoftPlc seam).
The CI check job runs cargo fmt --all --check; the new runtime modules and the
orchestrator wiring needed reformatting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sharang
merged commit 7ad7bce9db into main2026-07-17 07:47:44 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Phase-1 foundation of epic #183 — dynamic PLC testing via an ephemeral soft-PLC.
What & why
Dynamic testing of a customer's PLC currently needs to reach the running device (a
modbus:///http://live URL). A real device sits on the customer's OT network, unreachable from our cloud. Provision-and-test removes that requirement: since we already ingest the control-logic artifact for SAST, we instantiate that logic ourselves on a throwaway soft-PLC (OpenPLC) in-cluster, start it, probe it, and tear it down. No customer network access, sandboxed, reproducible, and destructive tests become safe.Model
Changes
plc/runtime/provision.rs— ephemeral container lifecycle via the Docker CLI. Resource-capped (--memory/--cpus/--pids-limit), hardened (--security-opt no-new-privileges), labelled, joined to the agent's own network, and never publishes a host port. A stale reaper sweeps any container a crashed run leaks (name-encoded epoch, so a live run is never disturbed). Thedockerargv is produced by pure functions and unit-tested without a daemon.plc/runtime/openplc.rs— drives the OpenPLC v3 web UI:POST /login→POST /upload-program(parses the server-assignedprog_file) →POST /upload-program-action→GET /compile-program(polls/compilation-logs) →GET /start_plc.plc/runtime/mod.rs—provision_and_testcomposes the above under a hard deadline (max_lifetime_secs) with guaranteed teardown on every path (success / error / timeout), then runsics::probe_target.extract_programpicks the best loadable program: complete ST (has aCONFIGURATION) > largest ST > PLCopen XML.PlcSpstarget with control logic and no live URL, run provision-and-test after the static PLC scan; findings persist asScanType::IcsProbe.PlcRuntimeConfig(PLC_RUNTIME_*). Default off — it shells out todocker, which needs the agent container to have Docker access (a deployment opt-in). Documented in.env.example.Safety / lifecycle (sub-task 5)
Resource caps + no host exposure + per-instance labels + hard lifetime deadline + guaranteed teardown + stale-orphan reaper. The inner work is panic-free (workspace lint bans
unwrap/expect), so no unwind can skip teardown.Testing
14 unit tests: argv construction (caps/hardening/no-publish/labels), instance naming + epoch parsing,
prog_fileextraction (both attribute orders), compilation-log predicates,extract_programselection, and — via a fake provisioner — teardown runs even when the test never completes and provision failure propagates without a spurious teardown.cargo clippy -p compliance-agent -- -D warningsclean.Deploy enablement (not in this PR)
To turn this on in
orca-infra, thecompliance-agentservice needs Docker access (socket mount) + thedockerCLI inDockerfile.agent, thenPLC_RUNTIME_ENABLED=1. Tracked as a follow-up.Follow-ups (rest of #183)
DAST against the provisioned WebVisu; richer findings mapping (WebVisu XSS/auth-bypass, default creds); CODESYS Control for Linux SL substrate for
.projectarchivefidelity (phase 2, licensed).🤖 Generated with Claude Code
Completes the provision-and-test loop's dynamic coverage (sub-tasks 3 + 4): - provision_and_test now returns a ProvisionOutcome { ics findings, DAST run }. After the Modbus probe it runs a bounded, best-effort DAST scan against the provisioned web endpoint (independently timed out so it can't consume the whole instance lifetime), and the orchestrator persists the DAST scan run + findings linked to the scan run. Kept as a plain data return so the whole run is portable to a remote execution backend. On the OpenPLC substrate the web endpoint is OpenPLC's own UI (fidelity caveat documented); the CODESYS-runtime follow-up raises this to a real WebVisu. - ICS Modbus probe now enumerates the exposed process surface (read-only): a Read Coils and a Read Holding Registers of the first block. Coils and holding registers are read/write process points, so an exposed block is an unauthenticated *write* surface — reported as `ics-modbus-exposed-points` (High). Read-only to detect (we never write), so it is safe on the live probe too, not just the provisioned instance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Expanded to complete the dynamic loop (sub-tasks 3 + 4), pushed in
69bce2f:ics-modbus-exposed-points(High) finding. Safe on the live probe too (never writes).Note: deploy enablement (Docker access) is intentionally NOT in this PR. Rather than mount the docker socket, we're moving toward a separate/on-prem execution-runner model (declarative TOML/YAML jobs; results-only egress for customer data residency; shared with QEMU firmware #149). Discussion in progress; the code here is already runner-ready (self-contained job body behind the SoftPlc seam).