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
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:
@@ -449,23 +449,24 @@ impl PipelineOrchestrator {
|
|||||||
// wizard-created targets, not just migrated ones.
|
// wizard-created targets, not just migrated ones.
|
||||||
self.ensure_dast_target(target, &plan).await;
|
self.ensure_dast_target(target, &plan).await;
|
||||||
|
|
||||||
// PLC control-logic analysis for PLC/SPS targets (a PlcProject artifact).
|
// PLC/SPS targets: the control-logic scan consumes the PLC source (an
|
||||||
// A PLC/SPS device is a composite target: after the control-logic scan we
|
// uploaded PlcProject *or* a git repo / source archive of PLCopen XML / ST
|
||||||
// fall through so a reachable device (WebVisu / exposed services) still
|
// exports), so it takes over the code artifact — we don't also run the
|
||||||
// gets DAST, rather than early-returning on the PLC scan alone.
|
// 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;
|
let mut new_count = 0u32;
|
||||||
if plan.has(ScanType::PlcControlLogic) {
|
if plan.has(ScanType::PlcControlLogic) {
|
||||||
new_count += self.run_plc_scan(target, &target_id, scan_run_id).await?;
|
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() {
|
match target.code_artifact() {
|
||||||
Some(code) if code.kind == ArtifactKind::GitRepo => {
|
Some(code) if code.kind == ArtifactKind::GitRepo => {
|
||||||
let n = {
|
let repo = RepoView::from_target(target, code);
|
||||||
let repo = RepoView::from_target(target, code);
|
let n = self.run_pipeline(&repo, scan_run_id).await?;
|
||||||
let n = self.run_pipeline(&repo, scan_run_id).await?;
|
self.finalize_target(target, &repo, n).await?;
|
||||||
self.finalize_target(target, &repo, n).await?;
|
|
||||||
n
|
|
||||||
};
|
|
||||||
new_count += n;
|
new_count += n;
|
||||||
}
|
}
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
@@ -475,10 +476,10 @@ impl PipelineOrchestrator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// No code to scan (a PLC device or a migrated DAST target).
|
// No code to scan (a migrated DAST target). Firmware/mobile static
|
||||||
// Firmware/mobile static scanners land in #128/#129; DAST for a
|
// scanners land in #128/#129; DAST for a running URL works when a
|
||||||
// running URL works when a DastTarget row exists (provisioned above
|
// DastTarget row exists (provisioned above from a LiveUrl, or from
|
||||||
// from a LiveUrl, or from a migrated target).
|
// a migrated target).
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target_id = %target_id,
|
target_id = %target_id,
|
||||||
"Unified pipeline: no code artifact; attempting DAST"
|
"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 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 Some(artifact) = target.first_of(ArtifactKind::PlcProject) else {
|
// The PLC source: a dedicated PlcProject artifact, else a code artifact
|
||||||
tracing::warn!(target_id, "PLC scan: no PLC project 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);
|
return Ok(0);
|
||||||
};
|
};
|
||||||
let Some(path) = ingest_set
|
let Some(path) = ingest_set
|
||||||
.get(&artifact.id)
|
.get(&artifact.id)
|
||||||
.and_then(|ia| ia.working_path.clone())
|
.and_then(|ia| ia.working_path.clone())
|
||||||
else {
|
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);
|
return Ok(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -538,14 +544,17 @@ impl PipelineOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Control-application dependency SBOM: the CODESYS libraries + runtime
|
// Control-application dependency SBOM: the CODESYS libraries + runtime
|
||||||
// bundled in a `.projectarchive`, matched against known CVEs. Best-effort
|
// bundled in a `.projectarchive`, matched against known CVEs. Sourced from
|
||||||
// and empty for a bare `.st`/`.xml` (which carries no library manifest).
|
// 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
|
let archive = artifact
|
||||||
.stored_path
|
.stored_path
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| artifact.source_ref.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),
|
std::path::Path::new(&archive),
|
||||||
|
&path,
|
||||||
target_id,
|
target_id,
|
||||||
);
|
);
|
||||||
if !sbom.is_empty() {
|
if !sbom.is_empty() {
|
||||||
|
|||||||
@@ -75,10 +75,16 @@ pub fn build_scan_plan(target: &OnboardedTarget) -> ScanPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the artifact a scan consumes. A "code" requirement (represented by
|
/// 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> {
|
fn resolve_artifact(target: &OnboardedTarget, required: Option<ArtifactKind>) -> Option<&Artifact> {
|
||||||
match required {
|
match required {
|
||||||
Some(ArtifactKind::GitRepo) => target.code_artifact(),
|
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),
|
Some(kind) => target.first_of(kind),
|
||||||
None => target.code_artifact().or_else(|| target.artifacts.first()),
|
None => target.code_artifact().or_else(|| target.artifacts.first()),
|
||||||
}
|
}
|
||||||
@@ -174,6 +180,18 @@ mod tests {
|
|||||||
assert_eq!(plan.steps[0].phase, ScanPhase::PlcAnalysis);
|
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]
|
#[test]
|
||||||
fn disabled_scan_is_dropped_and_off_by_default_can_be_enabled() {
|
fn disabled_scan_is_dropped_and_off_by_default_can_be_enabled() {
|
||||||
let mut t = target(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]);
|
let mut t = target(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]);
|
||||||
|
|||||||
@@ -11,10 +11,46 @@
|
|||||||
//! CVEs).
|
//! CVEs).
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use compliance_core::models::SbomEntry;
|
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).
|
/// 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
|
/// Best-effort: returns empty if the file is not a readable zip (e.g. a bare
|
||||||
/// `.st`/`.xml` project, which carries no library manifest).
|
/// `.st`/`.xml` project, which carries no library manifest).
|
||||||
@@ -114,7 +150,12 @@ mod tests {
|
|||||||
/// device descriptor.
|
/// device descriptor.
|
||||||
fn synthetic_archive(dir: &Path) -> std::path::PathBuf {
|
fn synthetic_archive(dir: &Path) -> std::path::PathBuf {
|
||||||
let path = dir.join("App.projectarchive");
|
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 mut zip = zip::ZipWriter::new(file);
|
||||||
let opts: zip::write::SimpleFileOptions = Default::default();
|
let opts: zip::write::SimpleFileOptions = Default::default();
|
||||||
let names = [
|
let names = [
|
||||||
@@ -131,7 +172,6 @@ mod tests {
|
|||||||
zip.write_all(b"x").expect("write");
|
zip.write_all(b"x").expect("write");
|
||||||
}
|
}
|
||||||
zip.finish().expect("finish");
|
zip.finish().expect("finish");
|
||||||
path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -179,6 +219,28 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&tmp);
|
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]
|
#[test]
|
||||||
fn non_zip_file_yields_no_sbom() {
|
fn non_zip_file_yields_no_sbom() {
|
||||||
let tmp = std::env::temp_dir().join(format!("cs-plc-sbom-st-{}", uuid::Uuid::new_v4()));
|
let tmp = std::env::temp_dir().join(format!("cs-plc-sbom-st-{}", uuid::Uuid::new_v4()));
|
||||||
|
|||||||
@@ -294,7 +294,12 @@ fn requirement_satisfied(req: ArtifactRequirement, target: &OnboardedTarget) ->
|
|||||||
ArtifactRequirement::Code => target.code_artifact().is_some(),
|
ArtifactRequirement::Code => target.code_artifact().is_some(),
|
||||||
ArtifactRequirement::RunningUrl => target.has(ArtifactKind::LiveUrl),
|
ArtifactRequirement::RunningUrl => target.has(ArtifactKind::LiveUrl),
|
||||||
ArtifactRequirement::Firmware => target.has(ArtifactKind::FirmwareImage),
|
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::Mobile => target.has(ArtifactKind::MobilePackage),
|
||||||
ArtifactRequirement::Container => target.has(ArtifactKind::ContainerImage),
|
ArtifactRequirement::Container => target.has(ArtifactKind::ContainerImage),
|
||||||
ArtifactRequirement::Any => true,
|
ArtifactRequirement::Any => true,
|
||||||
@@ -406,6 +411,19 @@ mod tests {
|
|||||||
assert!(dast.blocked_reason.is_some());
|
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]
|
#[test]
|
||||||
fn plc_composite_lights_up_device_scans_with_firmware_and_url() {
|
fn plc_composite_lights_up_device_scans_with_firmware_and_url() {
|
||||||
// A CODESYS-on-Yocto device: PLC project + firmware image + WebVisu URL.
|
// A CODESYS-on-Yocto device: PLC project + firmware image + WebVisu URL.
|
||||||
|
|||||||
@@ -214,6 +214,36 @@ pub fn OnboardingPage() -> Element {
|
|||||||
// ---- Step 1: artifacts ----
|
// ---- Step 1: artifacts ----
|
||||||
if step_now == 1 {
|
if step_now == 1 {
|
||||||
div { class: "card-header", "Attach artifacts" }
|
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: "padding: 16px;",
|
||||||
div { style: "display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end;",
|
div { style: "display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end;",
|
||||||
div { class: "form-group", style: "margin: 0;",
|
div { class: "form-group", style: "margin: 0;",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default withMermaid(defineConfig({
|
|||||||
{ text: 'Getting Started', link: '/guide/getting-started' },
|
{ text: 'Getting Started', link: '/guide/getting-started' },
|
||||||
{ text: 'Adding Repositories', link: '/guide/repositories' },
|
{ text: 'Adding Repositories', link: '/guide/repositories' },
|
||||||
{ text: 'Running Scans', link: '/guide/scanning' },
|
{ text: 'Running Scans', link: '/guide/scanning' },
|
||||||
|
{ text: 'PLC / SPS (CODESYS)', link: '/guide/plc' },
|
||||||
{ text: 'Understanding Findings', link: '/guide/findings' },
|
{ text: 'Understanding Findings', link: '/guide/findings' },
|
||||||
{ text: 'SBOM & Licenses', link: '/guide/sbom' },
|
{ text: 'SBOM & Licenses', link: '/guide/sbom' },
|
||||||
{ text: 'Issues & Tracking', link: '/guide/issues' },
|
{ text: 'Issues & Tracking', link: '/guide/issues' },
|
||||||
|
|||||||
@@ -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/<name>@<version>`) and
|
||||||
|
the runtime; matching runtime components (e.g. the `Cmp*` / `3SLicense` libraries)
|
||||||
|
surface real CODESYS advisories as CVE alerts.
|
||||||
Reference in New Issue
Block a user