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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a204f0c59c
commit
27d20ed11c
@@ -8,6 +8,7 @@ mod issue_creation;
|
|||||||
pub mod lint;
|
pub mod lint;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod patterns;
|
pub mod patterns;
|
||||||
|
pub mod plan;
|
||||||
mod pr_review;
|
mod pr_review;
|
||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
pub mod semgrep;
|
pub mod semgrep;
|
||||||
|
|||||||
@@ -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<ScanStep>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ArtifactKind>) -> 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<Artifact>) -> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user