From 27d20ed11c18a70fac0e2531c909f5da9e95b642 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:09:51 +0200 Subject: [PATCH] feat(pipeline): scan-plan builder for the unified pipeline (#133 part 1) build_scan_plan(target) turns an OnboardedTarget into the ordered set of scans to run, each bound to the artifact it consumes: matrix defaults (applicable_scans) intersected with the target's scan_config enable/disable overrides. Pure and fully unit-tested; the decision engine that run_target will execute next. Refs #133. Co-Authored-By: Claude Fable 5 --- compliance-agent/src/pipeline/mod.rs | 1 + compliance-agent/src/pipeline/plan.rs | 195 ++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 compliance-agent/src/pipeline/plan.rs diff --git a/compliance-agent/src/pipeline/mod.rs b/compliance-agent/src/pipeline/mod.rs index 2a75dfb..70d91b8 100644 --- a/compliance-agent/src/pipeline/mod.rs +++ b/compliance-agent/src/pipeline/mod.rs @@ -8,6 +8,7 @@ mod issue_creation; pub mod lint; pub mod orchestrator; pub mod patterns; +pub mod plan; mod pr_review; pub mod sbom; pub mod semgrep; diff --git a/compliance-agent/src/pipeline/plan.rs b/compliance-agent/src/pipeline/plan.rs new file mode 100644 index 0000000..6143324 --- /dev/null +++ b/compliance-agent/src/pipeline/plan.rs @@ -0,0 +1,195 @@ +//! 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. +fn resolve_artifact(target: &OnboardedTarget, required: Option) -> Option<&Artifact> { + match required { + Some(ArtifactKind::GitRepo) => 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, + } +} + +#[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 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()); + } +}