diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index 9030621..913f8da 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -503,11 +503,14 @@ impl PipelineOrchestrator { let ctx = crate::ingest::IngestContext::from_config(&self.config, target_id); let ingest_set = crate::ingest::ingest_all(target, &ctx)?; - let path = target - .first_of(ArtifactKind::PlcProject) - .and_then(|a| ingest_set.get(&a.id)) - .and_then(|ia| ia.working_path.clone()); - let Some(path) = path else { + let Some(artifact) = target.first_of(ArtifactKind::PlcProject) else { + tracing::warn!(target_id, "PLC scan: no PLC project 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"); return Ok(0); }; @@ -533,9 +536,102 @@ impl PipelineOrchestrator { new_count += 1; } } + + // 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). + let archive = artifact + .stored_path + .clone() + .unwrap_or_else(|| artifact.source_ref.clone()); + let sbom = crate::pipeline::plc::sbom::projectarchive_sbom( + std::path::Path::new(&archive), + target_id, + ); + if !sbom.is_empty() { + if let Err(e) = self.persist_control_app_sbom(target_id, sbom).await { + tracing::warn!(target_id, error = %e, "control-app SBOM persist failed"); + } + } Ok(new_count) } + /// Store a control-application SBOM (CODESYS libraries + runtime) for a target + /// and match it against known CVEs. Scoped to `package_manager = "codesys"` so + /// it refreshes on re-scan and coexists with any firmware/source SBOM. The + /// runtime `Cmp*` / `3SLicense` components carry real CODESYS advisories, so + /// this is where PLC-device CVE coverage comes from. + async fn persist_control_app_sbom( + &self, + target_id: &str, + mut entries: Vec, + ) -> Result<(), AgentError> { + if entries.is_empty() { + return Ok(()); + } + self.db + .sbom_entries() + .delete_many(doc! { "repo_id": target_id, "package_manager": "codesys" }) + .await?; + + let cve_scanner = CveScanner::new( + self.http.clone(), + self.config.searxng_url.clone(), + self.config.nvd_api_key.as_ref().map(|k| { + use secrecy::ExposeSecret; + k.expose_secret().to_string() + }), + ); + let alerts = match tokio::time::timeout( + std::time::Duration::from_secs(600), + cve_scanner.scan_dependencies(target_id, &mut entries), + ) + .await + { + Ok(Ok(a)) => a, + Ok(Err(e)) => { + tracing::warn!(target_id, error = %e, "control-app CVE scan failed"); + Vec::new() + } + Err(_) => { + tracing::warn!(target_id, "control-app CVE scan timed out"); + Vec::new() + } + }; + + for entry in &entries { + let filter = doc! { + "repo_id": &entry.repo_id, + "name": &entry.name, + "version": &entry.version, + }; + if let Ok(d) = mongodb::bson::to_document(entry) { + self.db + .sbom_entries() + .update_one(filter, doc! { "$set": d }) + .upsert(true) + .await?; + } + } + for alert in &alerts { + let filter = doc! { "cve_id": &alert.cve_id, "repo_id": &alert.repo_id }; + if let Ok(d) = mongodb::bson::to_document(alert) { + self.db + .cve_alerts() + .update_one(filter, doc! { "$set": d }) + .upsert(true) + .await?; + } + } + tracing::info!( + target_id, + components = entries.len(), + alerts = alerts.len(), + "control-app SBOM stored" + ); + Ok(()) + } + /// Ingest the target's artifacts, classify (tramiton for firmware/RTOS/Yocto, /// heuristics otherwise), and store the detected classification on the target. /// Best-effort — never fails the scan. diff --git a/compliance-agent/src/pipeline/plc/mod.rs b/compliance-agent/src/pipeline/plc/mod.rs index 417004a..e58e766 100644 --- a/compliance-agent/src/pipeline/plc/mod.rs +++ b/compliance-agent/src/pipeline/plc/mod.rs @@ -9,6 +9,7 @@ pub mod lexer; pub mod parser; pub mod plcopen; pub mod rules; +pub mod sbom; use std::path::Path; diff --git a/compliance-agent/src/pipeline/plc/sbom.rs b/compliance-agent/src/pipeline/plc/sbom.rs new file mode 100644 index 0000000..aa59d8f --- /dev/null +++ b/compliance-agent/src/pipeline/plc/sbom.rs @@ -0,0 +1,191 @@ +//! Control-application dependency SBOM from a CODESYS `.projectarchive`. +//! +//! A `.projectarchive` is a ZIP that bundles the project plus its referenced +//! libraries and the target runtime. Each referenced library is an entry whose +//! path segment follows the CODESYS convention +//! `Name, Major.Minor.Patch.Build (Company)` (e.g. `Standard, 3.5.18.0 (System)`, +//! `CSV Utility SL, 1.9.0.0 (CODESYS)`); the runtime appears as a device-descriptor +//! entry `CODESYS Control … …`. We enumerate those entries — no binary +//! parsing — and emit SBOM components tagged `pkg:codesys/…`, so the CVE pipeline +//! can match them (the runtime `Cmp*` / `3SLicense` components carry real CODESYS +//! CVEs). + +use std::collections::BTreeSet; +use std::path::Path; + +use compliance_core::models::SbomEntry; + +/// 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). +pub fn projectarchive_sbom(archive: &Path, repo_id: &str) -> Vec { + let Ok(file) = std::fs::File::open(archive) else { + return Vec::new(); + }; + let Ok(mut zip) = zip::ZipArchive::new(file) else { + return Vec::new(); + }; + let mut seen: BTreeSet<(String, String)> = BTreeSet::new(); + let mut entries = Vec::new(); + for i in 0..zip.len() { + let Ok(entry) = zip.by_index(i) else { + continue; + }; + // Entry paths use `\` (Windows-authored) and/or `/` separators; the + // component id is one path segment. + for seg in entry.name().split(['/', '\\']) { + if let Some((name, version)) = parse_library(seg).or_else(|| parse_runtime(seg)) { + if seen.insert((name.clone(), version.clone())) { + let mut e = SbomEntry::new( + repo_id.to_string(), + name.clone(), + version.clone(), + "codesys".to_string(), + ); + e.purl = Some(format!( + "pkg:codesys/{}@{version}", + name.replace(' ', "%20") + )); + entries.push(e); + } + } + } + } + entries +} + +/// `Name, X.Y.Z.W (Company)` → (name, version). +fn parse_library(seg: &str) -> Option<(String, String)> { + let seg = seg.trim(); + // Company is the trailing "(…)". + let open = seg.rfind(" (")?; + let rest = &seg[open + 2..]; + let close = rest.find(')')?; + if rest[..close].trim().is_empty() { + return None; + } + let head = seg[..open].trim(); // "Name, X.Y.Z.W" + let comma = head.rfind(", ")?; + let name = head[..comma].trim().to_string(); + let version = head[comma + 2..].trim().to_string(); + if name.is_empty() || !is_dotted_version(&version) { + return None; + } + Some((name, version)) +} + +/// Device-descriptor entry `CODESYS Control … X.Y.Z.W …` → (runtime name, version). +fn parse_runtime(seg: &str) -> Option<(String, String)> { + let seg = seg.trim(); + if !seg.starts_with("CODESYS Control") { + return None; + } + let version = seg + .split_whitespace() + .find(|t| is_dotted_version(t))? + .to_string(); + // The runtime name is the first field, before the run of padding spaces that + // precede the descriptor's numeric columns. + let name = seg.split(" ").next().unwrap_or(seg).trim().to_string(); + if name.is_empty() { + return None; + } + Some((name, version)) +} + +/// A dotted numeric version with at least 3 components (`3.5.18.0`, `4.17.0.0`). +fn is_dotted_version(s: &str) -> bool { + let parts: Vec<&str> = s.split('.').collect(); + parts.len() >= 3 + && parts + .iter() + .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::io::Write; + + /// Build a synthetic `.projectarchive` (zip) mirroring the real CODESYS entry + /// naming (verified against Proemion/codesys-examples): a native `.project`, + /// referenced libraries as `Name, Version (Company)` segments, and a runtime + /// 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"); + let mut zip = zip::ZipWriter::new(file); + let opts: zip::write::SimpleFileOptions = Default::default(); + let names = [ + "App.project", + r"{b0b5}\App.Device.Plc.compileinfo", + r"{e179}\Standard, 3.5.18.0 (System) standard.compiled-library-v3", + r"{e179}\Util, 3.5.21.0 (System) util.compiled-library-v3", + r"{e179}\CSV Utility SL, 1.9.0.0 (CODESYS) csv utility sl.compiled-library-v3", + r"{e179}\3SLicense, 3.5.20.0 (CODESYS) 3slicense.compiled-library-v3", + r"{0c63}\CODESYS Control for Linux ARM SL 0000 0006 4.17.0.0 4096 .zip", + ]; + for n in names { + zip.start_file(n, opts).expect("start"); + zip.write_all(b"x").expect("write"); + } + zip.finish().expect("finish"); + path + } + + #[test] + fn extracts_libraries_and_runtime_from_projectarchive() { + let tmp = std::env::temp_dir().join(format!("cs-plc-sbom-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&tmp).expect("mkdir"); + let archive = synthetic_archive(&tmp); + + let entries = projectarchive_sbom(&archive, "plc-target"); + let by_name: HashMap<&str, &SbomEntry> = + entries.iter().map(|e| (e.name.as_str(), e)).collect(); + + // Libraries with their versions. + assert_eq!( + by_name.get("Standard").map(|e| e.version.as_str()), + Some("3.5.18.0") + ); + assert_eq!( + by_name.get("Util").map(|e| e.version.as_str()), + Some("3.5.21.0") + ); + assert_eq!( + by_name.get("CSV Utility SL").map(|e| e.version.as_str()), + Some("1.9.0.0"), + "multi-word library names must parse" + ); + assert!(by_name.contains_key("3SLicense")); + + // The runtime, from the device descriptor. + assert_eq!( + by_name + .get("CODESYS Control for Linux ARM SL") + .map(|e| e.version.as_str()), + Some("4.17.0.0") + ); + + // Every component is CODESYS-tagged with a purl the CVE pipeline can match, + // and the native `.project` / compileinfo are not mistaken for components. + for e in &entries { + assert_eq!(e.package_manager, "codesys"); + assert!(e.purl.as_deref().unwrap_or("").starts_with("pkg:codesys/")); + } + assert!(!by_name.contains_key("App")); + + 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())); + std::fs::create_dir_all(&tmp).expect("mkdir"); + let st = tmp.join("prog.st"); + std::fs::write(&st, "PROGRAM P\nVAR x : INT; END_VAR\nEND_PROGRAM\n").expect("write"); + assert!(projectarchive_sbom(&st, "t").is_empty()); + let _ = std::fs::remove_dir_all(&tmp); + } +}