feat(onboarding): enable opt-in scans from the wizard success step #181
@@ -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