diff --git a/compliance-agent/src/api/handlers/onboarding.rs b/compliance-agent/src/api/handlers/onboarding.rs index f3d7f9d..55ab297 100644 --- a/compliance-agent/src/api/handlers/onboarding.rs +++ b/compliance-agent/src/api/handlers/onboarding.rs @@ -75,6 +75,9 @@ pub struct UpdateTargetRequest { pub scan_config: Option, pub compliance_profile: Option, pub scan_schedule: Option, + /// Replace the target's artifacts wholesale (used by the dashboard editor). + #[serde(default)] + pub artifacts: Option>, } /// One applicable-scan option, serialized for the wizard. @@ -214,6 +217,13 @@ pub async fn update_target( if let Some(ss) = req.scan_schedule { set.insert("scan_schedule", ss); } + if let Some(arts) = req.artifacts { + let built: Vec = arts.iter().map(ArtifactInput::build).collect(); + set.insert( + "artifacts", + to_bson(&built).map_err(|_| StatusCode::BAD_REQUEST)?, + ); + } db.onboarded_targets() .update_one(doc! { "_id": oid }, doc! { "$set": set }) diff --git a/compliance-agent/src/api/handlers/sbom.rs b/compliance-agent/src/api/handlers/sbom.rs index dbafe84..1e8422d 100644 --- a/compliance-agent/src/api/handlers/sbom.rs +++ b/compliance-agent/src/api/handlers/sbom.rs @@ -282,7 +282,7 @@ pub async fn license_summary( } }) .collect(); - summaries.sort_by(|a, b| b.count.cmp(&a.count)); + summaries.sort_by_key(|s| std::cmp::Reverse(s.count)); Ok(Json(ApiResponse { data: summaries, diff --git a/compliance-agent/src/pipeline/git.rs b/compliance-agent/src/pipeline/git.rs index 9508c3f..8d9c8a5 100644 --- a/compliance-agent/src/pipeline/git.rs +++ b/compliance-agent/src/pipeline/git.rs @@ -80,7 +80,10 @@ impl GitOps { #[tracing::instrument(skip_all, fields(repo_name = %repo_name))] pub fn clone_or_fetch(&self, git_url: &str, repo_name: &str) -> Result { - let repo_path = self.base_path.join(repo_name); + // Names can contain slashes or other path-hostile characters (a target + // named after a repo path, say); collapse to one safe directory segment + // so the clone path never nests or breaks. + let repo_path = self.base_path.join(sanitize_repo_dir(repo_name)); if repo_path.exists() { tracing::info!("fetching updates for existing repo"); @@ -253,3 +256,46 @@ pub struct DiffFile { pub path: String, pub hunks: String, } + +/// Collapse a repository name into a single filesystem-safe directory segment. +/// Names may carry slashes or other path-hostile characters (a target named +/// after a repo path, for instance); those would otherwise nest or break the +/// clone path, so map anything outside `[A-Za-z0-9._-]` to `_`. +fn sanitize_repo_dir(name: &str) -> String { + let mapped: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { + c + } else { + '_' + } + }) + .collect(); + let trimmed = mapped.trim_matches(|c| c == '.' || c == '_'); + if trimmed.is_empty() { + "repo".to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::sanitize_repo_dir; + + #[test] + fn sanitizes_path_hostile_names() { + assert_eq!( + sanitize_repo_dir("zephyr-example-app"), + "zephyr-example-app" + ); + assert_eq!( + sanitize_repo_dir("ChristianRinn/bare_metal_stm32f411xe"), + "ChristianRinn_bare_metal_stm32f411xe" + ); + assert_eq!(sanitize_repo_dir("../../etc/passwd"), "etc_passwd"); + assert_eq!(sanitize_repo_dir("a b:c"), "a_b_c"); + assert_eq!(sanitize_repo_dir("///"), "repo"); + } +} diff --git a/compliance-dashboard/src/infrastructure/onboarding.rs b/compliance-dashboard/src/infrastructure/onboarding.rs index 5cd050e..3f9552e 100644 --- a/compliance-dashboard/src/infrastructure/onboarding.rs +++ b/compliance-dashboard/src/infrastructure/onboarding.rs @@ -39,6 +39,59 @@ pub struct ApplicableScansResponse { pub data: ApplicableScansData, } +/// Validate a target name. The name is used as the clone directory downstream, +/// so it must be a single safe segment (no slashes) and free of stray spaces. +pub fn validate_target_name(name: &str) -> Option { + let n = name.trim(); + if n.is_empty() { + return Some("Enter a name".to_string()); + } + if name != n { + return Some("Remove the leading/trailing spaces".to_string()); + } + if n.contains('/') || n.contains('\\') { + return Some("No slashes — the name becomes a folder (e.g. stm32f411-blinky)".to_string()); + } + None +} + +/// Client-side validation of an artifact reference for its kind. Returns an +/// error message when the value is obviously wrong for its category, so the +/// wizard / editor can flag it up front instead of the scan discovering it. +pub fn validate_artifact_ref(kind: &str, source_ref: &str) -> Option { + let s = source_ref; + if s.trim().is_empty() { + return Some("Cannot be empty".to_string()); + } + if s != s.trim() { + return Some("Remove the leading/trailing spaces".to_string()); + } + let no_space = !s.contains(char::is_whitespace); + match kind { + "git_repo" => { + let looks_git = s.starts_with("https://") + || s.starts_with("http://") + || s.starts_with("ssh://") + || s.starts_with("git://") + || (s.contains('@') && s.contains(':')); + (!(looks_git && no_space)) + .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()) + } + "container_image" => { + (!no_space).then(|| "Enter an image ref, e.g. registry/name:tag".to_string()) + } + "source_archive" | "firmware_image" | "mobile_package" | "plc_project" => { + (!no_space).then(|| "Enter a path or URL (no spaces)".to_string()) + } + // plaintext_description (and anything unknown): accept free-form text. + _ => None, + } +} + /// List onboarded targets. #[server] pub async fn fetch_targets() -> Result { @@ -77,6 +130,41 @@ pub async fn create_target( .map_err(|e| ServerFnError::new(e.to_string())) } +/// Update a target's name / type / artifacts (dashboard editor). +#[server] +pub async fn update_target( + id: String, + name: Option, + target_type: Option, + artifacts: Option>, +) -> Result { + let mut body = serde_json::Map::new(); + if let Some(n) = name { + body.insert("name".to_string(), serde_json::json!(n)); + } + if let Some(t) = target_type { + body.insert("target_type".to_string(), serde_json::json!(t)); + } + if let Some(a) = artifacts { + body.insert( + "artifacts".to_string(), + serde_json::to_value(a).map_err(|e| ServerFnError::new(e.to_string()))?, + ); + } + let resp = super::agent_client::agent_request( + reqwest::Method::PATCH, + &format!("/api/v1/targets/{id}"), + ) + .await? + .json(&serde_json::Value::Object(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 5e8aaf6..9074e80 100644 --- a/compliance-dashboard/src/pages/onboarding.rs +++ b/compliance-dashboard/src/pages/onboarding.rs @@ -2,7 +2,8 @@ use dioxus::prelude::*; use crate::components::page_header::PageHeader; use crate::infrastructure::onboarding::{ - create_target, detect_target, fetch_applicable_scans, trigger_target_scan, ArtifactInputDto, + create_target, detect_target, fetch_applicable_scans, trigger_target_scan, + validate_artifact_ref, validate_target_name, ArtifactInputDto, }; /// (value, label, one-line description) for the 9 target families. @@ -117,8 +118,15 @@ pub fn OnboardingPage() -> Element { let mut scan_msg = use_signal(|| Option::::None); let step_now = step(); - let can_advance_type = !name().trim().is_empty() && !target_type().trim().is_empty(); + 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(); + // Live validation of the artifact reference being typed (empty = no error yet). + let new_source_error = if new_source().is_empty() { + None + } else { + validate_artifact_ref(&new_kind(), &new_source()) + }; rsx! { PageHeader { @@ -157,6 +165,11 @@ pub fn OnboardingPage() -> Element { value: "{name}", oninput: move |e| name.set(e.value()), } + if !name().is_empty() { + if let Some(err) = name_error.clone() { + div { style: "color: var(--danger, #d33); font-size: 0.85em; margin-top: 4px;", "{err}" } + } + } } div { style: "display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-top: 12px;", @@ -213,9 +226,12 @@ pub fn OnboardingPage() -> Element { } button { class: "btn btn-secondary", + disabled: new_source().trim().is_empty() || new_source_error.is_some(), onclick: move |_| { let kind = new_kind(); - if !new_source().trim().is_empty() { + if !new_source().trim().is_empty() + && validate_artifact_ref(&kind, &new_source()).is_none() + { let branch = if kind == "git_repo" { Some(new_branch()) } else { None }; artifacts.write().push(ArtifactInputDto { kind, @@ -229,6 +245,9 @@ pub fn OnboardingPage() -> Element { "+ Add" } } + if let Some(err) = new_source_error.clone() { + div { style: "color: var(--danger, #d33); font-size: 0.85em; margin-top: 6px;", "{err}" } + } div { style: "margin-top: 16px;", if has_artifacts { diff --git a/compliance-dashboard/src/pages/targets.rs b/compliance-dashboard/src/pages/targets.rs index a202d38..bd88018 100644 --- a/compliance-dashboard/src/pages/targets.rs +++ b/compliance-dashboard/src/pages/targets.rs @@ -12,9 +12,35 @@ use dioxus_free_icons::Icon; use crate::components::page_header::PageHeader; use crate::components::toast::{ToastType, Toasts}; use crate::infrastructure::onboarding::{ - delete_target, fetch_applicable_scans, fetch_targets, trigger_target_scan, + delete_target, fetch_applicable_scans, fetch_targets, trigger_target_scan, update_target, + validate_artifact_ref, validate_target_name, ArtifactInputDto, }; +/// The nine target families (value, label) for the edit form's type selector. +const TARGET_TYPES: &[(&str, &str)] = &[ + ("web_app", "Web Application"), + ("backend_service", "Backend / API"), + ("desktop_app", "Desktop App"), + ("android_app", "Android App"), + ("ios_app", "iOS App"), + ("firmware_bare_metal", "Firmware — bare metal"), + ("firmware_rtos", "Firmware — RTOS"), + ("embedded_linux_yocto", "Embedded Linux / Yocto"), + ("plc_sps", "PLC / SPS"), +]; + +/// The artifact kinds (value, label) for the edit form. +const ARTIFACT_KINDS: &[(&str, &str)] = &[ + ("git_repo", "Git repository"), + ("source_archive", "Source archive (zip)"), + ("firmware_image", "Firmware image"), + ("mobile_package", "Mobile package (APK/IPA)"), + ("container_image", "Container image"), + ("live_url", "Live URL"), + ("plc_project", "PLC project"), + ("plaintext_description", "Description (text)"), +]; + /// Prettify a snake_case target-type value into a human label. fn pretty_type(v: &str) -> String { match v { @@ -106,6 +132,17 @@ pub fn TargetsPage() -> Element { let mut expanded_ids = use_signal(Vec::::new); let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); + // Edit-target modal state. + let mut edit_id = use_signal(|| Option::::None); + let mut edit_name = use_signal(String::new); + let mut edit_type = use_signal(String::new); + let mut edit_arts = use_signal(Vec::::new); + let mut edit_saving = use_signal(|| false); + // In-modal "add artifact" mini-form. + let mut e_kind = use_signal(|| "git_repo".to_string()); + let mut e_source = use_signal(String::new); + let mut e_branch = use_signal(|| "main".to_string()); + let mut targets = use_resource(move || async move { fetch_targets().await.ok() }); rsx! { @@ -163,6 +200,145 @@ pub fn TargetsPage() -> Element { } } + // ── Edit target ── + if let Some(eid) = edit_id() { + { + let name_err = validate_target_name(&edit_name()); + let e_source_err = if e_source().is_empty() { + None + } else { + validate_artifact_ref(&e_kind(), &e_source()) + }; + rsx! { + div { class: "modal-overlay", + div { class: "modal-dialog", + h3 { "Edit target" } + div { class: "form-group", + label { "Name" } + input { + r#type: "text", + value: "{edit_name}", + oninput: move |e| edit_name.set(e.value()), + } + if !edit_name().is_empty() { + if let Some(err) = name_err.clone() { + div { style: "color: var(--danger, #d33); font-size: 0.85em;", "{err}" } + } + } + } + div { class: "form-group", + label { "Type" } + select { + value: "{edit_type}", + oninput: move |e| edit_type.set(e.value()), + for (v, l) in TARGET_TYPES.iter().copied() { + option { value: "{v}", "{l}" } + } + } + } + label { style: "font-weight: 600;", "Artifacts" } + for (i, a) in edit_arts().iter().enumerate() { + div { style: "display: flex; justify-content: space-between; align-items: center; padding: 4px 0;", + span { style: "font-size: 0.9em;", + span { style: "opacity: 0.7;", "{a.kind}: " } + span { style: "font-family: monospace;", "{a.source_ref}" } + } + button { + class: "btn btn-ghost btn-ghost-danger btn-sm", + onclick: move |_| { edit_arts.write().remove(i); }, + "Remove" + } + } + } + div { style: "display: flex; gap: 8px; align-items: flex-end; margin-top: 8px;", + div { class: "form-group", style: "margin: 0;", + label { "Kind" } + select { + value: "{e_kind}", + oninput: move |e| e_kind.set(e.value()), + for (v, l) in ARTIFACT_KINDS.iter().copied() { + option { value: "{v}", "{l}" } + } + } + } + div { class: "form-group", style: "margin: 0; flex: 1;", + label { "Reference" } + input { + r#type: "text", + value: "{e_source}", + oninput: move |e| e_source.set(e.value()), + } + } + if e_kind() == "git_repo" { + div { class: "form-group", style: "margin: 0;", + label { "Branch" } + input { + r#type: "text", + value: "{e_branch}", + oninput: move |e| e_branch.set(e.value()), + } + } + } + button { + class: "btn btn-secondary", + disabled: e_source().trim().is_empty() || e_source_err.is_some(), + onclick: move |_| { + let kind = e_kind(); + if !e_source().trim().is_empty() + && validate_artifact_ref(&kind, &e_source()).is_none() + { + let branch = if kind == "git_repo" { Some(e_branch()) } else { None }; + edit_arts.write().push(ArtifactInputDto { + kind, + source_ref: e_source(), + branch, + plc_format: None, + }); + e_source.set(String::new()); + } + }, + "+ Add" + } + } + if let Some(err) = e_source_err.clone() { + div { style: "color: var(--danger, #d33); font-size: 0.85em;", "{err}" } + } + div { class: "modal-actions", + button { + class: "btn btn-secondary", + onclick: move |_| edit_id.set(None), + "Cancel" + } + button { + class: "btn btn-primary", + disabled: edit_saving() || name_err.is_some(), + onclick: move |_| { + let id = eid.clone(); + let nm = edit_name(); + let tt = edit_type(); + let arts = edit_arts(); + edit_saving.set(true); + spawn(async move { + match update_target(id, Some(nm), Some(tt), Some(arts)).await { + Ok(_) => { + toasts.push(ToastType::Success, "Target updated"); + targets.restart(); + edit_id.set(None); + } + Err(e) => toasts.push(ToastType::Error, e.to_string()), + } + edit_saving.set(false); + }); + }, + if edit_saving() { "Saving..." } else { "Save" } + } + } + } + } + } + } + } + { let targets_snapshot = targets.read().clone(); match &targets_snapshot { @@ -223,8 +399,12 @@ pub fn TargetsPage() -> Element { let id_scan = id.clone(); let id_exp = id.clone(); let id_del = id.clone(); + let id_edit = id.clone(); let name_del = name.clone(); + let name_edit = name.clone(); + let ttype_raw = str_at(&t, "target_type").to_string(); let artifacts_detail = artifacts.clone(); + let artifacts_edit = artifacts.clone(); rsx! { tr { td { strong { "{name}" } } @@ -253,6 +433,31 @@ pub fn TargetsPage() -> Element { }, Icon { icon: BsInfoCircle, width: 16, height: 16 } } + button { + class: "btn btn-ghost", + title: "Edit target", + onclick: move |_| { + edit_name.set(name_edit.clone()); + edit_type.set(ttype_raw.clone()); + let arts: Vec = artifacts_edit + .iter() + .map(|a| ArtifactInputDto { + kind: str_at(a, "kind").to_string(), + source_ref: str_at(a, "source_ref").to_string(), + branch: a + .get("git") + .and_then(|g| g.get("default_branch")) + .and_then(|b| b.as_str()) + .map(String::from), + plc_format: None, + }) + .collect(); + edit_arts.set(arts); + e_source.set(String::new()); + edit_id.set(Some(id_edit.clone())); + }, + Icon { icon: BsPencil, width: 16, height: 16 } + } button { class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" }, title: "Run scan",