//! The scan plan. //! //! [`build_scan_plan`] turns an [`OnboardedTarget`] into the concrete ordered //! list of scans to run, each bound to the artifact it consumes. It intersects //! the scan-applicability matrix ([`applicable_scans`]) with the target's //! `scan_config` overrides: a scan runs when its required artifact is present and //! it is either on by default or explicitly enabled, and is not explicitly //! disabled. This is the decision engine the unified pipeline (`run_target`) //! executes. use compliance_core::models::{Artifact, ArtifactKind, OnboardedTarget, ScanPhase, ScanType}; use compliance_core::scan_matrix::applicable_scans; /// One scan to run, bound to the artifact it operates on. #[derive(Debug, Clone, PartialEq)] pub struct ScanStep { /// The scan to run. pub scan_type: ScanType, /// The pipeline phase to report while it runs. pub phase: ScanPhase, /// The id of the artifact this scan consumes ([`Artifact::id`]). pub artifact_id: String, } /// The ordered set of scans to run for a target. #[derive(Debug, Clone, Default, PartialEq)] pub struct ScanPlan { /// The scans, in matrix order. pub steps: Vec, } impl ScanPlan { /// Whether the plan contains a step for the given scan type. pub fn has(&self, scan: ScanType) -> bool { self.steps.iter().any(|s| s.scan_type == scan) } /// Whether the plan is empty (nothing to run). pub fn is_empty(&self) -> bool { self.steps.is_empty() } } /// Build the scan plan for a target: matrix defaults ∩ `scan_config`, each scan /// bound to the artifact it consumes. Scans whose required artifact is absent, or /// that are disabled, or off-by-default and not explicitly enabled, are dropped. pub fn build_scan_plan(target: &OnboardedTarget) -> ScanPlan { let enabled = &target.scan_config.enabled_scans; let disabled = &target.scan_config.disabled_scans; let mut steps = Vec::new(); for option in applicable_scans(target) { // Required artifact missing → not runnable. if option.blocked_reason.is_some() { continue; } // Explicit opt-out wins. if disabled.contains(&option.scan) { continue; } // Run if on by default, or explicitly enabled. if !option.default_on && !enabled.contains(&option.scan) { continue; } let Some(artifact) = resolve_artifact(target, option.required_artifact) else { continue; }; steps.push(ScanStep { scan_type: option.scan, phase: phase_for(option.scan), artifact_id: artifact.id.clone(), }); } ScanPlan { steps } } /// Resolve the artifact a scan consumes. A "code" requirement (represented by /// `GitRepo`) is satisfied by a git repo *or* a source archive. The PLC /// control-logic requirement (represented by `PlcProject`) prefers an uploaded /// PLC project but also accepts a code artifact — a git repo / source archive /// holding PLCopen XML / ST exports. fn resolve_artifact(target: &OnboardedTarget, required: Option) -> Option<&Artifact> { match required { Some(ArtifactKind::GitRepo) => target.code_artifact(), Some(ArtifactKind::PlcProject) => target .first_of(ArtifactKind::PlcProject) .or_else(|| target.code_artifact()), Some(kind) => target.first_of(kind), None => target.code_artifact().or_else(|| target.artifacts.first()), } } /// The pipeline phase reported while a given scan runs. fn phase_for(scan: ScanType) -> ScanPhase { match scan { ScanType::Sast => ScanPhase::Sast, ScanType::Sbom => ScanPhase::SbomGeneration, ScanType::Cve => ScanPhase::CveScanning, ScanType::Gdpr | ScanType::OAuth => ScanPhase::PatternScanning, ScanType::SecretDetection => ScanPhase::SecretDetection, ScanType::Lint => ScanPhase::LintScanning, ScanType::CodeReview => ScanPhase::CodeReview, ScanType::Graph => ScanPhase::GraphBuilding, ScanType::Dast => ScanPhase::DastScanning, ScanType::FirmwareStatic => ScanPhase::FirmwareStatic, ScanType::PlcControlLogic => ScanPhase::PlcAnalysis, ScanType::MobileStatic => ScanPhase::MobileStatic, ScanType::ContainerScan => ScanPhase::ContainerScan, ScanType::IcsProbe => ScanPhase::IcsProbe, } } #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; use compliance_core::models::{PlcFormat, TargetType}; fn target(target_type: TargetType, artifacts: Vec) -> OnboardedTarget { let mut t = OnboardedTarget::new("t".to_string(), target_type); t.artifacts = artifacts; t } fn step_for<'a>(plan: &'a ScanPlan, scan: ScanType) -> Option<&'a ScanStep> { plan.steps.iter().find(|s| s.scan_type == scan) } #[test] fn webapp_with_code_and_url_runs_sast_and_dast_bound_to_the_right_artifacts() { let code = Artifact::git_repo("https://git/x", "main"); let url = Artifact::live_url("https://x"); let (code_id, url_id) = (code.id.clone(), url.id.clone()); let t = target(TargetType::WebApp, vec![code, url]); let plan = build_scan_plan(&t); let sast = step_for(&plan, ScanType::Sast).expect("sast planned"); assert_eq!(sast.artifact_id, code_id); assert_eq!(sast.phase, ScanPhase::Sast); let dast = step_for(&plan, ScanType::Dast).expect("dast planned"); assert_eq!(dast.artifact_id, url_id); } #[test] fn webapp_without_url_omits_dast() { let t = target(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]); let plan = build_scan_plan(&t); assert!(plan.has(ScanType::Sast)); assert!(!plan.has(ScanType::Dast)); } #[test] fn code_scan_binds_to_source_archive_when_no_git_repo() { let arc = Artifact::source_archive("src.zip"); let arc_id = arc.id.clone(); let t = target(TargetType::BackendService, vec![arc]); let plan = build_scan_plan(&t); let sast = step_for(&plan, ScanType::Sast).expect("sast planned"); assert_eq!(sast.artifact_id, arc_id); } #[test] fn firmware_sbom_and_cve_bind_to_the_firmware_image() { let fw = Artifact::firmware_image("fw.bin"); let fw_id = fw.id.clone(); let t = target(TargetType::FirmwareBareMetal, vec![fw]); let plan = build_scan_plan(&t); let sbom = step_for(&plan, ScanType::Sbom).expect("sbom planned"); assert_eq!(sbom.artifact_id, fw_id); assert!(step_for(&plan, ScanType::FirmwareStatic).is_some()); assert!(!plan.has(ScanType::Dast)); } #[test] fn plc_plans_only_control_logic() { let plc = Artifact::plc_project("p.xml", PlcFormat::PlcopenXml); let t = target(TargetType::PlcSps, vec![plc]); let plan = build_scan_plan(&t); assert_eq!(plan.steps.len(), 1); assert_eq!(plan.steps[0].scan_type, ScanType::PlcControlLogic); assert_eq!(plan.steps[0].phase, ScanPhase::PlcAnalysis); } #[test] fn plc_control_logic_binds_to_a_git_repo() { // A CODESYS project in git (PLCopen XML / ST exports) with no uploaded // PlcProject: control-logic still plans, bound to the git artifact. let git = Artifact::git_repo("https://git/plc", "main"); let git_id = git.id.clone(); let t = target(TargetType::PlcSps, vec![git]); let plan = build_scan_plan(&t); let step = step_for(&plan, ScanType::PlcControlLogic).expect("control-logic planned"); assert_eq!(step.artifact_id, git_id, "PLC scan binds to the git repo"); } #[test] fn disabled_scan_is_dropped_and_off_by_default_can_be_enabled() { let mut t = target(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]); t.scan_config.disabled_scans = vec![ScanType::Lint]; // CodeReview is off by default for web; enable it explicitly. t.scan_config.enabled_scans = vec![ScanType::CodeReview]; let plan = build_scan_plan(&t); assert!(!plan.has(ScanType::Lint)); assert!(plan.has(ScanType::CodeReview)); assert!(plan.has(ScanType::Sast)); } #[test] fn no_code_artifact_yields_empty_plan_for_web() { let t = target(TargetType::WebApp, vec![]); let plan = build_scan_plan(&t); assert!(plan.is_empty()); } }