feat(plc): ephemeral soft-PLC provisioning + program load (#183) #193

Merged
sharang merged 3 commits from feat/plc-provision-and-test into main 2026-07-17 07:47:44 +00:00
Owner

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 /loginPOST /upload-program (parses the server-assigned prog_file) → POST /upload-program-actionGET /compile-program (polls /compilation-logs) → GET /start_plc.
  • plc/runtime/mod.rsprovision_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

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)
sharang added 1 commit 2026-07-16 22:40:22 +00:00
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
1ae6025286
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>
sharang added 1 commit 2026-07-17 07:35:15 +00:00
feat(plc): DAST the provisioned WebVisu + enumerate exposed Modbus points (#183)
CI / Check (pull_request) Has been cancelled
CI / Detect Changes (pull_request) Has been cancelled
CI / Deploy Agent (pull_request) Has been cancelled
CI / Deploy Dashboard (pull_request) Has been cancelled
CI / Deploy Docs (pull_request) Has been cancelled
CI / Deploy MCP (pull_request) Has been cancelled
69bce2f07c
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>
Author
Owner

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).
sharang added 1 commit 2026-07-17 07:44:38 +00:00
style: apply rustfmt to the provision-and-test modules
CI / Check (pull_request) Successful in 6m1s
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
896a06e8f6
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 main 2026-07-17 07:47:44 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sharang/compliance-scanner-agent#193