//! 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 (live URL / endpoint). RunningUrl, /// 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, 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", RunningUrl, )); 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)", RunningUrl, )); r } TargetType::PlcSps => vec![ScanRule::new( ScanType::PlcControlLogic, true, "Control-logic security rules over the PLC program", Plc, )], } } /// 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 ) } /// The representative artifact kind a requirement is satisfied by. fn representative_kind(req: ArtifactRequirement) -> Option { match req { ArtifactRequirement::Code => Some(ArtifactKind::GitRepo), ArtifactRequirement::RunningUrl => 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 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::Firmware => target.has(ArtifactKind::FirmwareImage), ArtifactRequirement::Plc => target.has(ArtifactKind::PlcProject), 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 { 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_offers_only_control_logic() { 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); } #[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::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"); } } }