Compare commits

..
Author SHA1 Message Date
Sharang ParnerkarandClaude Opus 4.8 37dac862e8 feat(onboarding): accept modbus:// and opc.tcp:// live-URL refs
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Check (pull_request) Successful in 5m18s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
The ICS probe targets a device's OT ports (Modbus 502, OPC UA 4840,
EtherNet/IP 44818) via the target's LiveUrl artifact. The wizard's
client-side validation only accepted http(s):// refs, so a PLC endpoint
like modbus://plc:502 was rejected and the probe could never be
onboarded. parse_endpoint already understands the modbus:// scheme;
this just lets the ref through the wizard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:36:34 +02:00
Sharang ParnerkarandClaude Opus 4.8 74ced0d740 feat(onboarding): enable opt-in scans from the wizard success step
CI / Detect Changes (pull_request) Has been cancelled
CI / Deploy Agent (pull_request) Has been cancelled
CI / Deploy Dashboard (pull_request) Has been cancelled
CI / Deploy Docs (pull_request) Has been cancelled
CI / Deploy MCP (pull_request) Has been cancelled
CI / Check (pull_request) Has been cancelled
The ICS probe (ScanType::IcsProbe) and other default-off scans are
applicable to a target but excluded from a run unless the target's
scan_config.enabled_scans lists them — build_scan_plan only includes a
scan when default_on || enabled_scans.contains(scan). Until now the
wizard had no way to enable them, so they could never be triggered.

Add a checkbox per applicable-but-default-off scan on the success step,
and a new enable_target_scans server fn that PATCHes
/api/v1/targets/{id} with { scan_config: { enabled_scans } }. Run scan
now persists the ticked scans (serde names, e.g. "icsprobe") before
triggering, so the first run includes them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:33:33 +02:00
3 changed files with 11 additions and 147 deletions
@@ -409,17 +409,11 @@ impl PipelineOrchestrator {
new_count += self.run_ics_probe(target, &target_id, scan_run_id).await?; new_count += self.run_ics_probe(target, &target_id, scan_run_id).await?;
} }
if plc || ics { if plc || ics {
// PLC/SPS device: also DAST against a WebVisu / exposed endpoint, but // PLC/SPS device: also DAST against a WebVisu / exposed endpoint. The
// only when DAST is actually planned — a device reachable only over an // control-logic scan already consumed the code artifact, so the SAST
// industrial protocol (e.g. modbus://) has no web surface to crawl, and // pipeline is not re-run.
// running DAST there just fails at reconnaissance. Gating here (not only
// at provisioning) also stops a DAST target left over from an earlier
// run from re-triggering. The control-logic scan already consumed the
// code artifact, so the SAST pipeline is not re-run.
if plan.has(ScanType::Dast) {
self.update_phase(scan_run_id, "dast_scanning").await; self.update_phase(scan_run_id, "dast_scanning").await;
self.maybe_trigger_dast(&target_id, scan_run_id).await; self.maybe_trigger_dast(&target_id, scan_run_id).await;
}
return Ok(new_count); return Ok(new_count);
} }
+6 -70
View File
@@ -14,12 +14,8 @@ use crate::models::{ArtifactKind, OnboardedTarget, ScanType, TargetType};
pub enum ArtifactRequirement { pub enum ArtifactRequirement {
/// Source code — a git repo or a source archive. /// Source code — a git repo or a source archive.
Code, Code,
/// A reachable running instance (any live URL / endpoint, scheme-agnostic — /// A reachable running instance (live URL / endpoint).
/// e.g. the ICS probe works off the host:port of a modbus:// or http:// ref).
RunningUrl, RunningUrl,
/// A reachable **web** endpoint — a live URL with an http(s) scheme. DAST is
/// an HTTP crawler, so a modbus:// / opc.tcp:// endpoint does not satisfy it.
HttpUrl,
/// A firmware image / binary blob. /// A firmware image / binary blob.
Firmware, Firmware,
/// A PLC project (PLCopen XML or Structured Text). /// A PLC project (PLCopen XML or Structured Text).
@@ -138,7 +134,7 @@ fn sast_umbrella() -> Vec<ScanRule> {
/// The rule set for a target type. Scans that are never applicable to a type are /// The rule set for a target type. Scans that are never applicable to a type are
/// simply absent (e.g. DAST is not listed for a PLC target). /// simply absent (e.g. DAST is not listed for a PLC target).
pub fn rules_for(target_type: TargetType) -> Vec<ScanRule> { pub fn rules_for(target_type: TargetType) -> Vec<ScanRule> {
use ArtifactRequirement::{Firmware, HttpUrl, Mobile, Plc, RunningUrl}; use ArtifactRequirement::{Firmware, Mobile, Plc, RunningUrl};
match target_type { match target_type {
TargetType::WebApp | TargetType::BackendService => { TargetType::WebApp | TargetType::BackendService => {
let mut r = sast_umbrella(); let mut r = sast_umbrella();
@@ -146,7 +142,7 @@ pub fn rules_for(target_type: TargetType) -> Vec<ScanRule> {
ScanType::Dast, ScanType::Dast,
true, true,
"Dynamic scan of the running endpoint", "Dynamic scan of the running endpoint",
HttpUrl, RunningUrl,
)); ));
r r
} }
@@ -207,7 +203,7 @@ pub fn rules_for(target_type: TargetType) -> Vec<ScanRule> {
ScanType::Dast, ScanType::Dast,
false, false,
"Dynamic scan of exposed network services (if any)", "Dynamic scan of exposed network services (if any)",
HttpUrl, RunningUrl,
)); ));
r r
} }
@@ -253,7 +249,7 @@ pub fn rules_for(target_type: TargetType) -> Vec<ScanRule> {
ScanType::Dast, ScanType::Dast,
false, false,
"Dynamic scan of the running device (WebVisu / exposed services)", "Dynamic scan of the running device (WebVisu / exposed services)",
HttpUrl, RunningUrl,
), ),
ScanRule::new( ScanRule::new(
ScanType::IcsProbe, ScanType::IcsProbe,
@@ -289,9 +285,7 @@ pub fn supports_pentest(target_type: TargetType) -> bool {
fn representative_kind(req: ArtifactRequirement) -> Option<ArtifactKind> { fn representative_kind(req: ArtifactRequirement) -> Option<ArtifactKind> {
match req { match req {
ArtifactRequirement::Code => Some(ArtifactKind::GitRepo), ArtifactRequirement::Code => Some(ArtifactKind::GitRepo),
ArtifactRequirement::RunningUrl | ArtifactRequirement::HttpUrl => { ArtifactRequirement::RunningUrl => Some(ArtifactKind::LiveUrl),
Some(ArtifactKind::LiveUrl)
}
ArtifactRequirement::Firmware => Some(ArtifactKind::FirmwareImage), ArtifactRequirement::Firmware => Some(ArtifactKind::FirmwareImage),
ArtifactRequirement::Plc => Some(ArtifactKind::PlcProject), ArtifactRequirement::Plc => Some(ArtifactKind::PlcProject),
ArtifactRequirement::Mobile => Some(ArtifactKind::MobilePackage), ArtifactRequirement::Mobile => Some(ArtifactKind::MobilePackage),
@@ -300,22 +294,11 @@ fn representative_kind(req: ArtifactRequirement) -> Option<ArtifactKind> {
} }
} }
/// Whether a live-URL reference is an http(s) web endpoint (vs. an industrial
/// endpoint like `modbus://` / `opc.tcp://`, which DAST cannot crawl).
fn is_http_url(source_ref: &str) -> bool {
let s = source_ref.trim();
s.starts_with("http://") || s.starts_with("https://")
}
/// Whether the target carries an artifact that satisfies the requirement. /// Whether the target carries an artifact that satisfies the requirement.
fn requirement_satisfied(req: ArtifactRequirement, target: &OnboardedTarget) -> bool { fn requirement_satisfied(req: ArtifactRequirement, target: &OnboardedTarget) -> bool {
match req { match req {
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::HttpUrl => target
.artifacts
.iter()
.any(|a| a.kind == ArtifactKind::LiveUrl && is_http_url(&a.source_ref)),
ArtifactRequirement::Firmware => target.has(ArtifactKind::FirmwareImage), ArtifactRequirement::Firmware => target.has(ArtifactKind::FirmwareImage),
// A PLC project artifact, or a code artifact (git repo / source archive) // A PLC project artifact, or a code artifact (git repo / source archive)
// holding the control logic as PLCopen XML / ST exports — the common way // holding the control logic as PLCopen XML / ST exports — the common way
@@ -339,10 +322,6 @@ pub fn applicable_scans(target: &OnboardedTarget) -> Vec<ScanOption> {
let required_artifact = representative_kind(rule.requires); let required_artifact = representative_kind(rule.requires);
let blocked_reason = if satisfied { let blocked_reason = if satisfied {
None None
} else if rule.requires == ArtifactRequirement::HttpUrl {
// A live URL may be present but non-HTTP (e.g. modbus://): be
// specific so the user knows DAST needs a web endpoint.
Some("no http(s) live URL — DAST needs a web endpoint".to_string())
} else { } else {
Some(match required_artifact { Some(match required_artifact {
Some(kind) => format!("no {kind} artifact provided"), Some(kind) => format!("no {kind} artifact provided"),
@@ -483,49 +462,6 @@ mod tests {
.is_none()); .is_none());
} }
#[test]
fn plc_with_modbus_url_offers_ics_probe_but_blocks_dast() {
// A soft-PLC reachable only over Modbus/TCP (no WebVisu). The ICS probe
// is applicable (it works off host:port), but DAST — an HTTP crawler —
// must be blocked so it isn't offered/run against a non-web endpoint.
let t = target_with(
TargetType::PlcSps,
vec![Artifact::live_url("modbus://plc-sim:502")],
);
let opts = applicable_scans(&t);
let ics = option(&opts, ScanType::IcsProbe).expect("ics probe offered");
assert!(
ics.blocked_reason.is_none(),
"ICS probe should be unblocked for a modbus:// endpoint"
);
assert!(!ics.default_on, "ICS probe stays opt-in (default-off)");
let dast = option(&opts, ScanType::Dast).expect("dast listed");
assert!(
dast.blocked_reason.is_some(),
"DAST must be blocked without an http(s) endpoint"
);
assert!(!dast.default_on);
}
#[test]
fn plc_with_http_webvisu_offers_both_dast_and_ics_probe() {
// A PLC exposing a WebVisu over HTTP: both DAST (web) and the ICS probe
// (OT ports on the same host) are applicable.
let t = target_with(
TargetType::PlcSps,
vec![Artifact::live_url("http://plc.local/webvisu")],
);
let opts = applicable_scans(&t);
assert!(option(&opts, ScanType::Dast)
.expect("dast offered")
.blocked_reason
.is_none());
assert!(option(&opts, ScanType::IcsProbe)
.expect("ics probe offered")
.blocked_reason
.is_none());
}
#[test] #[test]
fn pentest_support_matches_reachable_families() { fn pentest_support_matches_reachable_families() {
assert!(supports_pentest(TargetType::WebApp)); assert!(supports_pentest(TargetType::WebApp));
-66
View File
@@ -11,72 +11,6 @@ the control application *and* the device it runs on.
| A device firmware image | Firmware SBOM / CVE (opt-in) | | A device firmware image | Firmware SBOM / CVE (opt-in) |
| A reachable endpoint (WebVisu, OPC UA) | DAST / pentest (opt-in) | | A reachable endpoint (WebVisu, OPC UA) | DAST / pentest (opt-in) |
## Anatomy: a soft PLC is a SoC + Linux + runtime
A CODESYS controller is **not** a monolithic appliance like a classic Siemens
S7. It is **PC-based ("soft") control** — commodity silicon running a
general-purpose Linux, with a **software PLC runtime** as just another process:
| Classic PLC (e.g. Siemens S7) | Soft PLC (CODESYS-on-Yocto, OpenPLC-on-Raspbian) |
| --- | --- |
| Proprietary hardware + firmware | Commodity SoC (x86 / ARM) |
| Proprietary OS | General-purpose Linux (a **Yocto** image, or Raspbian) |
| Proprietary runtime | Software runtime (**CODESYS Control**, or OpenPLC) |
| STEP7 / TIA project | IEC 61131-3 control app (ST / LD / FBD / SFC) |
Because of this, the device is built along **two independent tracks**, by
different people, on different timelines, and shipped separately. It also
inherits the **entire Linux / IT attack surface on top of** the OT / control
one — which is exactly why a PLC/SPS target is treated as a **composite**:
Certifai ingests one artifact per layer and scans each with the right pipeline.
```mermaid
flowchart TB
subgraph TA["Track A · Device platform — built by the hardware OEM / vendor"]
direction LR
A1["Yocto / OpenEmbedded<br/>BSP + RT kernel"] --> A2["Bake in the CODESYS<br/>Control for Linux runtime"] --> A3["bitbake → device image<br/>.wic / .tar + manifest"]
end
subgraph TB2["Track B · Control application — built by the machine builder / customer"]
direction LR
B1["CODESYS IDE<br/>ST / LD / FBD / SFC + WebVisu"] --> B2["Reference CODESYS +<br/>vendor libraries"] --> B3["Compile → download<br/>to device (gateway 11740)"]
end
A3 --> DEV(["Running soft-PLC device<br/>SoC + Linux + runtime + control app<br/>Modbus · OPC UA · EtherNet/IP · WebVisu"])
B3 --> DEV
subgraph CERT["What Certifai scans — one layer per artifact"]
direction LR
S1["Firmware layer<br/>FirmwareStatic · SBOM · CVE"]
S2["Control-logic layer<br/>PLC SAST — ST + FBD/LD"]
S3["Control-app SBOM<br/>libraries + runtime → CVE"]
S4["Running layer<br/>ICS probe · DAST (WebVisu)"]
end
A3 -. firmware image .-> S1
B1 -. PLCopen XML / ST via git .-> S2
B2 -. projectarchive (zip) .-> S3
DEV -. live URL / provisioned .-> S4
classDef yocto fill:#fde68a,stroke:#b45309,color:#111
classDef codesys fill:#bfdbfe,stroke:#1d4ed8,color:#111
classDef dev fill:#e9d5ff,stroke:#7e22ce,color:#111
classDef cert fill:#bbf7d0,stroke:#15803d,color:#111
class A1,A2,A3 yocto
class B1,B2,B3 codesys
class DEV dev
class S1,S2,S3,S4 cert
```
::: tip Where Yocto fits
Yocto is **Track A** — the *build system* for the device platform. It produces
the Linux image and bakes in the CODESYS runtime, so it is the **firmware
layer**, entirely separate from the control application. Hand it to Certifai as
its own **firmware image** artifact (scanned by the firmware pipeline, not the
PLC pipeline). The device OS need not be Yocto — Raspbian/Debian/Buildroot, or
even an RTOS / bare-metal, are all possible — but Yocto is the common,
product-grade industrial choice.
:::
## Two ways to deliver the project ## Two ways to deliver the project
You can either **upload** the project when onboarding, or point Certifai at a You can either **upload** the project when onboarding, or point Certifai at a