//! The scan-applicability matrix. //! //! Which scans are possible for a target is a function of its [`TargetType`] and //! which [`ArtifactKind`]s are actually present: SAST needs code, DAST needs a //! running URL, firmware-static analysis needs a firmware image, and so on. This //! module encodes that as a table — one rule set per target type — and resolves //! it against a concrete [`OnboardedTarget`] into a list of [`ScanOption`]s the //! onboarding wizard and the scan pipeline both consume. use crate::models::{ArtifactKind, OnboardedTarget, ScanType, TargetType}; /// What an artifact a scan needs in order to run. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ArtifactRequirement { /// Source code — a git repo or a source archive. Code, /// 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). Plc, /// A mobile package (APK / AAB / IPA). Mobile, /// A container image. Container, /// No specific artifact required. Any, } /// A static rule: this scan applies to a target type, needs this artifact, and /// defaults on/off. The rationale explains the entry to the user. #[derive(Debug, Clone, Copy)] pub struct ScanRule { /// The scan this rule governs. pub scan: ScanType, /// Whether the scan is on by default (only when its artifact is present). pub default_on: bool, /// Human-readable explanation of what the scan does here. pub rationale: &'static str, /// The artifact the scan consumes. pub requires: ArtifactRequirement, } impl ScanRule { const fn new( scan: ScanType, default_on: bool, rationale: &'static str, requires: ArtifactRequirement, ) -> Self { Self { scan, default_on, rationale, requires, } } } /// A resolved scan choice for a specific target: a rule intersected with the /// artifacts actually present. `blocked_reason` is `Some` when the required /// artifact is missing. #[derive(Debug, Clone)] pub struct ScanOption { /// The scan. pub scan: ScanType, /// Whether to pre-select the scan (false when blocked). pub default_on: bool, /// Why the scan is offered. pub rationale: String, /// The artifact kind the scan needs, if any specific one. pub required_artifact: Option, /// Set when the required artifact is absent, explaining the block. pub blocked_reason: Option, } /// The SAST umbrella: every static-analysis sub-scan that runs over source code. fn sast_umbrella() -> Vec { use ArtifactRequirement::Code; vec![ ScanRule::new( ScanType::Sast, true, "Static analysis (Semgrep) over source", Code, ), ScanRule::new( ScanType::Sbom, true, "Software bill of materials from source", Code, ), ScanRule::new( ScanType::Cve, true, "Match dependencies against known CVEs", Code, ), ScanRule::new( ScanType::SecretDetection, true, "Scan source for committed secrets", Code, ), ScanRule::new(ScanType::Lint, true, "Language linters over source", Code), ScanRule::new( ScanType::Gdpr, true, "GDPR data-handling pattern checks", Code, ), ScanRule::new( ScanType::OAuth, true, "OAuth misconfiguration patterns", Code, ), ScanRule::new( ScanType::Graph, true, "Build the code graph for impact analysis", Code, ), ScanRule::new( ScanType::CodeReview, false, "LLM code review over changed source", Code, ), ] } /// 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, HttpUrl, Mobile, Plc, RunningUrl}; match target_type { TargetType::WebApp | TargetType::BackendService => { let mut r = sast_umbrella(); r.push(ScanRule::new( ScanType::Dast, true, "Dynamic scan of the running endpoint", HttpUrl, )); r } TargetType::DesktopApp => sast_umbrella(), TargetType::AndroidApp | TargetType::IosApp => { let mut r = sast_umbrella(); r.push(ScanRule::new( ScanType::MobileStatic, true, "Static analysis of the mobile package (manifest, permissions, libs)", Mobile, )); r } TargetType::FirmwareBareMetal | TargetType::FirmwareRtos => { let mut r = sast_umbrella(); r.push(ScanRule::new( ScanType::FirmwareStatic, true, "Unpack and statically analyze the firmware image", Firmware, )); r.push(ScanRule::new( ScanType::Sbom, true, "SBOM from the firmware image (binwalk / tramiton)", Firmware, )); r.push(ScanRule::new( ScanType::Cve, true, "Match firmware components against known CVEs", Firmware, )); r } TargetType::EmbeddedLinuxYocto => { let mut r = sast_umbrella(); r.push(ScanRule::new( ScanType::FirmwareStatic, true, "EMBA / binwalk static analysis of the image", Firmware, )); r.push(ScanRule::new( ScanType::Sbom, true, "SBOM from image layers / recipes", Firmware, )); r.push(ScanRule::new( ScanType::Cve, true, "Match image components against known CVEs", Firmware, )); r.push(ScanRule::new( ScanType::Dast, false, "Dynamic scan of exposed network services (if any)", HttpUrl, )); r } 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)", HttpUrl, ), ScanRule::new( ScanType::IcsProbe, false, "Probe the running device over industrial protocols (Modbus/TCP, …)", RunningUrl, ), ] } } } /// Whether an active penetration test is applicable to this target type. /// /// Pentest runs as its own session (not a [`ScanType`] scan) and needs a /// reachable running target, so it is offered only for the network-reachable /// families. pub fn supports_pentest(target_type: TargetType) -> bool { matches!( target_type, TargetType::WebApp | TargetType::BackendService | 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 ) } /// The representative artifact kind a requirement is satisfied by. fn representative_kind(req: ArtifactRequirement) -> Option { match req { ArtifactRequirement::Code => Some(ArtifactKind::GitRepo), ArtifactRequirement::RunningUrl | ArtifactRequirement::HttpUrl => { Some(ArtifactKind::LiveUrl) } ArtifactRequirement::Firmware => Some(ArtifactKind::FirmwareImage), ArtifactRequirement::Plc => Some(ArtifactKind::PlcProject), ArtifactRequirement::Mobile => Some(ArtifactKind::MobilePackage), ArtifactRequirement::Container => Some(ArtifactKind::ContainerImage), ArtifactRequirement::Any => None, } } /// 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 // CODESYS projects are version-controlled. ArtifactRequirement::Plc => { target.has(ArtifactKind::PlcProject) || target.code_artifact().is_some() } ArtifactRequirement::Mobile => target.has(ArtifactKind::MobilePackage), ArtifactRequirement::Container => target.has(ArtifactKind::ContainerImage), ArtifactRequirement::Any => true, } } /// Resolve the matrix for a concrete target into the scans it can run, marking /// any whose required artifact is missing as blocked. pub fn applicable_scans(target: &OnboardedTarget) -> Vec { rules_for(target.target_type) .into_iter() .map(|rule| { let satisfied = requirement_satisfied(rule.requires, target); 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"), None => "required artifact missing".to_string(), }) }; ScanOption { scan: rule.scan, default_on: rule.default_on && satisfied, rationale: rule.rationale.to_string(), required_artifact, blocked_reason, } }) .collect() } #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; use crate::models::{Artifact, PlcFormat}; fn target_with(target_type: TargetType, artifacts: Vec) -> OnboardedTarget { let mut t = OnboardedTarget::new("t".to_string(), target_type); t.artifacts = artifacts; t } fn option<'a>(opts: &'a [ScanOption], scan: ScanType) -> Option<&'a ScanOption> { opts.iter().find(|o| o.scan == scan) } #[test] fn webapp_with_code_and_url_offers_sast_and_dast() { let t = target_with( TargetType::WebApp, vec![ Artifact::git_repo("u", "main"), Artifact::live_url("http://x"), ], ); let opts = applicable_scans(&t); let sast = option(&opts, ScanType::Sast).expect("sast offered"); assert!(sast.default_on && sast.blocked_reason.is_none()); let dast = option(&opts, ScanType::Dast).expect("dast offered"); assert!(dast.default_on && dast.blocked_reason.is_none()); } #[test] fn webapp_without_url_blocks_dast() { let t = target_with(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]); let opts = applicable_scans(&t); let dast = option(&opts, ScanType::Dast).expect("dast listed"); assert!(!dast.default_on); assert!(dast.blocked_reason.is_some()); assert_eq!(dast.required_artifact, Some(ArtifactKind::LiveUrl)); } #[test] fn firmware_offers_firmware_static_and_not_dast() { let t = target_with( TargetType::FirmwareBareMetal, vec![Artifact::firmware_image("fw.bin")], ); let opts = applicable_scans(&t); let fw = option(&opts, ScanType::FirmwareStatic).expect("firmware static offered"); assert!(fw.default_on && fw.blocked_reason.is_none()); assert!(option(&opts, ScanType::Dast).is_none()); } #[test] 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); 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_control_logic_is_satisfied_by_a_git_repo() { // A CODESYS project version-controlled in git (PLCopen XML / ST exports), // no uploaded PlcProject artifact. let t = target_with(TargetType::PlcSps, vec![Artifact::git_repo("u", "main")]); let opts = applicable_scans(&t); let plc = option(&opts, ScanType::PlcControlLogic).expect("control-logic offered"); assert!( plc.default_on && plc.blocked_reason.is_none(), "a git repo should satisfy PLC control-logic" ); } #[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 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)); assert!(supports_pentest(TargetType::BackendService)); 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)); } #[test] fn every_target_type_has_at_least_one_rule() { for tt in [ TargetType::WebApp, TargetType::BackendService, TargetType::DesktopApp, TargetType::AndroidApp, TargetType::IosApp, TargetType::FirmwareBareMetal, TargetType::FirmwareRtos, TargetType::EmbeddedLinuxYocto, TargetType::PlcSps, ] { assert!(!rules_for(tt).is_empty(), "{tt} has no rules"); } } }