289 lines
9.1 KiB
Rust
289 lines
9.1 KiB
Rust
//! Server functions for the onboarding wizard — proxy to the agent's
|
|
//! `/api/v1/targets` endpoints.
|
|
|
|
use dioxus::prelude::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// One artifact the wizard collects for a target.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
|
pub struct ArtifactInputDto {
|
|
pub kind: String,
|
|
pub source_ref: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub branch: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub plc_format: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct TargetsResponse {
|
|
pub data: Vec<serde_json::Value>,
|
|
pub total: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct TargetResponse {
|
|
pub data: serde_json::Value,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct ApplicableScansData {
|
|
#[serde(default)]
|
|
pub scans: Vec<serde_json::Value>,
|
|
#[serde(default)]
|
|
pub pentest_supported: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
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<String> {
|
|
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<String> {
|
|
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" => {
|
|
// http(s) for web/DAST targets; modbus:// and opc.tcp:// for ICS
|
|
// 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" => {
|
|
(!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<TargetsResponse, ServerFnError> {
|
|
let resp = super::agent_client::agent_get("/api/v1/targets")
|
|
.await?
|
|
.send()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
resp.json()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
|
}
|
|
|
|
/// Create a target with the collected artifacts.
|
|
#[server]
|
|
pub async fn create_target(
|
|
name: String,
|
|
target_type: String,
|
|
description: Option<String>,
|
|
artifacts: Vec<ArtifactInputDto>,
|
|
) -> Result<TargetResponse, ServerFnError> {
|
|
let body = serde_json::json!({
|
|
"name": name,
|
|
"target_type": target_type,
|
|
"description": description,
|
|
"artifacts": artifacts,
|
|
});
|
|
let resp = super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/targets")
|
|
.await?
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
resp.json()
|
|
.await
|
|
.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<String>,
|
|
filename: String,
|
|
bytes: Vec<u8>,
|
|
) -> Result<TargetResponse, ServerFnError> {
|
|
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(
|
|
id: String,
|
|
name: Option<String>,
|
|
target_type: Option<String>,
|
|
artifacts: Option<Vec<ArtifactInputDto>>,
|
|
) -> Result<TargetResponse, ServerFnError> {
|
|
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()))
|
|
}
|
|
|
|
/// 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.
|
|
#[server]
|
|
pub async fn detect_target(id: String) -> Result<TargetResponse, ServerFnError> {
|
|
let resp = super::agent_client::agent_request(
|
|
reqwest::Method::POST,
|
|
&format!("/api/v1/targets/{id}/detect"),
|
|
)
|
|
.await?
|
|
.send()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
resp.json()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
|
}
|
|
|
|
/// Fetch the scan-applicability matrix for a target.
|
|
#[server]
|
|
pub async fn fetch_applicable_scans(id: String) -> Result<ApplicableScansResponse, ServerFnError> {
|
|
let resp = super::agent_client::agent_get(&format!("/api/v1/targets/{id}/applicable-scans"))
|
|
.await?
|
|
.send()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
resp.json()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
|
}
|
|
|
|
/// Delete a target (and cascade its findings / SBOM / scan runs / CVE alerts).
|
|
#[server]
|
|
pub async fn delete_target(id: String) -> Result<serde_json::Value, ServerFnError> {
|
|
let resp = super::agent_client::agent_request(
|
|
reqwest::Method::DELETE,
|
|
&format!("/api/v1/targets/{id}"),
|
|
)
|
|
.await?
|
|
.send()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
resp.json()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
|
}
|
|
|
|
/// Trigger a scan for a target.
|
|
#[server]
|
|
pub async fn trigger_target_scan(id: String) -> Result<serde_json::Value, ServerFnError> {
|
|
let resp = super::agent_client::agent_request(
|
|
reqwest::Method::POST,
|
|
&format!("/api/v1/targets/{id}/scan"),
|
|
)
|
|
.await?
|
|
.send()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
resp.json()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
|
}
|