diff --git a/Cargo.toml b/Cargo.toml index b1c0598..0e38bbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } chrono = { version = "0.4", features = ["serde"] } mongodb = { version = "3", features = ["rustls-tls", "compat-3-0-0"] } -reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart"], default-features = false } thiserror = "2" sha2 = "0.10" hex = "0.4" diff --git a/compliance-agent/Cargo.toml b/compliance-agent/Cargo.toml index edd972e..d18b5d1 100644 --- a/compliance-agent/Cargo.toml +++ b/compliance-agent/Cargo.toml @@ -34,7 +34,7 @@ hex = { workspace = true } uuid = { workspace = true } secrecy = { workspace = true } regex = { workspace = true } -axum = "0.8" +axum = { version = "0.8", features = ["multipart"] } tower-http = { version = "0.6", features = ["cors", "trace", "set-header"] } git2 = "0.20" octocrab = "0.44" @@ -65,5 +65,5 @@ tokio = { workspace = true } mongodb = { workspace = true } uuid = { workspace = true } secrecy = { workspace = true } -axum = "0.8" +axum = { version = "0.8", features = ["multipart"] } tower-http = { version = "0.6", features = ["cors"] } diff --git a/compliance-agent/src/api/handlers/onboarding.rs b/compliance-agent/src/api/handlers/onboarding.rs index 03eb635..b51efd3 100644 --- a/compliance-agent/src/api/handlers/onboarding.rs +++ b/compliance-agent/src/api/handlers/onboarding.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::sync::Arc; -use axum::extract::{Extension, Path, Query}; +use axum::extract::{Extension, Multipart, Path, Query}; use axum::http::StatusCode; use axum::Json; use mongodb::bson::{doc, oid::ObjectId, to_bson}; @@ -377,6 +377,116 @@ pub async fn add_artifact( get_target(Extension(agent), tenant, Path(id)).await } +/// POST /api/v1/targets/{id}/artifacts/upload — attach an artifact by uploading +/// its file (PLC project, firmware image, source archive, mobile package). The +/// bytes are written to the artifact blob store and referenced by `stored_path`, +/// so ingest resolves them locally (no URL fetch). +/// +/// Multipart fields: `file` (required), `kind` (required, snake_case +/// `ArtifactKind`), `plc_format` (optional, for PLC projects). +#[tracing::instrument(skip_all, fields(target_id = %id))] +pub async fn upload_artifact( + Extension(agent): AgentExt, + tenant: TenantCtx, + Path(id): Path, + mut multipart: Multipart, +) -> Result>, StatusCode> { + let oid = parse_oid(&id)?; + let db = tenant_db(&agent, &tenant).await?; + if db + .onboarded_targets() + .find_one(doc! { "_id": oid }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .is_none() + { + return Err(StatusCode::NOT_FOUND); + } + + let mut kind: Option = None; + let mut plc_format: Option = None; + let mut filename = String::from("upload.bin"); + let mut bytes: Option = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| StatusCode::BAD_REQUEST)? + { + match field.name().unwrap_or("") { + "kind" => { + let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?; + kind = parse_enum(&v); + } + "plc_format" => { + let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?; + plc_format = parse_enum(&v); + } + "file" => { + if let Some(fname) = field.file_name() { + filename = fname.to_string(); + } + bytes = Some(field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?); + } + _ => {} + } + } + + let (Some(kind), Some(bytes)) = (kind, bytes) else { + return Err(StatusCode::BAD_REQUEST); + }; + + // Store the uploaded bytes under the artifact blob store. + let safe_name: String = filename + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { + c + } else { + '_' + } + }) + .collect(); + let dir = std::path::Path::new(&agent.config.artifact_store_base_path) + .join("uploads") + .join(&id); + std::fs::create_dir_all(&dir).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let dest = dir.join(format!("{}_{safe_name}", uuid::Uuid::new_v4())); + std::fs::write(&dest, bytes.as_ref()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Build the artifact for this kind, referencing the stored file. + let mut artifact = match kind { + ArtifactKind::PlcProject => Artifact::plc_project( + filename.clone(), + plc_format.unwrap_or(PlcFormat::PlcopenXml), + ), + ArtifactKind::FirmwareImage => Artifact::firmware_image(filename.clone()), + ArtifactKind::SourceArchive => Artifact::source_archive(filename.clone()), + ArtifactKind::MobilePackage => Artifact::mobile_package(filename.clone()), + // Non-file kinds (git repo, live URL, container ref, text) use the JSON + // add-artifact endpoint, not upload. + _ => return Err(StatusCode::BAD_REQUEST), + }; + artifact.stored_path = Some(dest.to_string_lossy().to_string()); + artifact.size_bytes = Some(bytes.len() as u64); + + let artifact_bson = to_bson(&artifact).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + db.onboarded_targets() + .update_one( + doc! { "_id": oid }, + doc! { "$push": { "artifacts": artifact_bson }, "$set": { "updated_at": mongodb::bson::DateTime::now() } }, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + get_target(Extension(agent), tenant, Path(id)).await +} + +/// Deserialize a snake_case enum value from a plain string. +fn parse_enum Deserialize<'de>>(s: &str) -> Option { + serde_json::from_value(serde_json::Value::String(s.to_string())).ok() +} + /// GET /api/v1/targets/{id}/applicable-scans — the scan-applicability matrix. #[tracing::instrument(skip_all, fields(target_id = %id))] pub async fn applicable_scans_for_target( diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index bf73519..219760d 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -26,6 +26,10 @@ pub fn build_router() -> Router { "/api/v1/targets/{id}/artifacts", post(handlers::onboarding::add_artifact), ) + .route( + "/api/v1/targets/{id}/artifacts/upload", + post(handlers::onboarding::upload_artifact), + ) .route( "/api/v1/targets/{id}/applicable-scans", get(handlers::onboarding::applicable_scans_for_target), diff --git a/compliance-agent/src/ingest/mod.rs b/compliance-agent/src/ingest/mod.rs index 0a0a63e..c1b2009 100644 --- a/compliance-agent/src/ingest/mod.rs +++ b/compliance-agent/src/ingest/mod.rs @@ -162,14 +162,27 @@ fn ingest_blob( match blob::extract_zip(&stored, &dest) { Ok(()) => dest, Err(e) => { - // Not a zip (e.g. a tar.gz source archive) — keep the blob and - // note it so later stages can decide what to do. + // Not a zip container — this is a single uploaded file (e.g. a + // `.st`/`.xml` PLC project or a `.tar.gz`). The content-addressed + // blob has no extension, so materialize it into a working dir + // under its original name; extension-based scanners (PLC) can then + // discover it and report a readable path. facts.push(DetectedFact::new( "archive_unextracted", e.to_string(), "ingest", )); - stored.clone() + match materialize_single(&stored, &dest, &blob_file_name(artifact)) { + Ok(dir) => dir, + Err(copy_err) => { + facts.push(DetectedFact::new( + "materialize_failed", + copy_err.to_string(), + "ingest", + )); + stored.clone() + } + } } } } else { @@ -186,6 +199,27 @@ fn ingest_blob( }) } +/// Copy a stored blob into `dest`/`name`, returning `dest`. Used when an +/// "extractable" artifact turns out to be a single file rather than an archive. +fn materialize_single(stored: &Path, dest: &Path, name: &str) -> Result { + std::fs::create_dir_all(dest)?; + std::fs::copy(stored, dest.join(name))?; + Ok(dest.to_path_buf()) +} + +/// A safe, single-segment file name for an artifact, preserving the original +/// extension so scanners can identify it. Derives from `source_ref` (the +/// uploaded/original file name); `file_name` strips any directory components, +/// so this is traversal-safe. Falls back to the artifact id. +fn blob_file_name(artifact: &Artifact) -> String { + Path::new(&artifact.source_ref) + .file_name() + .and_then(|n| n.to_str()) + .map(str::to_string) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("artifact-{}", artifact.id)) +} + /// An artifact with no on-disk form: record a single fact, no hash/path. fn metadata_only(artifact: &Artifact, fact: DetectedFact) -> IngestedArtifact { IngestedArtifact { @@ -331,4 +365,52 @@ mod tests { assert_eq!(creds.ssh_key_path.as_deref(), Some("/default/ssh/key")); assert!(creds.auth_token.is_none()); } + + /// A single uploaded PLC file (not an archive) must land in a working dir + /// under its original name so the PLC scanner can discover it by extension + /// and report a readable path — the demo's upload → scan path. + #[test] + fn single_uploaded_plc_file_is_materialized_and_scannable() { + use compliance_core::models::PlcFormat; + + let scratch = Scratch::new(); + let store = scratch.0.join("store"); + // Simulate the upload handler: bytes written to an `uploads/` path, + // `source_ref` carrying the original (clean) file name. + let uploads = scratch.0.join("uploads"); + std::fs::create_dir_all(&uploads).expect("mkdir uploads"); + let uploaded = uploads.join("a1b2c3_pump_station.st"); + std::fs::write( + &uploaded, + "PROGRAM P\nVAR\n ApiKey : STRING := 'sk-live-1234';\nEND_VAR\nEND_PROGRAM\n", + ) + .expect("write st"); + + let mut artifact = Artifact::plc_project("pump_station.st", PlcFormat::StructuredText); + artifact.stored_path = Some(uploaded.to_string_lossy().to_string()); + + let ctx = ctx_for(&store, "t-plc"); + let out = ingest_artifact(&artifact, &ctx).expect("ingest"); + + // Working path is a directory (not the extensionless blob) holding the + // file under its original name. + let wp = out.working_path.expect("working path"); + assert!(wp.is_dir(), "expected a working dir, got {wp:?}"); + assert!(wp.join("pump_station.st").is_file()); + + // The PLC scanner finds the hardcoded credential and reports a clean path. + let findings = crate::pipeline::plc::analyze_tree(&wp, "t-plc"); + assert!( + !findings.is_empty(), + "scanner should flag the uploaded file" + ); + assert!(findings + .iter() + .any(|f| f.rule_id.as_deref() == Some("plc-hardcoded-credential"))); + assert_eq!( + findings[0].file_path.as_deref(), + Some("pump_station.st"), + "finding should reference the original file name" + ); + } } diff --git a/compliance-dashboard/src/infrastructure/onboarding.rs b/compliance-dashboard/src/infrastructure/onboarding.rs index 3f9552e..8f186fb 100644 --- a/compliance-dashboard/src/infrastructure/onboarding.rs +++ b/compliance-dashboard/src/infrastructure/onboarding.rs @@ -130,6 +130,37 @@ pub async fn create_target( .map_err(|e| ServerFnError::new(e.to_string())) } +/// Upload a file artifact (PLC project, firmware image, source archive, mobile +/// package) to a target — proxied to the agent as multipart. +#[server] +pub async fn upload_target_artifact( + id: String, + kind: String, + plc_format: Option, + filename: String, + bytes: Vec, +) -> Result { + let mut form = reqwest::multipart::Form::new().text("kind", kind).part( + "file", + reqwest::multipart::Part::bytes(bytes).file_name(filename), + ); + if let Some(pf) = plc_format { + form = form.text("plc_format", pf); + } + let resp = super::agent_client::agent_request( + reqwest::Method::POST, + &format!("/api/v1/targets/{id}/artifacts/upload"), + ) + .await? + .multipart(form) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + resp.json() + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + /// Update a target's name / type / artifacts (dashboard editor). #[server] pub async fn update_target( diff --git a/compliance-dashboard/src/pages/onboarding.rs b/compliance-dashboard/src/pages/onboarding.rs index 9074e80..d93439e 100644 --- a/compliance-dashboard/src/pages/onboarding.rs +++ b/compliance-dashboard/src/pages/onboarding.rs @@ -3,7 +3,7 @@ use dioxus::prelude::*; use crate::components::page_header::PageHeader; use crate::infrastructure::onboarding::{ create_target, detect_target, fetch_applicable_scans, trigger_target_scan, - validate_artifact_ref, validate_target_name, ArtifactInputDto, + upload_target_artifact, validate_artifact_ref, validate_target_name, ArtifactInputDto, }; /// (value, label, one-line description) for the 9 target families. @@ -41,6 +41,23 @@ const ARTIFACT_KINDS: &[(&str, &str)] = &[ const STEP_LABELS: &[&str] = &["Target type", "Artifacts", "Review", "Done"]; +/// Artifact kinds provided as an uploaded file (rather than a URL/text ref). +fn is_file_kind(kind: &str) -> bool { + matches!( + kind, + "plc_project" | "firmware_image" | "source_archive" | "mobile_package" + ) +} + +/// A file artifact staged in the wizard, uploaded after the target is created. +#[derive(Clone, PartialEq)] +struct PendingFile { + kind: String, + plc_format: Option, + filename: String, + bytes: Vec, +} + /// One row in the applicable-scans list on the success step. #[component] fn ScanRow(scan: serde_json::Value) -> Element { @@ -108,6 +125,10 @@ pub fn OnboardingPage() -> Element { let mut new_kind = use_signal(|| "git_repo".to_string()); let mut new_source = use_signal(String::new); let mut new_branch = use_signal(|| "main".to_string()); + // File-upload artifacts (PLC project, firmware image, ...). + let mut new_plc_format = use_signal(|| "plcopen_xml".to_string()); + let mut new_file = use_signal(|| Option::<(String, Vec)>::None); + let mut pending_files = use_signal(Vec::::new); // Create + result state. let mut creating = use_signal(|| false); @@ -120,7 +141,7 @@ pub fn OnboardingPage() -> Element { 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(); + let has_artifacts = !artifacts().is_empty() || !pending_files().is_empty(); // Live validation of the artifact reference being typed (empty = no error yet). let new_source_error = if new_source().is_empty() { None @@ -205,49 +226,121 @@ pub fn OnboardingPage() -> Element { } } } - div { class: "form-group", style: "margin: 0; flex: 1; min-width: 240px;", - label { "Reference (URL / path / text)" } - input { - r#type: "text", - placeholder: "https://git.example.com/acme.git", - value: "{new_source}", - oninput: move |e| new_source.set(e.value()), + if is_file_kind(&new_kind()) { + div { class: "form-group", style: "margin: 0; flex: 1; min-width: 240px;", + label { "File" } + input { + r#type: "file", + onchange: move |evt| { + let Some(file) = evt.files().into_iter().next() else { return; }; + let name = file.name(); + spawn(async move { + if let Ok(bytes) = file.read_bytes().await { + new_file.set(Some((name, bytes.to_vec()))); + } + }); + }, + } } - } - if new_kind() == "git_repo" { - div { class: "form-group", style: "margin: 0;", - label { "Branch" } + if new_kind() == "plc_project" { + div { class: "form-group", style: "margin: 0;", + label { "Format" } + select { + value: "{new_plc_format}", + oninput: move |e| new_plc_format.set(e.value()), + option { value: "plcopen_xml", "PLCopen XML" } + option { value: "structured_text", "Structured Text" } + } + } + } + button { + class: "btn btn-secondary", + disabled: new_file().is_none(), + onclick: move |_| { + if let Some((fname, data)) = new_file() { + let kind = new_kind(); + let plc_format = if kind == "plc_project" { + Some(new_plc_format()) + } else { + None + }; + pending_files.write().push(PendingFile { + kind, + plc_format, + filename: fname, + bytes: data, + }); + new_file.set(None); + } + }, + "+ Add file" + } + } else { + div { class: "form-group", style: "margin: 0; flex: 1; min-width: 240px;", + label { "Reference (URL / path / text)" } input { r#type: "text", - value: "{new_branch}", - oninput: move |e| new_branch.set(e.value()), + placeholder: "https://git.example.com/acme.git", + value: "{new_source}", + oninput: move |e| new_source.set(e.value()), } } - } - 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() - && validate_artifact_ref(&kind, &new_source()).is_none() - { - let branch = if kind == "git_repo" { Some(new_branch()) } else { None }; - artifacts.write().push(ArtifactInputDto { - kind, - source_ref: new_source(), - branch, - plc_format: None, - }); - new_source.set(String::new()); + if new_kind() == "git_repo" { + div { class: "form-group", style: "margin: 0;", + label { "Branch" } + input { + r#type: "text", + value: "{new_branch}", + oninput: move |e| new_branch.set(e.value()), + } } - }, - "+ Add" + } + 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() + && validate_artifact_ref(&kind, &new_source()).is_none() + { + let branch = if kind == "git_repo" { Some(new_branch()) } else { None }; + artifacts.write().push(ArtifactInputDto { + kind, + source_ref: new_source(), + branch, + plc_format: None, + }); + new_source.set(String::new()); + } + }, + "+ Add" + } } } - if let Some(err) = new_source_error.clone() { + if is_file_kind(&new_kind()) { + if let Some((fname, data)) = new_file() { + div { style: "font-size: 0.85em; opacity: 0.7; margin-top: 6px;", + "Selected: {fname} ({data.len()} bytes)" + } + } + } else if let Some(err) = new_source_error.clone() { div { style: "color: var(--danger, #d33); font-size: 0.85em; margin-top: 6px;", "{err}" } } + // Staged file artifacts (uploaded after the target is created). + for (i, pf) in pending_files().iter().enumerate() { + div { + style: "display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border: 1px solid var(--border, #333); border-radius: 6px; margin-top: 6px;", + span { + span { style: "opacity: 0.7;", "{kind_label(&pf.kind)} (file): " } + "{pf.filename} ({pf.bytes.len()} bytes)" + } + button { + class: "btn btn-ghost-danger btn-sm", + onclick: move |_| { pending_files.write().remove(i); }, + "Remove" + } + } + } div { style: "margin-top: 16px;", if has_artifacts { @@ -340,6 +433,8 @@ pub fn OnboardingPage() -> Element { target_type.set(String::new()); description.set(String::new()); artifacts.write().clear(); + pending_files.write().clear(); + new_file.set(None); scans.write().clear(); suggested.set(None); created_id.set(None); @@ -378,6 +473,7 @@ pub fn OnboardingPage() -> Element { let tt = target_type(); let desc = description(); let arts = artifacts(); + let files = pending_files(); let d = if desc.trim().is_empty() { None } else { Some(desc) }; creating.set(true); error.set(None); @@ -392,6 +488,17 @@ pub fn OnboardingPage() -> Element { .map(String::from); if let Some(id) = id { created_id.set(Some(id.clone())); + // Upload staged file artifacts now that the target exists. + for pf in files { + let _ = upload_target_artifact( + id.clone(), + pf.kind, + pf.plc_format, + pf.filename, + pf.bytes, + ) + .await; + } if let Ok(sc) = fetch_applicable_scans(id.clone()).await { scans.set(sc.data.scans); }