feat(matrix): PlcSps as a composite device target (SBOM/CVE/DAST/pentest)
CI / Check (pull_request) Successful in 5m34s
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
CI / Check (pull_request) Successful in 5m34s
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
A PLC/SPS device is two layers — the control application and the device it runs on (firmware/OS + reachable runtime services). Previously PlcSps offered only PlcControlLogic, so a CODESYS-on-Yocto device's firmware and network attack surface was invisible. - scan_matrix: PlcSps now offers FirmwareStatic + Sbom + Cve (require a firmware image) and Dast (require a running endpoint, e.g. WebVisu), and supports_pentest(PlcSps) is now true. Control-logic stays default-on; the device-level scans are offered but opt-in (default-off) — firmware-image SBOM/CVE execution is shared with the firmware families and still landing (#151/#128), so they must not silently auto-run. - orchestrator: the PLC branch no longer early-returns, so a PLC device with a reachable endpoint also runs DAST (the wired path) after the control-logic scan. Part of the CODESYS-on-Yocto coverage tracker (#167). Closes #164. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
afa5c985ee
commit
78cf3237d5
@@ -450,37 +450,44 @@ impl PipelineOrchestrator {
|
|||||||
self.ensure_dast_target(target, &plan).await;
|
self.ensure_dast_target(target, &plan).await;
|
||||||
|
|
||||||
// PLC control-logic analysis for PLC/SPS targets (a PlcProject artifact).
|
// 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) {
|
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() {
|
match target.code_artifact() {
|
||||||
Some(code) if code.kind == ArtifactKind::GitRepo => {
|
Some(code) if code.kind == ArtifactKind::GitRepo => {
|
||||||
let repo = RepoView::from_target(target, code);
|
let n = {
|
||||||
let new_count = self.run_pipeline(&repo, scan_run_id).await?;
|
let repo = RepoView::from_target(target, code);
|
||||||
self.finalize_target(target, &repo, new_count).await?;
|
let n = self.run_pipeline(&repo, scan_run_id).await?;
|
||||||
Ok(new_count)
|
self.finalize_target(target, &repo, n).await?;
|
||||||
|
n
|
||||||
|
};
|
||||||
|
new_count += n;
|
||||||
}
|
}
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target_id = %target_id,
|
target_id = %target_id,
|
||||||
"Unified pipeline: source-archive scanning not yet wired; skipping"
|
"Unified pipeline: source-archive scanning not yet wired; skipping"
|
||||||
);
|
);
|
||||||
Ok(0)
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// No code to scan. Firmware/PLC/mobile static scanners land in
|
// No code to scan (a PLC device or a migrated DAST target).
|
||||||
// #128/#129/#130; DAST for a running URL still works when a
|
// Firmware/mobile static scanners land in #128/#129; DAST for a
|
||||||
// DastTarget row exists (migrated targets).
|
// running URL works when a DastTarget row exists (provisioned above
|
||||||
|
// from a LiveUrl, or from a migrated target).
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target_id = %target_id,
|
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.update_phase(scan_run_id, "dast_scanning").await;
|
||||||
self.maybe_trigger_dast(&target_id, scan_run_id).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
|
/// Analyze a PLC/SPS project (Structured Text / PLCopen XML) for
|
||||||
|
|||||||
@@ -207,12 +207,52 @@ pub fn rules_for(target_type: TargetType) -> Vec<ScanRule> {
|
|||||||
));
|
));
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
TargetType::PlcSps => vec![ScanRule::new(
|
TargetType::PlcSps => {
|
||||||
ScanType::PlcControlLogic,
|
// A PLC/SPS device is a composite: the control application *and* the
|
||||||
true,
|
// device it runs on (firmware/OS + reachable runtime services). The
|
||||||
"Control-logic security rules over the PLC program",
|
// control-logic scan runs on the PLC project; the firmware and DAST
|
||||||
Plc,
|
// 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::AndroidApp
|
||||||
| TargetType::IosApp
|
| TargetType::IosApp
|
||||||
| TargetType::EmbeddedLinuxYocto
|
| 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]
|
#[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(
|
let t = target_with(
|
||||||
TargetType::PlcSps,
|
TargetType::PlcSps,
|
||||||
vec![Artifact::plc_project("p.xml", PlcFormat::PlcopenXml)],
|
vec![Artifact::plc_project("p.xml", PlcFormat::PlcopenXml)],
|
||||||
);
|
);
|
||||||
let opts = applicable_scans(&t);
|
let opts = applicable_scans(&t);
|
||||||
assert_eq!(opts.len(), 1);
|
let plc = option(&opts, ScanType::PlcControlLogic).expect("control-logic offered");
|
||||||
assert_eq!(opts[0].scan, ScanType::PlcControlLogic);
|
assert!(plc.default_on && plc.blocked_reason.is_none());
|
||||||
assert!(opts[0].default_on);
|
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]
|
#[test]
|
||||||
fn pentest_support_matches_reachable_families() {
|
fn pentest_support_matches_reachable_families() {
|
||||||
assert!(supports_pentest(TargetType::WebApp));
|
assert!(supports_pentest(TargetType::WebApp));
|
||||||
assert!(supports_pentest(TargetType::BackendService));
|
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::FirmwareBareMetal));
|
||||||
assert!(!supports_pentest(TargetType::DesktopApp));
|
assert!(!supports_pentest(TargetType::DesktopApp));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user