diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index f4419d5..9030621 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -450,37 +450,44 @@ impl PipelineOrchestrator { self.ensure_dast_target(target, &plan).await; // PLC control-logic analysis for PLC/SPS targets (a PlcProject artifact). + // A PLC/SPS device is a composite target: after the control-logic scan we + // fall through so a reachable device (WebVisu / exposed services) still + // gets DAST, rather than early-returning on the PLC scan alone. + let mut new_count = 0u32; if plan.has(ScanType::PlcControlLogic) { - return self.run_plc_scan(target, &target_id, scan_run_id).await; + new_count += self.run_plc_scan(target, &target_id, scan_run_id).await?; } match target.code_artifact() { Some(code) if code.kind == ArtifactKind::GitRepo => { - let repo = RepoView::from_target(target, code); - let new_count = self.run_pipeline(&repo, scan_run_id).await?; - self.finalize_target(target, &repo, new_count).await?; - Ok(new_count) + let n = { + let repo = RepoView::from_target(target, code); + let n = self.run_pipeline(&repo, scan_run_id).await?; + self.finalize_target(target, &repo, n).await?; + n + }; + new_count += n; } Some(_) => { tracing::warn!( target_id = %target_id, "Unified pipeline: source-archive scanning not yet wired; skipping" ); - Ok(0) } None => { - // No code to scan. Firmware/PLC/mobile static scanners land in - // #128/#129/#130; DAST for a running URL still works when a - // DastTarget row exists (migrated targets). + // No code to scan (a PLC device or a migrated DAST target). + // Firmware/mobile static scanners land in #128/#129; DAST for a + // running URL works when a DastTarget row exists (provisioned above + // from a LiveUrl, or from a migrated target). tracing::info!( target_id = %target_id, - "Unified pipeline: no code artifact; attempting DAST only" + "Unified pipeline: no code artifact; attempting DAST" ); self.update_phase(scan_run_id, "dast_scanning").await; self.maybe_trigger_dast(&target_id, scan_run_id).await; - Ok(0) } } + Ok(new_count) } /// Analyze a PLC/SPS project (Structured Text / PLCopen XML) for diff --git a/compliance-core/src/scan_matrix.rs b/compliance-core/src/scan_matrix.rs index b6873d8..93dc40b 100644 --- a/compliance-core/src/scan_matrix.rs +++ b/compliance-core/src/scan_matrix.rs @@ -207,12 +207,52 @@ pub fn rules_for(target_type: TargetType) -> Vec { )); r } - TargetType::PlcSps => vec![ScanRule::new( - ScanType::PlcControlLogic, - true, - "Control-logic security rules over the PLC program", - Plc, - )], + TargetType::PlcSps => { + // A PLC/SPS device is a composite: the control application *and* the + // device it runs on (firmware/OS + reachable runtime services). The + // control-logic scan runs on the PLC project; the firmware and DAST + // scans light up only when a firmware image / running endpoint is + // attached (e.g. a CODESYS runtime on a Yocto image with WebVisu). + // Firmware-image SBOM/CVE *execution* is shared with the firmware + // families and tracked in #151/#128; DAST over a WebVisu/OPC-UA + // endpoint uses the existing DAST path. + vec![ + ScanRule::new( + ScanType::PlcControlLogic, + true, + "Control-logic security rules over the PLC program", + Plc, + ), + // Device-level scans are offered but opt-in (default-off): they + // apply only when a firmware image is attached, and firmware-image + // SBOM/CVE *execution* is shared with the firmware families and + // still landing (#151/#128), so they must not silently auto-run. + ScanRule::new( + ScanType::FirmwareStatic, + false, + "Static analysis of the device firmware image (OS + runtime)", + Firmware, + ), + ScanRule::new( + ScanType::Sbom, + false, + "SBOM from the device firmware image (OS packages + CODESYS runtime)", + Firmware, + ), + ScanRule::new( + ScanType::Cve, + false, + "Match device firmware components against known CVEs", + Firmware, + ), + ScanRule::new( + ScanType::Dast, + false, + "Dynamic scan of the running device (WebVisu / exposed services)", + RunningUrl, + ), + ] + } } } @@ -229,6 +269,9 @@ pub fn supports_pentest(target_type: TargetType) -> bool { | TargetType::AndroidApp | TargetType::IosApp | TargetType::EmbeddedLinuxYocto + // A PLC/SPS device exposes reachable runtime services (WebVisu, OPC UA, + // the CODESYS programming protocol), so an active pentest applies. + | TargetType::PlcSps ) } @@ -340,22 +383,68 @@ mod tests { } #[test] - fn plc_offers_only_control_logic() { + fn plc_control_logic_is_default_on_and_device_scans_block_without_artifacts() { + // A PLC project alone: control-logic runs; the device-level scans are + // offered but blocked until a firmware image / running endpoint is added. let t = target_with( TargetType::PlcSps, vec![Artifact::plc_project("p.xml", PlcFormat::PlcopenXml)], ); let opts = applicable_scans(&t); - assert_eq!(opts.len(), 1); - assert_eq!(opts[0].scan, ScanType::PlcControlLogic); - assert!(opts[0].default_on); + let plc = option(&opts, ScanType::PlcControlLogic).expect("control-logic offered"); + assert!(plc.default_on && plc.blocked_reason.is_none()); + for scan in [ScanType::FirmwareStatic, ScanType::Sbom, ScanType::Cve] { + let o = option(&opts, scan).expect("device scan offered"); + assert!( + !o.default_on, + "{scan} must not pre-select without a firmware image" + ); + assert!(o.blocked_reason.is_some()); + } + let dast = option(&opts, ScanType::Dast).expect("dast offered"); + assert!(!dast.default_on); + assert!(dast.blocked_reason.is_some()); + } + + #[test] + fn plc_composite_lights_up_device_scans_with_firmware_and_url() { + // A CODESYS-on-Yocto device: PLC project + firmware image + WebVisu URL. + let t = target_with( + TargetType::PlcSps, + vec![ + Artifact::plc_project("p.xml", PlcFormat::PlcopenXml), + Artifact::firmware_image("device.img"), + Artifact::live_url("http://plc.local/webvisu"), + ], + ); + let opts = applicable_scans(&t); + for scan in [ + ScanType::PlcControlLogic, + ScanType::FirmwareStatic, + ScanType::Sbom, + ScanType::Cve, + ] { + let o = option(&opts, scan).expect("scan offered"); + assert!(o.blocked_reason.is_none(), "{scan} should be unblocked"); + } + // Control-logic auto-runs; the device-level scans are unblocked but opt-in + // (default-off) until firmware-image execution lands (#151/#128). + assert!(option(&opts, ScanType::PlcControlLogic).unwrap().default_on); + assert!(!option(&opts, ScanType::Sbom).unwrap().default_on); + assert!(!option(&opts, ScanType::Dast).unwrap().default_on); + assert!(option(&opts, ScanType::Dast) + .unwrap() + .blocked_reason + .is_none()); } #[test] fn pentest_support_matches_reachable_families() { assert!(supports_pentest(TargetType::WebApp)); assert!(supports_pentest(TargetType::BackendService)); - assert!(!supports_pentest(TargetType::PlcSps)); + assert!(supports_pentest(TargetType::EmbeddedLinuxYocto)); + // A PLC/SPS device is network-reachable (WebVisu / OPC UA / 11740). + assert!(supports_pentest(TargetType::PlcSps)); assert!(!supports_pentest(TargetType::FirmwareBareMetal)); assert!(!supports_pentest(TargetType::DesktopApp)); }