diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs index 385bfc1..8b4631c 100644 --- a/compliance-agent/src/api/handlers/mod.rs +++ b/compliance-agent/src/api/handlers/mod.rs @@ -9,6 +9,7 @@ pub mod help_chat; pub mod issues; pub mod mcp_tokens; pub mod notifications; +pub mod onboarding; pub mod pentest_handlers; pub use pentest_handlers as pentest; pub mod repos; diff --git a/compliance-agent/src/api/handlers/onboarding.rs b/compliance-agent/src/api/handlers/onboarding.rs new file mode 100644 index 0000000..860d3ad --- /dev/null +++ b/compliance-agent/src/api/handlers/onboarding.rs @@ -0,0 +1,350 @@ +//! Onboarding API — CRUD for unified targets, artifact add, classification, and +//! the scan-applicability matrix. The wizard (and future integrations) drive +//! onboarding through these endpoints. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::extract::{Extension, Path, Query}; +use axum::http::StatusCode; +use axum::Json; +use mongodb::bson::{doc, oid::ObjectId, to_bson}; +use serde::{Deserialize, Serialize}; + +use compliance_core::models::{ + Artifact, ArtifactKind, ComplianceProfile, OnboardedTarget, PlcFormat, TargetScanConfig, + TargetType, +}; +use compliance_core::scan_matrix::{applicable_scans, supports_pentest}; +use compliance_core::tenant_ctx::TenantCtx; + +use crate::agent::ComplianceAgent; +use crate::classify::{classify_target, MockFirmwareDetector}; + +use super::dto::tenant_db; +use super::{collect_cursor_async, ApiResponse, PaginationParams}; + +type AgentExt = Extension>; + +/// A client-supplied artifact spec. The server builds the [`Artifact`] (and its +/// id) from it, so clients never set internal fields. +#[derive(Deserialize)] +pub struct ArtifactInput { + pub kind: ArtifactKind, + pub source_ref: String, + #[serde(default)] + pub branch: Option, + #[serde(default)] + pub plc_format: Option, +} + +impl ArtifactInput { + fn build(&self) -> Artifact { + let s = self.source_ref.clone(); + match self.kind { + ArtifactKind::GitRepo => { + Artifact::git_repo(s, self.branch.clone().unwrap_or_else(|| "main".to_string())) + } + ArtifactKind::LiveUrl => Artifact::live_url(s), + ArtifactKind::FirmwareImage => Artifact::firmware_image(s), + ArtifactKind::SourceArchive => Artifact::source_archive(s), + ArtifactKind::MobilePackage => Artifact::mobile_package(s), + ArtifactKind::ContainerImage => Artifact::container_image(s), + ArtifactKind::PlcProject => { + Artifact::plc_project(s, self.plc_format.unwrap_or(PlcFormat::PlcopenXml)) + } + ArtifactKind::PlaintextDescription => Artifact::plaintext(s), + } + } +} + +#[derive(Deserialize)] +pub struct CreateTargetRequest { + pub name: String, + pub target_type: TargetType, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub artifacts: Vec, +} + +#[derive(Deserialize)] +pub struct UpdateTargetRequest { + pub name: Option, + pub target_type: Option, + pub scan_config: Option, + pub compliance_profile: Option, + pub scan_schedule: Option, +} + +/// One applicable-scan option, serialized for the wizard. +#[derive(Serialize)] +pub struct ScanOptionDto { + pub scan: String, + pub default_on: bool, + pub rationale: String, + pub required_artifact: Option, + pub blocked_reason: Option, +} + +#[derive(Serialize)] +pub struct ApplicableScansResponse { + pub scans: Vec, + pub pentest_supported: bool, +} + +fn parse_oid(id: &str) -> Result { + ObjectId::parse_str(id).map_err(|_| StatusCode::BAD_REQUEST) +} + +/// GET /api/v1/targets — list onboarded targets (paginated). +#[tracing::instrument(skip_all)] +pub async fn list_targets( + Extension(agent): AgentExt, + tenant: TenantCtx, + Query(params): Query, +) -> Result>>, StatusCode> { + let db = tenant_db(&agent, &tenant).await?; + let skip = (params.page.saturating_sub(1)) * params.limit as u64; + let total = db + .onboarded_targets() + .count_documents(doc! {}) + .await + .unwrap_or(0); + let targets = match db + .onboarded_targets() + .find(doc! {}) + .skip(skip) + .limit(params.limit) + .await + { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(e) => { + tracing::warn!("Failed to fetch onboarded targets: {e}"); + Vec::new() + } + }; + Ok(Json(ApiResponse { + data: targets, + total: Some(total), + page: Some(params.page), + })) +} + +/// POST /api/v1/targets — create an onboarded target. +#[tracing::instrument(skip_all)] +pub async fn create_target( + Extension(agent): AgentExt, + tenant: TenantCtx, + Json(req): Json, +) -> Result>, StatusCode> { + let mut target = OnboardedTarget::new(req.name, req.target_type); + target.description = req.description; + target.artifacts = req.artifacts.iter().map(ArtifactInput::build).collect(); + + let db = tenant_db(&agent, &tenant).await?; + let res = db + .onboarded_targets() + .insert_one(&target) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + target.id = res.inserted_id.as_object_id(); + Ok(Json(ApiResponse { + data: target, + total: None, + page: None, + })) +} + +/// GET /api/v1/targets/{id} — fetch one target. +#[tracing::instrument(skip_all, fields(target_id = %id))] +pub async fn get_target( + Extension(agent): AgentExt, + tenant: TenantCtx, + Path(id): Path, +) -> Result>, StatusCode> { + let oid = parse_oid(&id)?; + let db = tenant_db(&agent, &tenant).await?; + let target = db + .onboarded_targets() + .find_one(doc! { "_id": oid }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + Ok(Json(ApiResponse { + data: target, + total: None, + page: None, + })) +} + +/// PATCH /api/v1/targets/{id} — update mutable fields. +#[tracing::instrument(skip_all, fields(target_id = %id))] +pub async fn update_target( + Extension(agent): AgentExt, + tenant: TenantCtx, + Path(id): Path, + Json(req): Json, +) -> Result>, StatusCode> { + let oid = parse_oid(&id)?; + let db = tenant_db(&agent, &tenant).await?; + + let mut set = doc! { "updated_at": mongodb::bson::DateTime::now() }; + if let Some(name) = req.name { + set.insert("name", name); + } + if let Some(tt) = req.target_type { + set.insert( + "target_type", + to_bson(&tt).map_err(|_| StatusCode::BAD_REQUEST)?, + ); + } + if let Some(sc) = req.scan_config { + set.insert( + "scan_config", + to_bson(&sc).map_err(|_| StatusCode::BAD_REQUEST)?, + ); + } + if let Some(cp) = req.compliance_profile { + set.insert( + "compliance_profile", + to_bson(&cp).map_err(|_| StatusCode::BAD_REQUEST)?, + ); + } + if let Some(ss) = req.scan_schedule { + set.insert("scan_schedule", ss); + } + + db.onboarded_targets() + .update_one(doc! { "_id": oid }, doc! { "$set": set }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + get_target(Extension(agent), tenant, Path(id)).await +} + +/// DELETE /api/v1/targets/{id} — remove the target and its findings/scans. +#[tracing::instrument(skip_all, fields(target_id = %id))] +pub async fn delete_target( + Extension(agent): AgentExt, + tenant: TenantCtx, + Path(id): Path, +) -> Result, StatusCode> { + let oid = parse_oid(&id)?; + let db = tenant_db(&agent, &tenant).await?; + db.onboarded_targets() + .delete_one(doc! { "_id": oid }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + // Cascade the collections keyed by repo_id == target id (best-effort). + let by_repo = doc! { "repo_id": &id }; + let _ = db.findings().delete_many(by_repo.clone()).await; + let _ = db.scan_runs().delete_many(by_repo.clone()).await; + let _ = db.sbom_entries().delete_many(by_repo.clone()).await; + let _ = db.cve_alerts().delete_many(by_repo).await; + Ok(Json(serde_json::json!({ "status": "deleted" }))) +} + +/// POST /api/v1/targets/{id}/artifacts — attach an artifact (by reference). +#[tracing::instrument(skip_all, fields(target_id = %id))] +pub async fn add_artifact( + Extension(agent): AgentExt, + tenant: TenantCtx, + Path(id): Path, + Json(input): Json, +) -> Result>, StatusCode> { + let oid = parse_oid(&id)?; + let db = tenant_db(&agent, &tenant).await?; + let artifact = to_bson(&input.build()).map_err(|_| StatusCode::BAD_REQUEST)?; + db.onboarded_targets() + .update_one( + doc! { "_id": oid }, + doc! { "$push": { "artifacts": artifact }, "$set": { "updated_at": mongodb::bson::DateTime::now() } }, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + get_target(Extension(agent), tenant, Path(id)).await +} + +/// 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( + Extension(agent): AgentExt, + tenant: TenantCtx, + Path(id): Path, +) -> Result>, StatusCode> { + let oid = parse_oid(&id)?; + let db = tenant_db(&agent, &tenant).await?; + let target = db + .onboarded_targets() + .find_one(doc! { "_id": oid }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let scans = applicable_scans(&target) + .into_iter() + .map(|o| ScanOptionDto { + scan: o.scan.to_string(), + default_on: o.default_on, + rationale: o.rationale, + required_artifact: o.required_artifact.map(|k| k.to_string()), + blocked_reason: o.blocked_reason, + }) + .collect(); + + Ok(Json(ApiResponse { + data: ApplicableScansResponse { + scans, + pentest_supported: supports_pentest(target.target_type), + }, + total: None, + page: None, + })) +} + +/// POST /api/v1/targets/{id}/detect — classify the target from its artifacts. +/// +/// This is the lightweight pass: it classifies from artifact kinds without +/// ingesting (cloning) sources, so it returns immediately. Deep detection (after +/// ingest, with tramiton firmware analysis) is a follow-up background step. +#[tracing::instrument(skip_all, fields(target_id = %id))] +pub async fn detect_target( + Extension(agent): AgentExt, + tenant: TenantCtx, + Path(id): Path, +) -> Result>, StatusCode> { + let oid = parse_oid(&id)?; + let db = tenant_db(&agent, &tenant).await?; + let mut target = db + .onboarded_targets() + .find_one(doc! { "_id": oid }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + // No ingested working paths here → kind-based classification only; the mock + // firmware detector is never invoked (no firmware working path present). + let empty = HashMap::new(); + let detector = MockFirmwareDetector { detection: None }; + let classification = classify_target(&target, &empty, &detector) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let classification_bson = + to_bson(&classification).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + db.onboarded_targets() + .update_one( + doc! { "_id": oid }, + doc! { "$set": { "classification": classification_bson, "updated_at": mongodb::bson::DateTime::now() } }, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + target.classification = Some(classification); + + Ok(Json(ApiResponse { + data: target, + total: None, + page: None, + })) +} diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index b4773e8..a067366 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -25,6 +25,29 @@ pub fn build_router() -> Router { "/api/v1/repositories/{id}/webhook-config", get(handlers::get_webhook_config), ) + // Unified onboarding targets (#131). + .route( + "/api/v1/targets", + get(handlers::onboarding::list_targets).post(handlers::onboarding::create_target), + ) + .route( + "/api/v1/targets/{id}", + get(handlers::onboarding::get_target) + .patch(handlers::onboarding::update_target) + .delete(handlers::onboarding::delete_target), + ) + .route( + "/api/v1/targets/{id}/artifacts", + post(handlers::onboarding::add_artifact), + ) + .route( + "/api/v1/targets/{id}/applicable-scans", + get(handlers::onboarding::applicable_scans_for_target), + ) + .route( + "/api/v1/targets/{id}/detect", + post(handlers::onboarding::detect_target), + ) .route("/api/v1/findings", get(handlers::list_findings)) .route("/api/v1/findings/{id}", get(handlers::get_finding)) .route( diff --git a/compliance-agent/tests/common/mod.rs b/compliance-agent/tests/common/mod.rs index cbb82fe..9d0934e 100644 --- a/compliance-agent/tests/common/mod.rs +++ b/compliance-agent/tests/common/mod.rs @@ -25,8 +25,9 @@ impl TestServer { let mongodb_uri = std::env::var("TEST_MONGODB_URI") .unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into()); - // Unique database name per test run to avoid collisions - let db_name = format!("test_{}", uuid::Uuid::new_v4().simple()); + // Unique db-name prefix per run. Must fit the pool's 30-char cap + // (`_<32 hex>` <= 63), so use a 16-hex-char suffix. + let db_name = format!("t_{}", &uuid::Uuid::new_v4().simple().to_string()[..16]); let db_pool = DatabasePool::connect(&mongodb_uri, &db_name) .await diff --git a/compliance-agent/tests/integration/api/mod.rs b/compliance-agent/tests/integration/api/mod.rs index 82b0fe1..960394b 100644 --- a/compliance-agent/tests/integration/api/mod.rs +++ b/compliance-agent/tests/integration/api/mod.rs @@ -2,5 +2,6 @@ mod cascade_delete; mod dast; mod findings; mod health; +mod onboarding; mod repositories; mod stats; diff --git a/compliance-agent/tests/integration/api/onboarding.rs b/compliance-agent/tests/integration/api/onboarding.rs new file mode 100644 index 0000000..2b51333 --- /dev/null +++ b/compliance-agent/tests/integration/api/onboarding.rs @@ -0,0 +1,115 @@ +use crate::common::TestServer; +use serde_json::json; + +#[tokio::test] +async fn create_list_and_applicable_scans() { + let server = TestServer::start().await; + + // Initially empty. + let resp = server.get("/api/v1/targets").await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["data"].as_array().unwrap().len(), 0); + + // Create a web-app target with a git repo + a live URL. + let resp = server + .post( + "/api/v1/targets", + &json!({ + "name": "acme-web", + "target_type": "web_app", + "artifacts": [ + { "kind": "git_repo", "source_ref": "https://git/acme.git", "branch": "main" }, + { "kind": "live_url", "source_ref": "https://acme.example.com" } + ] + }), + ) + .await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + let id = body["data"]["_id"]["$oid"].as_str().unwrap().to_string(); + assert!(!id.is_empty()); + assert_eq!(body["data"]["artifacts"].as_array().unwrap().len(), 2); + + // List returns it. + let resp = server.get("/api/v1/targets").await; + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["data"].as_array().unwrap().len(), 1); + + // Applicable scans: SAST present + DAST offered (live URL present), pentest supported. + let resp = server + .get(&format!("/api/v1/targets/{id}/applicable-scans")) + .await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + let scans = body["data"]["scans"].as_array().unwrap(); + let names: Vec<&str> = scans.iter().filter_map(|s| s["scan"].as_str()).collect(); + assert!(names.contains(&"sast")); + assert!(names.contains(&"dast")); + assert_eq!(body["data"]["pentest_supported"], true); + + server.cleanup().await; +} + +#[tokio::test] +async fn detect_classifies_a_plc_target() { + let server = TestServer::start().await; + + // A PLC project artifact is a strong kind-based signal. + let resp = server + .post( + "/api/v1/targets", + &json!({ + "name": "line-controller", + "target_type": "backend_service", // deliberately wrong; detect should suggest PLC + "artifacts": [ + { "kind": "plc_project", "source_ref": "line.xml", "plc_format": "plcopen_xml" } + ] + }), + ) + .await; + let body: serde_json::Value = resp.json().await.unwrap(); + let id = body["data"]["_id"]["$oid"].as_str().unwrap().to_string(); + + let resp = server + .post(&format!("/api/v1/targets/{id}/detect"), &json!({})) + .await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["data"]["classification"]["suggested"], "plc_sps"); + + server.cleanup().await; +} + +#[tokio::test] +async fn add_artifact_and_delete_target() { + let server = TestServer::start().await; + + let resp = server + .post( + "/api/v1/targets", + &json!({ "name": "svc", "target_type": "backend_service" }), + ) + .await; + let body: serde_json::Value = resp.json().await.unwrap(); + let id = body["data"]["_id"]["$oid"].as_str().unwrap().to_string(); + + // Attach a git repo. + let resp = server + .post( + &format!("/api/v1/targets/{id}/artifacts"), + &json!({ "kind": "git_repo", "source_ref": "https://git/svc.git" }), + ) + .await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["data"]["artifacts"].as_array().unwrap().len(), 1); + + // Delete it. + let resp = server.delete(&format!("/api/v1/targets/{id}")).await; + assert_eq!(resp.status(), 200); + let resp = server.get(&format!("/api/v1/targets/{id}")).await; + assert_eq!(resp.status(), 404); + + server.cleanup().await; +}