Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bba3518d1 | ||
|
|
cd65fa345c |
@@ -409,11 +409,17 @@ 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. The
|
// PLC/SPS device: also DAST against a WebVisu / exposed endpoint, but
|
||||||
// control-logic scan already consumed the code artifact, so the SAST
|
// only when DAST is actually planned — a device reachable only over an
|
||||||
// pipeline is not re-run.
|
// industrial protocol (e.g. modbus://) has no web surface to crawl, and
|
||||||
self.update_phase(scan_run_id, "dast_scanning").await;
|
// running DAST there just fails at reconnaissance. Gating here (not only
|
||||||
self.maybe_trigger_dast(&target_id, scan_run_id).await;
|
// 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.maybe_trigger_dast(&target_id, scan_run_id).await;
|
||||||
|
}
|
||||||
return Ok(new_count);
|
return Ok(new_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ 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 (live URL / endpoint).
|
/// A reachable running instance (any live URL / endpoint, scheme-agnostic —
|
||||||
|
/// 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).
|
||||||
@@ -134,7 +138,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, Mobile, Plc, RunningUrl};
|
use ArtifactRequirement::{Firmware, HttpUrl, 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();
|
||||||
@@ -142,7 +146,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",
|
||||||
RunningUrl,
|
HttpUrl,
|
||||||
));
|
));
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
@@ -203,7 +207,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)",
|
||||||
RunningUrl,
|
HttpUrl,
|
||||||
));
|
));
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
@@ -249,7 +253,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)",
|
||||||
RunningUrl,
|
HttpUrl,
|
||||||
),
|
),
|
||||||
ScanRule::new(
|
ScanRule::new(
|
||||||
ScanType::IcsProbe,
|
ScanType::IcsProbe,
|
||||||
@@ -285,7 +289,9 @@ 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 => Some(ArtifactKind::LiveUrl),
|
ArtifactRequirement::RunningUrl | ArtifactRequirement::HttpUrl => {
|
||||||
|
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),
|
||||||
@@ -294,11 +300,22 @@ 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
|
||||||
@@ -322,6 +339,10 @@ 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"),
|
||||||
@@ -462,6 +483,49 @@ 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));
|
||||||
|
|||||||
@@ -78,8 +78,16 @@ pub fn validate_artifact_ref(kind: &str, source_ref: &str) -> Option<String> {
|
|||||||
.then(|| "Enter a git URL — https://…, ssh://…, or git@host:path".to_string())
|
.then(|| "Enter a git URL — https://…, ssh://…, or git@host:path".to_string())
|
||||||
}
|
}
|
||||||
"live_url" => {
|
"live_url" => {
|
||||||
let ok = (s.starts_with("https://") || s.starts_with("http://")) && no_space;
|
// http(s) for web/DAST targets; modbus:// and opc.tcp:// for ICS
|
||||||
(!ok).then(|| "Enter an http(s) URL, e.g. https://app.example.com".to_string())
|
// devices probed by the ICS probe (e.g. modbus://plc:502).
|
||||||
|
let ok = (s.starts_with("https://")
|
||||||
|
|| s.starts_with("http://")
|
||||||
|
|| s.starts_with("modbus://")
|
||||||
|
|| s.starts_with("opc.tcp://"))
|
||||||
|
&& no_space;
|
||||||
|
(!ok).then(|| {
|
||||||
|
"Enter a URL — https://app.example.com, or modbus://host:502 for a PLC".to_string()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
"container_image" => {
|
"container_image" => {
|
||||||
(!no_space).then(|| "Enter an image ref, e.g. registry/name:tag".to_string())
|
(!no_space).then(|| "Enter an image ref, e.g. registry/name:tag".to_string())
|
||||||
@@ -196,6 +204,28 @@ pub async fn update_target(
|
|||||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable specific opt-in scans on a target by setting `scan_config.enabled_scans`.
|
||||||
|
/// `scans` are serde scan-type names (lowercase, no underscores — e.g. `icsprobe`).
|
||||||
|
#[server]
|
||||||
|
pub async fn enable_target_scans(
|
||||||
|
id: String,
|
||||||
|
scans: Vec<String>,
|
||||||
|
) -> Result<TargetResponse, ServerFnError> {
|
||||||
|
let body = serde_json::json!({ "scan_config": { "enabled_scans": scans } });
|
||||||
|
let resp = super::agent_client::agent_request(
|
||||||
|
reqwest::Method::PATCH,
|
||||||
|
&format!("/api/v1/targets/{id}"),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
resp.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Run kind-based classification on a target.
|
/// Run kind-based classification on a target.
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn detect_target(id: String) -> Result<TargetResponse, ServerFnError> {
|
pub async fn detect_target(id: String) -> Result<TargetResponse, ServerFnError> {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use dioxus::prelude::*;
|
|||||||
|
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::infrastructure::onboarding::{
|
use crate::infrastructure::onboarding::{
|
||||||
create_target, detect_target, fetch_applicable_scans, trigger_target_scan,
|
create_target, detect_target, enable_target_scans, fetch_applicable_scans, trigger_target_scan,
|
||||||
upload_target_artifact, validate_artifact_ref, validate_target_name, ArtifactInputDto,
|
upload_target_artifact, validate_artifact_ref, validate_target_name, ArtifactInputDto,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,11 +137,35 @@ pub fn OnboardingPage() -> Element {
|
|||||||
let mut suggested = use_signal(|| Option::<String>::None);
|
let mut suggested = use_signal(|| Option::<String>::None);
|
||||||
let mut created_id = use_signal(|| Option::<String>::None);
|
let mut created_id = use_signal(|| Option::<String>::None);
|
||||||
let mut scan_msg = use_signal(|| Option::<String>::None);
|
let mut scan_msg = use_signal(|| Option::<String>::None);
|
||||||
|
// Opt-in scans (default-off but unblocked) the user ticks to enable before
|
||||||
|
// running — stored as serde scan-type names (lowercase, no underscores).
|
||||||
|
let mut enabled_extra = use_signal(Vec::<String>::new);
|
||||||
|
|
||||||
let step_now = step();
|
let step_now = step();
|
||||||
let name_error = validate_target_name(&name());
|
let name_error = validate_target_name(&name());
|
||||||
let can_advance_type = name_error.is_none() && !target_type().trim().is_empty();
|
let can_advance_type = name_error.is_none() && !target_type().trim().is_empty();
|
||||||
let has_artifacts = !artifacts().is_empty() || !pending_files().is_empty();
|
let has_artifacts = !artifacts().is_empty() || !pending_files().is_empty();
|
||||||
|
// Opt-in scans: applicable + unblocked, but default-off (e.g. the ICS probe).
|
||||||
|
// The user ticks these to enable them before the first run. Each entry is
|
||||||
|
// (display name for the label, serde scan-type name for the enable call —
|
||||||
|
// lowercase, no underscores, matching ScanType's rename_all = "lowercase").
|
||||||
|
let optin_scans: Vec<(String, String)> = scans()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| {
|
||||||
|
let unblocked = s.get("blocked_reason").and_then(|v| v.as_str()).is_none();
|
||||||
|
let default_on = s
|
||||||
|
.get("default_on")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if unblocked && !default_on {
|
||||||
|
let display = s.get("scan").and_then(|v| v.as_str())?.to_string();
|
||||||
|
let serde_name = display.replace('_', "");
|
||||||
|
Some((display, serde_name))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
// Live validation of the artifact reference being typed (empty = no error yet).
|
// Live validation of the artifact reference being typed (empty = no error yet).
|
||||||
let new_source_error = if new_source().is_empty() {
|
let new_source_error = if new_source().is_empty() {
|
||||||
None
|
None
|
||||||
@@ -456,6 +480,39 @@ pub fn OnboardingPage() -> Element {
|
|||||||
ScanRow { scan: s }
|
ScanRow { scan: s }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !optin_scans.is_empty() {
|
||||||
|
div { style: "margin-top: 12px; padding: 10px; border: 1px dashed var(--border, #ccc); border-radius: 6px;",
|
||||||
|
div { style: "font-weight: 600; margin-bottom: 6px;", "Enable opt-in scans" }
|
||||||
|
div { style: "opacity: 0.7; font-size: 0.85em; margin-bottom: 8px;",
|
||||||
|
"These are applicable but off by default (they touch a live device). Tick to enable before running."
|
||||||
|
}
|
||||||
|
for pair in optin_scans.clone() {
|
||||||
|
{
|
||||||
|
let (display, serde_name) = pair;
|
||||||
|
let cb_name = serde_name.clone();
|
||||||
|
rsx! {
|
||||||
|
label {
|
||||||
|
style: "display: flex; gap: 6px; align-items: center; margin-top: 4px;",
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: enabled_extra().contains(&serde_name),
|
||||||
|
onchange: move |_| {
|
||||||
|
let mut v = enabled_extra();
|
||||||
|
if let Some(p) = v.iter().position(|x| x == &cb_name) {
|
||||||
|
v.remove(p);
|
||||||
|
} else {
|
||||||
|
v.push(cb_name.clone());
|
||||||
|
}
|
||||||
|
enabled_extra.set(v);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
"Enable {display}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(msg) = scan_msg() {
|
if let Some(msg) = scan_msg() {
|
||||||
div { style: "margin-top: 8px; color: var(--success, #2a2);", "{msg}" }
|
div { style: "margin-top: 8px; color: var(--success, #2a2);", "{msg}" }
|
||||||
}
|
}
|
||||||
@@ -464,8 +521,21 @@ pub fn OnboardingPage() -> Element {
|
|||||||
class: "btn btn-primary",
|
class: "btn btn-primary",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
if let Some(id) = created_id() {
|
if let Some(id) = created_id() {
|
||||||
|
let extra = enabled_extra();
|
||||||
scan_msg.set(Some("Scan triggered...".to_string()));
|
scan_msg.set(Some("Scan triggered...".to_string()));
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
|
// Persist any ticked opt-in scans first, so the
|
||||||
|
// agent's build_scan_plan includes them this run.
|
||||||
|
if !extra.is_empty() {
|
||||||
|
if let Err(e) =
|
||||||
|
enable_target_scans(id.clone(), extra).await
|
||||||
|
{
|
||||||
|
scan_msg.set(Some(format!(
|
||||||
|
"Failed to enable opt-in scans: {e}"
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
match trigger_target_scan(id).await {
|
match trigger_target_scan(id).await {
|
||||||
Ok(_) => scan_msg.set(Some(
|
Ok(_) => scan_msg.set(Some(
|
||||||
"Scan started — findings will appear as it runs.".to_string(),
|
"Scan started — findings will appear as it runs.".to_string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user