diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index 913f8da..bb0ccca 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -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() { diff --git a/compliance-agent/src/pipeline/plan.rs b/compliance-agent/src/pipeline/plan.rs index 6143324..d9cfcc3 100644 --- a/compliance-agent/src/pipeline/plan.rs +++ b/compliance-agent/src/pipeline/plan.rs @@ -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) -> 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")]); diff --git a/compliance-agent/src/pipeline/plc/sbom.rs b/compliance-agent/src/pipeline/plc/sbom.rs index aa59d8f..d417261 100644 --- a/compliance-agent/src/pipeline/plc/sbom.rs +++ b/compliance-agent/src/pipeline/plc/sbom.rs @@ -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 { + let mut archives: Vec = 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())); diff --git a/compliance-core/src/scan_matrix.rs b/compliance-core/src/scan_matrix.rs index 93dc40b..83fd412 100644 --- a/compliance-core/src/scan_matrix.rs +++ b/compliance-core/src/scan_matrix.rs @@ -294,7 +294,12 @@ fn requirement_satisfied(req: ArtifactRequirement, target: &OnboardedTarget) -> ArtifactRequirement::Code => target.code_artifact().is_some(), ArtifactRequirement::RunningUrl => target.has(ArtifactKind::LiveUrl), ArtifactRequirement::Firmware => target.has(ArtifactKind::FirmwareImage), - ArtifactRequirement::Plc => target.has(ArtifactKind::PlcProject), + // A PLC project artifact, or a code artifact (git repo / source archive) + // holding the control logic as PLCopen XML / ST exports — the common way + // CODESYS projects are version-controlled. + ArtifactRequirement::Plc => { + target.has(ArtifactKind::PlcProject) || target.code_artifact().is_some() + } ArtifactRequirement::Mobile => target.has(ArtifactKind::MobilePackage), ArtifactRequirement::Container => target.has(ArtifactKind::ContainerImage), ArtifactRequirement::Any => true, @@ -406,6 +411,19 @@ mod tests { assert!(dast.blocked_reason.is_some()); } + #[test] + fn plc_control_logic_is_satisfied_by_a_git_repo() { + // A CODESYS project version-controlled in git (PLCopen XML / ST exports), + // no uploaded PlcProject artifact. + let t = target_with(TargetType::PlcSps, vec![Artifact::git_repo("u", "main")]); + let opts = applicable_scans(&t); + let plc = option(&opts, ScanType::PlcControlLogic).expect("control-logic offered"); + assert!( + plc.default_on && plc.blocked_reason.is_none(), + "a git repo should satisfy PLC control-logic" + ); + } + #[test] fn plc_composite_lights_up_device_scans_with_firmware_and_url() { // A CODESYS-on-Yocto device: PLC project + firmware image + WebVisu URL. diff --git a/compliance-dashboard/src/pages/onboarding.rs b/compliance-dashboard/src/pages/onboarding.rs index d93439e..f67c9bb 100644 --- a/compliance-dashboard/src/pages/onboarding.rs +++ b/compliance-dashboard/src/pages/onboarding.rs @@ -214,6 +214,36 @@ pub fn OnboardingPage() -> Element { // ---- Step 1: artifacts ---- if step_now == 1 { div { class: "card-header", "Attach artifacts" } + if target_type() == "plc_sps" { + div { + style: "margin: 12px 16px 0; padding: 12px 14px; border-left: 3px solid var(--accent, #3b82f6); background: var(--surface-2, rgba(59,130,246,0.08)); font-size: 0.88em; line-height: 1.55;", + div { style: "font-weight: 600; margin-bottom: 4px;", "CODESYS / PLC projects" } + "Attach a " + b { "PLC project" } + " (PLCopen XML / ST, or a .projectarchive), or a " + b { "Git repository" } + " of exported source — every scan is then just a pull." + ul { style: "margin: 6px 0 0; padding-left: 18px;", + li { + b { "Control-logic SAST" } + " — commit " + b { "PLCopen XML exports" } + " (Project → Export PLCopenXML) or raw .st; ST and graphical FBD/LD are both analyzed." + } + li { + b { "Library + runtime SBOM" } + " — include the " + b { ".projectarchive" } + "; PLCopen XML alone carries no libraries." + } + li { + "Avoid committing only the binary " + code { ".project" } + " — it can't be parsed and doesn't diff." + } + } + } + } div { style: "padding: 16px;", div { style: "display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end;", div { class: "form-group", style: "margin: 0;", diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 74eded2..73dad79 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -20,6 +20,7 @@ export default withMermaid(defineConfig({ { text: 'Getting Started', link: '/guide/getting-started' }, { text: 'Adding Repositories', link: '/guide/repositories' }, { text: 'Running Scans', link: '/guide/scanning' }, + { text: 'PLC / SPS (CODESYS)', link: '/guide/plc' }, { text: 'Understanding Findings', link: '/guide/findings' }, { text: 'SBOM & Licenses', link: '/guide/sbom' }, { text: 'Issues & Tracking', link: '/guide/issues' }, diff --git a/docs/guide/plc.md b/docs/guide/plc.md new file mode 100644 index 0000000..b0326b2 --- /dev/null +++ b/docs/guide/plc.md @@ -0,0 +1,78 @@ +# PLC / SPS Projects (CODESYS) + +Certifai analyzes industrial control logic (IEC 61131-3) for PLC/SPS targets such +as CODESYS projects. A single PLC/SPS target is treated as a **composite device**: +the control application *and* the device it runs on. + +| What you provide | What Certifai does | +| --- | --- | +| PLC project (PLCopen XML / ST, or a `.projectarchive`) | **Control-logic SAST** — semantic security rules over ST **and** graphical FBD/LD | +| A `.projectarchive` | **Control-app SBOM** — the referenced CODESYS libraries + the runtime version, matched against known CVEs | +| A device firmware image | Firmware SBOM / CVE (opt-in) | +| A reachable endpoint (WebVisu, OPC UA) | DAST / pentest (opt-in) | + +## Two ways to deliver the project + +You can either **upload** the project when onboarding, or point Certifai at a +**git repository** (recommended — every scan is just a `git pull`, no re-upload). + +### Option A — Upload + +On the onboarding wizard, choose target type **PLC / SPS**, then attach a **PLC +project** artifact and pick its format: + +- **PLCopen XML** (`.xml`) — export from CODESYS via *Project → Export PLCopenXML*. +- **Structured Text** (`.st`) — a raw ST file. +- **Project archive** (`.projectarchive`) — *File → Project Archive → Save/Send + Archive…* with **"Referenced libraries"** ticked. This is the only form that + also yields the **library + runtime SBOM**. + +### Option B — Git repository (recommended) + +Attach a **Git repository** artifact to the PLC/SPS target. Certifai clones it and +runs the control-logic scan over the exported source in the repo. + +## Best-case git repository layout + +Because the binary `.project` does not diff or merge in git, commit **textual +exports** for review-friendly SAST, and include the **`.projectarchive`** so the +library/runtime SBOM is available too: + +```text +my-plc-project/ +├── src/ +│ ├── PLC_PRG.xml # PLCopen XML export (ST or FBD/LD) — one per POU +│ ├── PumpController.xml +│ ├── SafetyInterlock.xml +│ └── GVL.xml # global variable lists, also as PLCopen XML +├── PumpStation.projectarchive # optional but recommended → library + runtime SBOM +└── README.md +``` + +**Guidelines** + +- **Export to PLCopen XML** (`Project → Export PLCopenXML`), one file per POU, and + commit those. ST, **and graphical FBD/LD**, are both analyzed. +- Alternatively commit raw `.st` / `.exp` / `.scl` files — also analyzed. +- **Do not** commit only the binary `.project` — it cannot be parsed (and does not + diff). If you want the library SBOM, commit the **`.projectarchive`** as well. +- CODESYS's built-in Git integration, which stores an exported representation, + works too — as long as the committed form is PLCopen XML / textual. + +::: tip What unlocks what +- **Control-logic SAST** needs textual source in the repo (PLCopen XML or `.st`). +- **Library + runtime SBOM** needs a **`.projectarchive`** — PLCopen XML exports do + **not** carry the referenced libraries. +::: + +## What the scanner finds + +The control-logic rules are CWE-mapped and include: hardcoded credentials +(CWE-798), default/weak passwords (CWE-1393), safety interlock / watchdog bypass +(CWE-1384), unchecked array indexing (CWE-129), division-by-zero (CWE-369, +guard-aware), cleartext/insecure communication (CWE-319), insecure protocol ports +(CWE-319, e.g. Modbus 502, FTP 21, Telnet 23), and unstructured jumps (CWE-691). + +The **SBOM** view lists the CODESYS libraries (`pkg:codesys/@`) and +the runtime; matching runtime components (e.g. the `Cmp*` / `3SLicense` libraries) +surface real CODESYS advisories as CVE alerts.