From 4f9e2c282814fcecdb0c1d73cc34e8a58d13a7fa Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Thu, 16 Jul 2026 19:59:58 +0000 Subject: [PATCH] fix(plc): scan every PLC-source artifact, not just the first (#178) --- compliance-agent/src/pipeline/orchestrator.rs | 77 +++++++++++-------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index eae76a0..f6c6d37 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -459,32 +459,57 @@ impl PipelineOrchestrator { let ctx = crate::ingest::IngestContext::from_config(&self.config, target_id); let ingest_set = crate::ingest::ingest_all(target, &ctx)?; - // The PLC source: a dedicated PlcProject artifact, else a code artifact - // (git repo / source archive) holding PLCopen XML / ST exports. - let Some(artifact) = target - .first_of(ArtifactKind::PlcProject) - .or_else(|| target.code_artifact()) - else { + // Every PLC-source artifact on the target: dedicated PLC projects plus any + // code artifacts (git repo / source archive) holding PLCopen XML / ST + // exports. A target can carry several (e.g. one POU export per file). + let sources: Vec<&Artifact> = target + .artifacts + .iter() + .filter(|a| { + matches!( + a.kind, + ArtifactKind::PlcProject | ArtifactKind::GitRepo | ArtifactKind::SourceArchive + ) + }) + .collect(); + if sources.is_empty() { tracing::warn!(target_id, "PLC scan: no PLC source artifact"); return Ok(0); - }; - let Some(path) = ingest_set - .get(&artifact.id) - .and_then(|ia| ia.working_path.clone()) - else { - tracing::warn!(target_id, "PLC scan: no ingested PLC source path"); - return Ok(0); - }; + } - let findings = crate::pipeline::plc::analyze_tree(&path, target_id); + let mut all_findings = Vec::new(); + let mut all_sbom: Vec = Vec::new(); + let mut sbom_seen = std::collections::BTreeSet::new(); + for a in &sources { + let Some(path) = ingest_set.get(&a.id).and_then(|ia| ia.working_path.clone()) else { + continue; + }; + all_findings.extend(crate::pipeline::plc::analyze_tree(&path, target_id)); + // Control-application SBOM: CODESYS libraries + runtime from a + // `.projectarchive` (uploaded, or committed in the working tree). + let archive = a + .stored_path + .clone() + .unwrap_or_else(|| a.source_ref.clone()); + for e in crate::pipeline::plc::sbom::collect_sbom( + std::path::Path::new(&archive), + &path, + target_id, + ) { + if sbom_seen.insert((e.name.clone(), e.version.clone())) { + all_sbom.push(e); + } + } + } tracing::info!( target_id, - found = findings.len(), + artifacts = sources.len(), + found = all_findings.len(), "PLC control-logic analysis complete" ); let mut new_count = 0u32; - for mut finding in findings { + for mut finding in all_findings { finding.scan_run_id = Some(scan_run_id.to_string()); if self .db @@ -498,23 +523,9 @@ impl PipelineOrchestrator { } } - // Control-application dependency SBOM: the CODESYS libraries + runtime - // bundled in a `.projectarchive`, matched against known CVEs. Sourced from - // the uploaded archive *and* any `.projectarchive` committed in the working - // tree (e.g. a git repo). Empty for a bare `.st`/`.xml` or a repo of only - // PLCopen XML exports (which carry no library manifest). - let archive = artifact - .stored_path - .clone() - .unwrap_or_else(|| artifact.source_ref.clone()); - let sbom = crate::pipeline::plc::sbom::collect_sbom( - std::path::Path::new(&archive), - &path, - target_id, - ); - if !sbom.is_empty() { + if !all_sbom.is_empty() { if let Err(e) = self - .persist_control_app_sbom(target_id, &target.name, sbom) + .persist_control_app_sbom(target_id, &target.name, all_sbom) .await { tracing::warn!(target_id, error = %e, "control-app SBOM persist failed");