feat(plc): ingest CODESYS projects from a git repo (SAST + SBOM) (#171)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 11m7s
CI / Deploy Dashboard (push) Successful in 7m0s
CI / Deploy Docs (push) Successful in 57s
CI / Deploy MCP (push) Successful in 1m48s

This commit was merged in pull request #171.
This commit is contained in:
2026-07-16 15:47:04 +00:00
parent 7369e031c4
commit a3f3f1d4f5
7 changed files with 241 additions and 25 deletions
+29 -20
View File
@@ -449,23 +449,24 @@ impl PipelineOrchestrator {
// wizard-created targets, not just migrated ones.
self.ensure_dast_target(target, &plan).await;
// PLC control-logic analysis for PLC/SPS targets (a PlcProject artifact).
// A PLC/SPS device is a composite target: after the control-logic scan we
// fall through so a reachable device (WebVisu / exposed services) still
// gets DAST, rather than early-returning on the PLC scan alone.
// PLC/SPS targets: the control-logic scan consumes the PLC source (an
// uploaded PlcProject *or* a git repo / source archive of PLCopen XML / ST
// exports), so it takes over the code artifact — we don't also run the
// SAST pipeline over it. A PLC device is reachable, so DAST still runs
// against a WebVisu / exposed endpoint when one is provisioned.
let mut new_count = 0u32;
if plan.has(ScanType::PlcControlLogic) {
new_count += self.run_plc_scan(target, &target_id, scan_run_id).await?;
self.update_phase(scan_run_id, "dast_scanning").await;
self.maybe_trigger_dast(&target_id, scan_run_id).await;
return Ok(new_count);
}
match target.code_artifact() {
Some(code) if code.kind == ArtifactKind::GitRepo => {
let n = {
let repo = RepoView::from_target(target, code);
let n = self.run_pipeline(&repo, scan_run_id).await?;
self.finalize_target(target, &repo, n).await?;
n
};
let repo = RepoView::from_target(target, code);
let n = self.run_pipeline(&repo, scan_run_id).await?;
self.finalize_target(target, &repo, n).await?;
new_count += n;
}
Some(_) => {
@@ -475,10 +476,10 @@ impl PipelineOrchestrator {
);
}
None => {
// No code to scan (a PLC device or a migrated DAST target).
// Firmware/mobile static scanners land in #128/#129; DAST for a
// running URL works when a DastTarget row exists (provisioned above
// from a LiveUrl, or from a migrated target).
// No code to scan (a migrated DAST target). Firmware/mobile static
// scanners land in #128/#129; DAST for a running URL works when a
// DastTarget row exists (provisioned above from a LiveUrl, or from
// a migrated target).
tracing::info!(
target_id = %target_id,
"Unified pipeline: no code artifact; attempting DAST"
@@ -503,15 +504,20 @@ impl PipelineOrchestrator {
let ctx = crate::ingest::IngestContext::from_config(&self.config, target_id);
let ingest_set = crate::ingest::ingest_all(target, &ctx)?;
let Some(artifact) = target.first_of(ArtifactKind::PlcProject) else {
tracing::warn!(target_id, "PLC scan: no PLC project artifact");
// 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 {
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 project path");
tracing::warn!(target_id, "PLC scan: no ingested PLC source path");
return Ok(0);
};
@@ -538,14 +544,17 @@ impl PipelineOrchestrator {
}
// Control-application dependency SBOM: the CODESYS libraries + runtime
// bundled in a `.projectarchive`, matched against known CVEs. Best-effort
// and empty for a bare `.st`/`.xml` (which carries no library manifest).
// 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::projectarchive_sbom(
let sbom = crate::pipeline::plc::sbom::collect_sbom(
std::path::Path::new(&archive),
&path,
target_id,
);
if !sbom.is_empty() {
+19 -1
View File
@@ -75,10 +75,16 @@ pub fn build_scan_plan(target: &OnboardedTarget) -> ScanPlan {
}
/// Resolve the artifact a scan consumes. A "code" requirement (represented by
/// `GitRepo`) is satisfied by a git repo *or* a source archive.
/// `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<ArtifactKind>) -> 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()),
}
@@ -174,6 +180,18 @@ mod tests {
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")]);
+65 -3
View File
@@ -11,10 +11,46 @@
//! CVEs).
use std::collections::BTreeSet;
use std::path::Path;
use std::path::{Path, PathBuf};
use compliance_core::models::SbomEntry;
/// Collect the control-application SBOM from every `.projectarchive` reachable for
/// a target: the ingested artifact file itself (an uploaded archive), plus any
/// `*.projectarchive` committed inside the working tree — e.g. a git repo or an
/// extracted source archive that ships the archive alongside its PLCopen XML / ST
/// exports. Deduplicated by (name, version).
pub fn collect_sbom(artifact_file: &Path, working_path: &Path, repo_id: &str) -> Vec<SbomEntry> {
let mut archives: Vec<PathBuf> = Vec::new();
if artifact_file.is_file() {
archives.push(artifact_file.to_path_buf());
}
for entry in walkdir::WalkDir::new(working_path)
.max_depth(8)
.into_iter()
.filter_map(|e| e.ok())
{
let p = entry.path();
if entry.file_type().is_file()
&& p.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| x.eq_ignore_ascii_case("projectarchive"))
{
archives.push(p.to_path_buf());
}
}
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
let mut out = Vec::new();
for a in archives {
for e in projectarchive_sbom(&a, repo_id) {
if seen.insert((e.name.clone(), e.version.clone())) {
out.push(e);
}
}
}
out
}
/// Extract CODESYS library + runtime components from a `.projectarchive` (a zip).
/// Best-effort: returns empty if the file is not a readable zip (e.g. a bare
/// `.st`/`.xml` project, which carries no library manifest).
@@ -114,7 +150,12 @@ mod tests {
/// device descriptor.
fn synthetic_archive(dir: &Path) -> std::path::PathBuf {
let path = dir.join("App.projectarchive");
let file = std::fs::File::create(&path).expect("create");
write_synthetic_archive(&path);
path
}
fn write_synthetic_archive(path: &Path) {
let file = std::fs::File::create(path).expect("create");
let mut zip = zip::ZipWriter::new(file);
let opts: zip::write::SimpleFileOptions = Default::default();
let names = [
@@ -131,7 +172,6 @@ mod tests {
zip.write_all(b"x").expect("write");
}
zip.finish().expect("finish");
path
}
#[test]
@@ -179,6 +219,28 @@ mod tests {
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn collect_sbom_finds_a_projectarchive_committed_in_a_git_tree() {
let tmp = std::env::temp_dir().join(format!("cs-plc-collect-{}", uuid::Uuid::new_v4()));
let src = tmp.join("clone/src");
std::fs::create_dir_all(&src).expect("mkdir");
// Simulate a git clone that commits the archive alongside its exports.
write_synthetic_archive(&src.join("PumpStation.projectarchive"));
// The artifact "file" is a git URL (not a real file), so the SBOM must
// come from walking the cloned tree.
let entries = collect_sbom(Path::new("https://git.example/plc.git"), &tmp, "t");
let names: std::collections::HashSet<&str> =
entries.iter().map(|e| e.name.as_str()).collect();
assert!(
names.contains("Standard"),
"found libs in the committed archive"
);
assert!(names.contains("CODESYS Control for Linux ARM SL"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn non_zip_file_yields_no_sbom() {
let tmp = std::env::temp_dir().join(format!("cs-plc-sbom-st-{}", uuid::Uuid::new_v4()));