fix(matrix): DAST needs an http(s) endpoint; don't offer/run it on modbus:// (#182)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Agent (push) Successful in 3m34s
CI / Deploy Dashboard (push) Successful in 2m37s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m0s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Agent (push) Successful in 3m34s
CI / Deploy Dashboard (push) Successful in 2m37s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m0s
This commit was merged in pull request #182.
This commit is contained in:
@@ -409,11 +409,17 @@ impl PipelineOrchestrator {
|
|||||||
new_count += self.run_ics_probe(target, &target_id, scan_run_id).await?;
|
new_count += self.run_ics_probe(target, &target_id, scan_run_id).await?;
|
||||||
}
|
}
|
||||||
if plc || ics {
|
if plc || ics {
|
||||||
// PLC/SPS device: also DAST against a WebVisu / exposed endpoint. The
|
// PLC/SPS device: also DAST against a WebVisu / exposed endpoint, but
|
||||||
// control-logic scan already consumed the code artifact, so the SAST
|
// only when DAST is actually planned — a device reachable only over an
|
||||||
// pipeline is not re-run.
|
// industrial protocol (e.g. modbus://) has no web surface to crawl, and
|
||||||
self.update_phase(scan_run_id, "dast_scanning").await;
|
// running DAST there just fails at reconnaissance. Gating here (not only
|
||||||
self.maybe_trigger_dast(&target_id, scan_run_id).await;
|
// 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);
|
return Ok(new_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ use crate::models::{ArtifactKind, OnboardedTarget, ScanType, TargetType};
|
|||||||
pub enum ArtifactRequirement {
|
pub enum ArtifactRequirement {
|
||||||
/// Source code — a git repo or a source archive.
|
/// Source code — a git repo or a source archive.
|
||||||
Code,
|
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,
|
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.
|
/// A firmware image / binary blob.
|
||||||
Firmware,
|
Firmware,
|
||||||
/// A PLC project (PLCopen XML or Structured Text).
|
/// A PLC project (PLCopen XML or Structured Text).
|
||||||
@@ -134,7 +138,7 @@ fn sast_umbrella() -> Vec<ScanRule> {
|
|||||||
/// The rule set for a target type. Scans that are never applicable to a type are
|
/// 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).
|
/// simply absent (e.g. DAST is not listed for a PLC target).
|
||||||
pub fn rules_for(target_type: TargetType) -> Vec<ScanRule> {
|
pub fn rules_for(target_type: TargetType) -> Vec<ScanRule> {
|
||||||
use ArtifactRequirement::{Firmware, Mobile, Plc, RunningUrl};
|
use ArtifactRequirement::{Firmware, HttpUrl, Mobile, Plc, RunningUrl};
|
||||||
match target_type {
|
match target_type {
|
||||||
TargetType::WebApp | TargetType::BackendService => {
|
TargetType::WebApp | TargetType::BackendService => {
|
||||||
let mut r = sast_umbrella();
|
let mut r = sast_umbrella();
|
||||||
@@ -142,7 +146,7 @@ pub fn rules_for(target_type: TargetType) -> Vec<ScanRule> {
|
|||||||
ScanType::Dast,
|
ScanType::Dast,
|
||||||
true,
|
true,
|
||||||
"Dynamic scan of the running endpoint",
|
"Dynamic scan of the running endpoint",
|
||||||
RunningUrl,
|
HttpUrl,
|
||||||
));
|
));
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
@@ -203,7 +207,7 @@ pub fn rules_for(target_type: TargetType) -> Vec<ScanRule> {
|
|||||||
ScanType::Dast,
|
ScanType::Dast,
|
||||||
false,
|
false,
|
||||||
"Dynamic scan of exposed network services (if any)",
|
"Dynamic scan of exposed network services (if any)",
|
||||||
RunningUrl,
|
HttpUrl,
|
||||||
));
|
));
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
@@ -249,7 +253,7 @@ pub fn rules_for(target_type: TargetType) -> Vec<ScanRule> {
|
|||||||
ScanType::Dast,
|
ScanType::Dast,
|
||||||
false,
|
false,
|
||||||
"Dynamic scan of the running device (WebVisu / exposed services)",
|
"Dynamic scan of the running device (WebVisu / exposed services)",
|
||||||
RunningUrl,
|
HttpUrl,
|
||||||
),
|
),
|
||||||
ScanRule::new(
|
ScanRule::new(
|
||||||
ScanType::IcsProbe,
|
ScanType::IcsProbe,
|
||||||
@@ -285,7 +289,9 @@ pub fn supports_pentest(target_type: TargetType) -> bool {
|
|||||||
fn representative_kind(req: ArtifactRequirement) -> Option<ArtifactKind> {
|
fn representative_kind(req: ArtifactRequirement) -> Option<ArtifactKind> {
|
||||||
match req {
|
match req {
|
||||||
ArtifactRequirement::Code => Some(ArtifactKind::GitRepo),
|
ArtifactRequirement::Code => Some(ArtifactKind::GitRepo),
|
||||||
ArtifactRequirement::RunningUrl => Some(ArtifactKind::LiveUrl),
|
ArtifactRequirement::RunningUrl | ArtifactRequirement::HttpUrl => {
|
||||||
|
Some(ArtifactKind::LiveUrl)
|
||||||
|
}
|
||||||
ArtifactRequirement::Firmware => Some(ArtifactKind::FirmwareImage),
|
ArtifactRequirement::Firmware => Some(ArtifactKind::FirmwareImage),
|
||||||
ArtifactRequirement::Plc => Some(ArtifactKind::PlcProject),
|
ArtifactRequirement::Plc => Some(ArtifactKind::PlcProject),
|
||||||
ArtifactRequirement::Mobile => Some(ArtifactKind::MobilePackage),
|
ArtifactRequirement::Mobile => Some(ArtifactKind::MobilePackage),
|
||||||
@@ -294,11 +300,22 @@ fn representative_kind(req: ArtifactRequirement) -> Option<ArtifactKind> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Whether the target carries an artifact that satisfies the requirement.
|
||||||
fn requirement_satisfied(req: ArtifactRequirement, target: &OnboardedTarget) -> bool {
|
fn requirement_satisfied(req: ArtifactRequirement, target: &OnboardedTarget) -> bool {
|
||||||
match req {
|
match req {
|
||||||
ArtifactRequirement::Code => target.code_artifact().is_some(),
|
ArtifactRequirement::Code => target.code_artifact().is_some(),
|
||||||
ArtifactRequirement::RunningUrl => target.has(ArtifactKind::LiveUrl),
|
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),
|
ArtifactRequirement::Firmware => target.has(ArtifactKind::FirmwareImage),
|
||||||
// A PLC project artifact, or a code artifact (git repo / source archive)
|
// A PLC project artifact, or a code artifact (git repo / source archive)
|
||||||
// holding the control logic as PLCopen XML / ST exports — the common way
|
// holding the control logic as PLCopen XML / ST exports — the common way
|
||||||
@@ -322,6 +339,10 @@ pub fn applicable_scans(target: &OnboardedTarget) -> Vec<ScanOption> {
|
|||||||
let required_artifact = representative_kind(rule.requires);
|
let required_artifact = representative_kind(rule.requires);
|
||||||
let blocked_reason = if satisfied {
|
let blocked_reason = if satisfied {
|
||||||
None
|
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 {
|
} else {
|
||||||
Some(match required_artifact {
|
Some(match required_artifact {
|
||||||
Some(kind) => format!("no {kind} artifact provided"),
|
Some(kind) => format!("no {kind} artifact provided"),
|
||||||
@@ -462,6 +483,49 @@ mod tests {
|
|||||||
.is_none());
|
.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]
|
#[test]
|
||||||
fn pentest_support_matches_reachable_families() {
|
fn pentest_support_matches_reachable_families() {
|
||||||
assert!(supports_pentest(TargetType::WebApp));
|
assert!(supports_pentest(TargetType::WebApp));
|
||||||
|
|||||||
Reference in New Issue
Block a user