feat(plc): control-application SBOM from CODESYS .projectarchive (+CVE) [#166] #170
@@ -503,11 +503,14 @@ impl PipelineOrchestrator {
|
|||||||
|
|
||||||
let ctx = crate::ingest::IngestContext::from_config(&self.config, target_id);
|
let ctx = crate::ingest::IngestContext::from_config(&self.config, target_id);
|
||||||
let ingest_set = crate::ingest::ingest_all(target, &ctx)?;
|
let ingest_set = crate::ingest::ingest_all(target, &ctx)?;
|
||||||
let path = target
|
let Some(artifact) = target.first_of(ArtifactKind::PlcProject) else {
|
||||||
.first_of(ArtifactKind::PlcProject)
|
tracing::warn!(target_id, "PLC scan: no PLC project artifact");
|
||||||
.and_then(|a| ingest_set.get(&a.id))
|
return Ok(0);
|
||||||
.and_then(|ia| ia.working_path.clone());
|
};
|
||||||
let Some(path) = path else {
|
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 project path");
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
};
|
};
|
||||||
@@ -533,9 +536,102 @@ impl PipelineOrchestrator {
|
|||||||
new_count += 1;
|
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)
|
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<SbomEntry>,
|
||||||
|
) -> 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,
|
/// Ingest the target's artifacts, classify (tramiton for firmware/RTOS/Yocto,
|
||||||
/// heuristics otherwise), and store the detected classification on the target.
|
/// heuristics otherwise), and store the detected classification on the target.
|
||||||
/// Best-effort — never fails the scan.
|
/// Best-effort — never fails the scan.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod lexer;
|
|||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod plcopen;
|
pub mod plcopen;
|
||||||
pub mod rules;
|
pub mod rules;
|
||||||
|
pub mod sbom;
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
|||||||
@@ -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 … <version> …`. 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<SbomEntry> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user