diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index 7d3b2ca..c48bc18 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -409,11 +409,17 @@ impl PipelineOrchestrator { new_count += self.run_ics_probe(target, &target_id, scan_run_id).await?; } if plc || ics { - // PLC/SPS device: also DAST against a WebVisu / exposed endpoint. The - // control-logic scan already consumed the code artifact, so the SAST - // pipeline is not re-run. - self.update_phase(scan_run_id, "dast_scanning").await; - self.maybe_trigger_dast(&target_id, scan_run_id).await; + // PLC/SPS device: also DAST against a WebVisu / exposed endpoint, but + // only when DAST is actually planned — a device reachable only over an + // industrial protocol (e.g. modbus://) has no web surface to crawl, and + // running DAST there just fails at reconnaissance. Gating here (not only + // at provisioning) also stops a DAST target left over from an earlier + // run from re-triggering. The control-logic scan already consumed the + // code artifact, so the SAST pipeline is not re-run. + if plan.has(ScanType::Dast) { + self.update_phase(scan_run_id, "dast_scanning").await; + self.maybe_trigger_dast(&target_id, scan_run_id).await; + } return Ok(new_count); } diff --git a/compliance-core/src/scan_matrix.rs b/compliance-core/src/scan_matrix.rs index 525220b..0d2b22c 100644 --- a/compliance-core/src/scan_matrix.rs +++ b/compliance-core/src/scan_matrix.rs @@ -14,8 +14,12 @@ use crate::models::{ArtifactKind, OnboardedTarget, ScanType, TargetType}; pub enum ArtifactRequirement { /// Source code — a git repo or a source archive. Code, - /// A reachable running instance (live URL / endpoint). + /// A reachable running instance (any live URL / endpoint, scheme-agnostic — + /// e.g. the ICS probe works off the host:port of a modbus:// or http:// ref). RunningUrl, + /// A reachable **web** endpoint — a live URL with an http(s) scheme. DAST is + /// an HTTP crawler, so a modbus:// / opc.tcp:// endpoint does not satisfy it. + HttpUrl, /// A firmware image / binary blob. Firmware, /// A PLC project (PLCopen XML or Structured Text). @@ -134,7 +138,7 @@ fn sast_umbrella() -> Vec { /// The rule set for a target type. Scans that are never applicable to a type are /// simply absent (e.g. DAST is not listed for a PLC target). pub fn rules_for(target_type: TargetType) -> Vec { - use ArtifactRequirement::{Firmware, Mobile, Plc, RunningUrl}; + use ArtifactRequirement::{Firmware, HttpUrl, Mobile, Plc, RunningUrl}; match target_type { TargetType::WebApp | TargetType::BackendService => { let mut r = sast_umbrella(); @@ -142,7 +146,7 @@ pub fn rules_for(target_type: TargetType) -> Vec { ScanType::Dast, true, "Dynamic scan of the running endpoint", - RunningUrl, + HttpUrl, )); r } @@ -203,7 +207,7 @@ pub fn rules_for(target_type: TargetType) -> Vec { ScanType::Dast, false, "Dynamic scan of exposed network services (if any)", - RunningUrl, + HttpUrl, )); r } @@ -249,7 +253,7 @@ pub fn rules_for(target_type: TargetType) -> Vec { ScanType::Dast, false, "Dynamic scan of the running device (WebVisu / exposed services)", - RunningUrl, + HttpUrl, ), ScanRule::new( ScanType::IcsProbe, @@ -285,7 +289,9 @@ pub fn supports_pentest(target_type: TargetType) -> bool { fn representative_kind(req: ArtifactRequirement) -> Option { match req { ArtifactRequirement::Code => Some(ArtifactKind::GitRepo), - ArtifactRequirement::RunningUrl => Some(ArtifactKind::LiveUrl), + ArtifactRequirement::RunningUrl | ArtifactRequirement::HttpUrl => { + Some(ArtifactKind::LiveUrl) + } ArtifactRequirement::Firmware => Some(ArtifactKind::FirmwareImage), ArtifactRequirement::Plc => Some(ArtifactKind::PlcProject), ArtifactRequirement::Mobile => Some(ArtifactKind::MobilePackage), @@ -294,11 +300,22 @@ fn representative_kind(req: ArtifactRequirement) -> Option { } } +/// Whether a live-URL reference is an http(s) web endpoint (vs. an industrial +/// endpoint like `modbus://` / `opc.tcp://`, which DAST cannot crawl). +fn is_http_url(source_ref: &str) -> bool { + let s = source_ref.trim(); + s.starts_with("http://") || s.starts_with("https://") +} + /// Whether the target carries an artifact that satisfies the requirement. fn requirement_satisfied(req: ArtifactRequirement, target: &OnboardedTarget) -> bool { match req { ArtifactRequirement::Code => target.code_artifact().is_some(), ArtifactRequirement::RunningUrl => target.has(ArtifactKind::LiveUrl), + ArtifactRequirement::HttpUrl => target + .artifacts + .iter() + .any(|a| a.kind == ArtifactKind::LiveUrl && is_http_url(&a.source_ref)), ArtifactRequirement::Firmware => target.has(ArtifactKind::FirmwareImage), // A PLC project artifact, or a code artifact (git repo / source archive) // holding the control logic as PLCopen XML / ST exports — the common way @@ -322,6 +339,10 @@ pub fn applicable_scans(target: &OnboardedTarget) -> Vec { let required_artifact = representative_kind(rule.requires); let blocked_reason = if satisfied { None + } else if rule.requires == ArtifactRequirement::HttpUrl { + // A live URL may be present but non-HTTP (e.g. modbus://): be + // specific so the user knows DAST needs a web endpoint. + Some("no http(s) live URL — DAST needs a web endpoint".to_string()) } else { Some(match required_artifact { Some(kind) => format!("no {kind} artifact provided"), @@ -462,6 +483,49 @@ mod tests { .is_none()); } + #[test] + fn plc_with_modbus_url_offers_ics_probe_but_blocks_dast() { + // A soft-PLC reachable only over Modbus/TCP (no WebVisu). The ICS probe + // is applicable (it works off host:port), but DAST — an HTTP crawler — + // must be blocked so it isn't offered/run against a non-web endpoint. + let t = target_with( + TargetType::PlcSps, + vec![Artifact::live_url("modbus://plc-sim:502")], + ); + let opts = applicable_scans(&t); + let ics = option(&opts, ScanType::IcsProbe).expect("ics probe offered"); + assert!( + ics.blocked_reason.is_none(), + "ICS probe should be unblocked for a modbus:// endpoint" + ); + assert!(!ics.default_on, "ICS probe stays opt-in (default-off)"); + let dast = option(&opts, ScanType::Dast).expect("dast listed"); + assert!( + dast.blocked_reason.is_some(), + "DAST must be blocked without an http(s) endpoint" + ); + assert!(!dast.default_on); + } + + #[test] + fn plc_with_http_webvisu_offers_both_dast_and_ics_probe() { + // A PLC exposing a WebVisu over HTTP: both DAST (web) and the ICS probe + // (OT ports on the same host) are applicable. + let t = target_with( + TargetType::PlcSps, + vec![Artifact::live_url("http://plc.local/webvisu")], + ); + let opts = applicable_scans(&t); + assert!(option(&opts, ScanType::Dast) + .expect("dast offered") + .blocked_reason + .is_none()); + assert!(option(&opts, ScanType::IcsProbe) + .expect("ics probe offered") + .blocked_reason + .is_none()); + } + #[test] fn pentest_support_matches_reachable_families() { assert!(supports_pentest(TargetType::WebApp));