feat(api): onboarding endpoints for unified targets (#131)
CI / Check (pull_request) Successful in 6m14s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
CI / Check (pull_request) Successful in 6m14s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
REST surface the wizard drives onboarding through:
- GET/POST /api/v1/targets (list paginated / create)
- GET/PATCH/DELETE /api/v1/targets/{id} (fetch / update / delete + cascade
findings/scans by repo_id)
- POST /api/v1/targets/{id}/artifacts (attach an artifact by reference)
- GET /api/v1/targets/{id}/applicable-scans (scan matrix + pentest support)
- POST /api/v1/targets/{id}/detect (kind-based classification — no ingest;
deep post-ingest detection is a follow-up)
Follows the existing axum handler/route/DTO conventions (ApiResponse,
PaginationParams, tenant_db, collect_cursor_async, TenantCtx). Multipart artifact
upload and the scan-trigger endpoint (needs the unified pipeline, #133) are
follow-ups.
Also fixes the TestServer db-name prefix (was 37 chars, over the pool's 30-char
cap) which had silently broken every API integration test locally.
3 HTTP integration tests (create/list/applicable-scans, detect->PLC,
add-artifact/delete) — pass against local Mongo.
Closes #131.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a074efd0b4
commit
c8255bf024
@@ -9,6 +9,7 @@ pub mod help_chat;
|
|||||||
pub mod issues;
|
pub mod issues;
|
||||||
pub mod mcp_tokens;
|
pub mod mcp_tokens;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
pub mod onboarding;
|
||||||
pub mod pentest_handlers;
|
pub mod pentest_handlers;
|
||||||
pub use pentest_handlers as pentest;
|
pub use pentest_handlers as pentest;
|
||||||
pub mod repos;
|
pub mod repos;
|
||||||
|
|||||||
@@ -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<Arc<ComplianceAgent>>;
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub plc_format: Option<PlcFormat>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artifacts: Vec<ArtifactInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateTargetRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub target_type: Option<TargetType>,
|
||||||
|
pub scan_config: Option<TargetScanConfig>,
|
||||||
|
pub compliance_profile: Option<ComplianceProfile>,
|
||||||
|
pub scan_schedule: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
pub blocked_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ApplicableScansResponse {
|
||||||
|
pub scans: Vec<ScanOptionDto>,
|
||||||
|
pub pentest_supported: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_oid(id: &str) -> Result<ObjectId, StatusCode> {
|
||||||
|
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<PaginationParams>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<OnboardedTarget>>>, 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<CreateTargetRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<OnboardedTarget>>, 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<String>,
|
||||||
|
) -> Result<Json<ApiResponse<OnboardedTarget>>, 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<String>,
|
||||||
|
Json(req): Json<UpdateTargetRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<OnboardedTarget>>, 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<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<String>,
|
||||||
|
Json(input): Json<ArtifactInput>,
|
||||||
|
) -> Result<Json<ApiResponse<OnboardedTarget>>, 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<String>,
|
||||||
|
) -> Result<Json<ApiResponse<ApplicableScansResponse>>, 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<String>,
|
||||||
|
) -> Result<Json<ApiResponse<OnboardedTarget>>, 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -25,6 +25,29 @@ pub fn build_router() -> Router {
|
|||||||
"/api/v1/repositories/{id}/webhook-config",
|
"/api/v1/repositories/{id}/webhook-config",
|
||||||
get(handlers::get_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", get(handlers::list_findings))
|
||||||
.route("/api/v1/findings/{id}", get(handlers::get_finding))
|
.route("/api/v1/findings/{id}", get(handlers::get_finding))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ impl TestServer {
|
|||||||
let mongodb_uri = std::env::var("TEST_MONGODB_URI")
|
let mongodb_uri = std::env::var("TEST_MONGODB_URI")
|
||||||
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
|
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
|
||||||
|
|
||||||
// Unique database name per test run to avoid collisions
|
// Unique db-name prefix per run. Must fit the pool's 30-char cap
|
||||||
let db_name = format!("test_{}", uuid::Uuid::new_v4().simple());
|
// (`<prefix>_<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)
|
let db_pool = DatabasePool::connect(&mongodb_uri, &db_name)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ mod cascade_delete;
|
|||||||
mod dast;
|
mod dast;
|
||||||
mod findings;
|
mod findings;
|
||||||
mod health;
|
mod health;
|
||||||
|
mod onboarding;
|
||||||
mod repositories;
|
mod repositories;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user