diff --git a/compliance-dashboard/src/infrastructure/onboarding.rs b/compliance-dashboard/src/infrastructure/onboarding.rs index 8f186fb..0e7e5c2 100644 --- a/compliance-dashboard/src/infrastructure/onboarding.rs +++ b/compliance-dashboard/src/infrastructure/onboarding.rs @@ -78,8 +78,16 @@ pub fn validate_artifact_ref(kind: &str, source_ref: &str) -> Option { .then(|| "Enter a git URL — https://…, ssh://…, or git@host:path".to_string()) } "live_url" => { - let ok = (s.starts_with("https://") || s.starts_with("http://")) && no_space; - (!ok).then(|| "Enter an http(s) URL, e.g. https://app.example.com".to_string()) + // http(s) for web/DAST targets; modbus:// and opc.tcp:// for ICS + // 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" => { (!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())) } +/// 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, +) -> Result { + 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. #[server] pub async fn detect_target(id: String) -> Result { diff --git a/compliance-dashboard/src/pages/onboarding.rs b/compliance-dashboard/src/pages/onboarding.rs index 0fd3461..e9d096a 100644 --- a/compliance-dashboard/src/pages/onboarding.rs +++ b/compliance-dashboard/src/pages/onboarding.rs @@ -2,7 +2,7 @@ use dioxus::prelude::*; use crate::components::page_header::PageHeader; 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, }; @@ -137,11 +137,35 @@ pub fn OnboardingPage() -> Element { let mut suggested = use_signal(|| Option::::None); let mut created_id = use_signal(|| Option::::None); let mut scan_msg = use_signal(|| Option::::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::::new); let step_now = step(); let name_error = validate_target_name(&name()); let can_advance_type = name_error.is_none() && !target_type().trim().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). let new_source_error = if new_source().is_empty() { None @@ -456,6 +480,39 @@ pub fn OnboardingPage() -> Element { 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() { div { style: "margin-top: 8px; color: var(--success, #2a2);", "{msg}" } } @@ -464,8 +521,21 @@ pub fn OnboardingPage() -> Element { class: "btn btn-primary", onclick: move |_| { if let Some(id) = created_id() { + let extra = enabled_extra(); scan_msg.set(Some("Scan triggered...".to_string())); 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 { Ok(_) => scan_msg.set(Some( "Scan started — findings will appear as it runs.".to_string(),