Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbe07261b1 | ||
|
|
27d20ed11c |
@@ -191,18 +191,13 @@ jobs:
|
|||||||
image: docker:27-cli
|
image: docker:27-cli
|
||||||
steps:
|
steps:
|
||||||
- name: Build, push and trigger orca redeploy
|
- name: Build, push and trigger orca redeploy
|
||||||
env:
|
|
||||||
# PAT for fetching the private tramiton-core git dependency during the
|
|
||||||
# image build (injected as a BuildKit secret, never baked into a layer).
|
|
||||||
TRAMITON_FETCH_TOKEN: ${{ secrets.TRAMITON_FETCH_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
apk add --no-cache git curl openssl
|
apk add --no-cache git curl openssl
|
||||||
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
||||||
IMAGE=registry.meghsakha.com/compliance-agent
|
IMAGE=registry.meghsakha.com/compliance-agent
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.meghsakha.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.meghsakha.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||||
DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN \
|
docker build -f Dockerfile.agent -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||||
-f Dockerfile.agent -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
|
||||||
docker push "$IMAGE:latest" && docker push "$IMAGE:${GITHUB_SHA}"
|
docker push "$IMAGE:latest" && docker push "$IMAGE:${GITHUB_SHA}"
|
||||||
PAYLOAD=$(printf '{"ref":"refs/heads/main","repository":{"full_name":"sharang/compliance-scanner-agent"},"head_commit":{"id":"%s","message":"deploy agent"}}' "${GITHUB_SHA}")
|
PAYLOAD=$(printf '{"ref":"refs/heads/main","repository":{"full_name":"sharang/compliance-scanner-agent"},"head_commit":{"id":"%s","message":"deploy agent"}}' "${GITHUB_SHA}")
|
||||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
||||||
|
|||||||
+1
-10
@@ -2,16 +2,7 @@ FROM rust:1.94-bookworm AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
# compliance-agent depends on the private tramiton-core git repo. Authenticate
|
RUN cargo build --release -p compliance-agent
|
||||||
# the fetch with a PAT passed as a BuildKit secret (never baked into a layer).
|
|
||||||
# Build with: DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN ...
|
|
||||||
RUN --mount=type=secret,id=tramiton_token \
|
|
||||||
if [ -s /run/secrets/tramiton_token ]; then \
|
|
||||||
git config --global \
|
|
||||||
url."https://sharang:$(cat /run/secrets/tramiton_token)@gitea.meghsakha.com/".insteadOf \
|
|
||||||
"ssh://git@gitea.meghsakha.com:22222/"; \
|
|
||||||
fi && \
|
|
||||||
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p compliance-agent
|
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y ca-certificates libssl3 git curl python3 python3-pip npm golang-go php-cli && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y ca-certificates libssl3 git curl python3 python3-pip npm golang-go php-cli && rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -498,13 +498,6 @@ impl PipelineOrchestrator {
|
|||||||
"Unified pipeline: scan plan built"
|
"Unified pipeline: scan plan built"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ingest + classify (tramiton for firmware) and store the detected type.
|
|
||||||
self.classify_and_store(target, &target_id, scan_run_id)
|
|
||||||
.await;
|
|
||||||
// Provision a DAST target from a LiveUrl artifact so DAST fires for
|
|
||||||
// wizard-created targets, not just migrated ones.
|
|
||||||
self.ensure_dast_target(target, &plan).await;
|
|
||||||
|
|
||||||
match target.code_artifact() {
|
match target.code_artifact() {
|
||||||
Some(code) if code.kind == ArtifactKind::GitRepo => {
|
Some(code) if code.kind == ArtifactKind::GitRepo => {
|
||||||
let repo = repo_view_from_target(target, code);
|
let repo = repo_view_from_target(target, code);
|
||||||
@@ -534,110 +527,6 @@ impl PipelineOrchestrator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ingest the target's artifacts, classify (tramiton for firmware/RTOS/Yocto,
|
|
||||||
/// heuristics otherwise), and store the detected classification on the target.
|
|
||||||
/// Best-effort — never fails the scan.
|
|
||||||
async fn classify_and_store(
|
|
||||||
&self,
|
|
||||||
target: &OnboardedTarget,
|
|
||||||
target_id: &str,
|
|
||||||
scan_run_id: &str,
|
|
||||||
) {
|
|
||||||
self.update_phase(scan_run_id, "classification").await;
|
|
||||||
let ctx = crate::ingest::IngestContext::from_config(&self.config, target_id);
|
|
||||||
let ingest_set = match crate::ingest::ingest_all(target, &ctx) {
|
|
||||||
Ok(set) => set,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(target_id, error = %e, "Unified pipeline: ingest for classification failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let working_paths = ingest_set.working_paths();
|
|
||||||
match crate::classify::classify_target(
|
|
||||||
target,
|
|
||||||
&working_paths,
|
|
||||||
&crate::classify::TramitonNative,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(classification) => {
|
|
||||||
tracing::info!(
|
|
||||||
target_id,
|
|
||||||
suggested = %classification.suggested,
|
|
||||||
"Unified pipeline: classified target"
|
|
||||||
);
|
|
||||||
if let (Some(oid), Ok(bson)) = (target.id, mongodb::bson::to_bson(&classification))
|
|
||||||
{
|
|
||||||
let _ = self
|
|
||||||
.db
|
|
||||||
.onboarded_targets()
|
|
||||||
.update_one(
|
|
||||||
doc! { "_id": oid },
|
|
||||||
doc! { "$set": { "classification": bson } },
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(target_id, error = %e, "Unified pipeline: classification failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// If the target has a `LiveUrl` artifact and DAST is planned, provision a
|
|
||||||
/// `DastTarget` (keyed by `repo_id` = target id) so the existing DAST trigger
|
|
||||||
/// fires for wizard-created targets. Idempotent.
|
|
||||||
async fn ensure_dast_target(
|
|
||||||
&self,
|
|
||||||
target: &OnboardedTarget,
|
|
||||||
plan: &crate::pipeline::plan::ScanPlan,
|
|
||||||
) {
|
|
||||||
if !plan.has(ScanType::Dast) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let (Some(url), Some(oid)) = (target.live_url(), target.id) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let target_id = oid.to_hex();
|
|
||||||
if self
|
|
||||||
.db
|
|
||||||
.dast_targets()
|
|
||||||
.find_one(doc! { "repo_id": &target_id })
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
return; // already provisioned
|
|
||||||
}
|
|
||||||
let kind = url
|
|
||||||
.web
|
|
||||||
.as_ref()
|
|
||||||
.map(|w| w.target_kind.clone())
|
|
||||||
.unwrap_or(DastTargetType::WebApp);
|
|
||||||
let mut dast = DastTarget::new(target.name.clone(), url.source_ref.clone(), kind);
|
|
||||||
dast.repo_id = Some(target_id);
|
|
||||||
if let Some(web) = &url.web {
|
|
||||||
dast.excluded_paths = web.excluded_paths.clone();
|
|
||||||
dast.max_crawl_depth = web.max_crawl_depth;
|
|
||||||
dast.rate_limit = web.rate_limit;
|
|
||||||
dast.allow_destructive = web.allow_destructive;
|
|
||||||
}
|
|
||||||
if let Some(auth) = &url.auth {
|
|
||||||
dast.auth_config = Some(DastAuthConfig {
|
|
||||||
method: auth.method.clone(),
|
|
||||||
login_url: auth.login_url.clone(),
|
|
||||||
username: auth.username.clone(),
|
|
||||||
password: None,
|
|
||||||
token: auth.secret.clone(),
|
|
||||||
headers: auth.headers.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Err(e) = self.db.dast_targets().insert_one(&dast).await {
|
|
||||||
tracing::warn!(error = %e, "Unified pipeline: failed to provision DAST target");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sync the onboarded-target document after a scan: bump `findings_count`
|
/// Sync the onboarded-target document after a scan: bump `findings_count`
|
||||||
/// and advance the git artifact's `last_scanned_commit` watermark.
|
/// and advance the git artifact's `last_scanned_commit` watermark.
|
||||||
async fn finalize_target(
|
async fn finalize_target(
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ pub enum Route {
|
|||||||
OverviewPage {},
|
OverviewPage {},
|
||||||
#[route("/repositories")]
|
#[route("/repositories")]
|
||||||
RepositoriesPage {},
|
RepositoriesPage {},
|
||||||
#[route("/onboard")]
|
|
||||||
OnboardingPage {},
|
|
||||||
#[route("/findings")]
|
#[route("/findings")]
|
||||||
FindingsPage {},
|
FindingsPage {},
|
||||||
#[route("/findings/:id")]
|
#[route("/findings/:id")]
|
||||||
|
|||||||
@@ -28,11 +28,6 @@ pub fn Sidebar() -> Element {
|
|||||||
route: Route::RepositoriesPage {},
|
route: Route::RepositoriesPage {},
|
||||||
icon: rsx! { Icon { icon: BsFolder2Open, width: 18, height: 18 } },
|
icon: rsx! { Icon { icon: BsFolder2Open, width: 18, height: 18 } },
|
||||||
},
|
},
|
||||||
NavItem {
|
|
||||||
label: "Onboard",
|
|
||||||
route: Route::OnboardingPage {},
|
|
||||||
icon: rsx! { Icon { icon: BsPlusCircle, width: 18, height: 18 } },
|
|
||||||
},
|
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "Findings",
|
label: "Findings",
|
||||||
route: Route::FindingsPage {},
|
route: Route::FindingsPage {},
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ pub mod issues;
|
|||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod mcp_tokens;
|
pub mod mcp_tokens;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod onboarding;
|
|
||||||
pub mod pentest;
|
pub mod pentest;
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
//! 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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()))
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ pub mod impact_analysis;
|
|||||||
pub mod issues;
|
pub mod issues;
|
||||||
pub mod mcp_servers;
|
pub mod mcp_servers;
|
||||||
pub mod mcp_tokens;
|
pub mod mcp_tokens;
|
||||||
pub mod onboarding;
|
|
||||||
pub mod overview;
|
pub mod overview;
|
||||||
pub mod pentest_dashboard;
|
pub mod pentest_dashboard;
|
||||||
pub mod pentest_session;
|
pub mod pentest_session;
|
||||||
@@ -33,7 +32,6 @@ pub use impact_analysis::ImpactAnalysisPage;
|
|||||||
pub use issues::IssuesPage;
|
pub use issues::IssuesPage;
|
||||||
pub use mcp_servers::McpServersPage;
|
pub use mcp_servers::McpServersPage;
|
||||||
pub use mcp_tokens::McpTokensPage;
|
pub use mcp_tokens::McpTokensPage;
|
||||||
pub use onboarding::OnboardingPage;
|
|
||||||
pub use overview::OverviewPage;
|
pub use overview::OverviewPage;
|
||||||
pub use pentest_dashboard::PentestDashboardPage;
|
pub use pentest_dashboard::PentestDashboardPage;
|
||||||
pub use pentest_session::PentestSessionPage;
|
pub use pentest_session::PentestSessionPage;
|
||||||
|
|||||||
@@ -1,377 +0,0 @@
|
|||||||
use dioxus::prelude::*;
|
|
||||||
|
|
||||||
use crate::components::page_header::PageHeader;
|
|
||||||
use crate::infrastructure::onboarding::{
|
|
||||||
create_target, detect_target, fetch_applicable_scans, ArtifactInputDto,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// (value, label, one-line description) for the 9 target families.
|
|
||||||
const TARGET_TYPES: &[(&str, &str, &str)] = &[
|
|
||||||
("web_app", "Web Application", "Front end + server"),
|
|
||||||
("backend_service", "Backend / API", "REST, GraphQL, gRPC"),
|
|
||||||
("desktop_app", "Desktop App", "Windows / macOS / Linux"),
|
|
||||||
("android_app", "Android App", "APK / AAB"),
|
|
||||||
("ios_app", "iOS App", "IPA"),
|
|
||||||
(
|
|
||||||
"firmware_bare_metal",
|
|
||||||
"Firmware — bare metal",
|
|
||||||
"No operating system",
|
|
||||||
),
|
|
||||||
("firmware_rtos", "Firmware — RTOS", "Zephyr, FreeRTOS, ..."),
|
|
||||||
(
|
|
||||||
"embedded_linux_yocto",
|
|
||||||
"Embedded Linux / Yocto",
|
|
||||||
"BSP + image",
|
|
||||||
),
|
|
||||||
("plc_sps", "PLC / SPS", "IEC 61131-3"),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// (value, label) for the artifact kinds a user can attach.
|
|
||||||
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)"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const STEP_LABELS: &[&str] = &["Target type", "Artifacts", "Review", "Done"];
|
|
||||||
|
|
||||||
/// One row in the applicable-scans list on the success step.
|
|
||||||
#[component]
|
|
||||||
fn ScanRow(scan: serde_json::Value) -> Element {
|
|
||||||
let name = scan
|
|
||||||
.get("scan")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("?")
|
|
||||||
.to_string();
|
|
||||||
let rationale = scan
|
|
||||||
.get("rationale")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let blocked = scan
|
|
||||||
.get("blocked_reason")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(String::from);
|
|
||||||
let default_on = scan
|
|
||||||
.get("default_on")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
let badge_class = if blocked.is_some() {
|
|
||||||
"badge badge-info"
|
|
||||||
} else if default_on {
|
|
||||||
"badge badge-success"
|
|
||||||
} else {
|
|
||||||
"badge"
|
|
||||||
};
|
|
||||||
rsx! {
|
|
||||||
div { style: "display: flex; gap: 8px; align-items: center; padding: 6px 0;",
|
|
||||||
span { class: "{badge_class}", "{name}" }
|
|
||||||
span { style: "opacity: 0.8;", "{rationale}" }
|
|
||||||
if let Some(b) = blocked {
|
|
||||||
span { style: "opacity: 0.6; font-style: italic;", "— {b}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kind_label(kind: &str) -> &str {
|
|
||||||
ARTIFACT_KINDS
|
|
||||||
.iter()
|
|
||||||
.find(|(v, _)| *v == kind)
|
|
||||||
.map(|(_, l)| *l)
|
|
||||||
.unwrap_or(kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn type_label(value: &str) -> &str {
|
|
||||||
TARGET_TYPES
|
|
||||||
.iter()
|
|
||||||
.find(|(v, _, _)| *v == value)
|
|
||||||
.map(|(_, l, _)| *l)
|
|
||||||
.unwrap_or(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn OnboardingPage() -> Element {
|
|
||||||
let mut step = use_signal(|| 0usize);
|
|
||||||
let mut name = use_signal(String::new);
|
|
||||||
let mut target_type = use_signal(String::new);
|
|
||||||
let mut description = use_signal(String::new);
|
|
||||||
let mut artifacts = use_signal(Vec::<ArtifactInputDto>::new);
|
|
||||||
|
|
||||||
// "Add artifact" mini-form.
|
|
||||||
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());
|
|
||||||
|
|
||||||
// Create + result state.
|
|
||||||
let mut creating = use_signal(|| false);
|
|
||||||
let mut error = use_signal(|| Option::<String>::None);
|
|
||||||
let mut scans = use_signal(Vec::<serde_json::Value>::new);
|
|
||||||
let mut suggested = use_signal(|| Option::<String>::None);
|
|
||||||
|
|
||||||
let step_now = step();
|
|
||||||
let can_advance_type = !name().trim().is_empty() && !target_type().trim().is_empty();
|
|
||||||
let has_artifacts = !artifacts().is_empty();
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
PageHeader {
|
|
||||||
title: "Onboard a target",
|
|
||||||
description: "Add a target, attach its artifacts, and see which scans apply.",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stepper.
|
|
||||||
div { class: "wizard-steps",
|
|
||||||
for (i, label) in STEP_LABELS.iter().enumerate() {
|
|
||||||
div {
|
|
||||||
class: if i == step_now { "wizard-step wizard-step-active" } else { "wizard-step" },
|
|
||||||
span { class: "wizard-step-dot", "{i + 1}" }
|
|
||||||
span { class: "wizard-step-label", "{label}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(err) = error() {
|
|
||||||
div { class: "card", style: "border-color: var(--danger, #d33); margin-bottom: 12px;",
|
|
||||||
div { class: "card-header", "Error" }
|
|
||||||
div { style: "padding: 12px;", "{err}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div { class: "card",
|
|
||||||
// ---- Step 0: target type + name ----
|
|
||||||
if step_now == 0 {
|
|
||||||
div { class: "card-header", "What kind of software is this?" }
|
|
||||||
div { style: "padding: 16px;",
|
|
||||||
div { class: "form-group",
|
|
||||||
label { "Name" }
|
|
||||||
input {
|
|
||||||
r#type: "text",
|
|
||||||
placeholder: "acme-web",
|
|
||||||
value: "{name}",
|
|
||||||
oninput: move |e| name.set(e.value()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
style: "display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-top: 12px;",
|
|
||||||
for (value, tlabel, tdesc) in TARGET_TYPES.iter().copied() {
|
|
||||||
div {
|
|
||||||
class: "card",
|
|
||||||
style: if target_type() == value {
|
|
||||||
"padding: 12px; cursor: pointer; border: 2px solid var(--accent, #3b82f6);"
|
|
||||||
} else {
|
|
||||||
"padding: 12px; cursor: pointer;"
|
|
||||||
},
|
|
||||||
onclick: move |_| target_type.set(value.to_string()),
|
|
||||||
div { style: "font-weight: 600;", "{tlabel}" }
|
|
||||||
div { style: "font-size: 0.85em; opacity: 0.7;", "{tdesc}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Step 1: artifacts ----
|
|
||||||
if step_now == 1 {
|
|
||||||
div { class: "card-header", "Attach artifacts" }
|
|
||||||
div { style: "padding: 16px;",
|
|
||||||
div { style: "display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end;",
|
|
||||||
div { class: "form-group", style: "margin: 0;",
|
|
||||||
label { "Kind" }
|
|
||||||
select {
|
|
||||||
value: "{new_kind}",
|
|
||||||
oninput: move |e| new_kind.set(e.value()),
|
|
||||||
for (value, klabel) in ARTIFACT_KINDS.iter().copied() {
|
|
||||||
option { value: "{value}", "{klabel}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: "btn btn-secondary",
|
|
||||||
onclick: move |_| {
|
|
||||||
let kind = new_kind();
|
|
||||||
if !new_source().trim().is_empty() {
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div { style: "margin-top: 16px;",
|
|
||||||
if has_artifacts {
|
|
||||||
for (i, a) in artifacts().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-bottom: 6px;",
|
|
||||||
span {
|
|
||||||
span { style: "opacity: 0.7;", "{kind_label(&a.kind)}: " }
|
|
||||||
"{a.source_ref}"
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: "btn btn-ghost-danger btn-sm",
|
|
||||||
onclick: move |_| { artifacts.write().remove(i); },
|
|
||||||
"Remove"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
div { style: "opacity: 0.6;", "No artifacts yet. Add at least one." }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Step 2: review + create ----
|
|
||||||
if step_now == 2 {
|
|
||||||
div { class: "card-header", "Review" }
|
|
||||||
div { style: "padding: 16px;",
|
|
||||||
div { class: "wizard-summary",
|
|
||||||
div { strong { "Name: " } "{name()}" }
|
|
||||||
div { strong { "Type: " } "{type_label(&target_type())}" }
|
|
||||||
div { strong { "Artifacts:" } }
|
|
||||||
ul {
|
|
||||||
for a in artifacts() {
|
|
||||||
li { "{kind_label(&a.kind)}: {a.source_ref}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div { style: "margin-top: 12px; opacity: 0.7; font-size: 0.9em;",
|
|
||||||
"The applicable scans (SAST / DAST / firmware / PLC) are shown after the target is created."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Step 3: created ----
|
|
||||||
if step_now == 3 {
|
|
||||||
div { class: "card-header", "Target onboarded" }
|
|
||||||
div { style: "padding: 16px;",
|
|
||||||
p {
|
|
||||||
strong { "{name()}" }
|
|
||||||
" was created."
|
|
||||||
if let Some(s) = suggested() {
|
|
||||||
span { " Suggested type from detection: " strong { "{type_label(&s)}" } "." }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h4 { style: "margin-top: 16px;", "Applicable scans" }
|
|
||||||
if scans().is_empty() {
|
|
||||||
div { style: "opacity: 0.6;", "No scans available (no code / URL / firmware artifact present)." }
|
|
||||||
} else {
|
|
||||||
for s in scans() {
|
|
||||||
ScanRow { scan: s }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div { style: "margin-top: 16px;",
|
|
||||||
button {
|
|
||||||
class: "btn btn-secondary",
|
|
||||||
onclick: move |_| {
|
|
||||||
step.set(0);
|
|
||||||
name.set(String::new());
|
|
||||||
target_type.set(String::new());
|
|
||||||
description.set(String::new());
|
|
||||||
artifacts.write().clear();
|
|
||||||
scans.write().clear();
|
|
||||||
suggested.set(None);
|
|
||||||
error.set(None);
|
|
||||||
},
|
|
||||||
"Onboard another"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Footer navigation ----
|
|
||||||
if step_now < 3 {
|
|
||||||
div { style: "display: flex; justify-content: space-between; margin-top: 16px;",
|
|
||||||
button {
|
|
||||||
class: "btn btn-back",
|
|
||||||
disabled: step_now == 0,
|
|
||||||
onclick: move |_| { if step() > 0 { step.set(step() - 1); } },
|
|
||||||
"Back"
|
|
||||||
}
|
|
||||||
if step_now < 2 {
|
|
||||||
button {
|
|
||||||
class: "btn btn-primary",
|
|
||||||
disabled: (step_now == 0 && !can_advance_type) || (step_now == 1 && !has_artifacts),
|
|
||||||
onclick: move |_| step.set(step() + 1),
|
|
||||||
"Next"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
button {
|
|
||||||
class: "btn btn-primary",
|
|
||||||
disabled: creating(),
|
|
||||||
onclick: move |_| {
|
|
||||||
let n = name();
|
|
||||||
let tt = target_type();
|
|
||||||
let desc = description();
|
|
||||||
let arts = artifacts();
|
|
||||||
let d = if desc.trim().is_empty() { None } else { Some(desc) };
|
|
||||||
creating.set(true);
|
|
||||||
error.set(None);
|
|
||||||
spawn(async move {
|
|
||||||
match create_target(n, tt, d, arts).await {
|
|
||||||
Ok(resp) => {
|
|
||||||
let id = resp
|
|
||||||
.data
|
|
||||||
.get("_id")
|
|
||||||
.and_then(|o| o.get("$oid"))
|
|
||||||
.and_then(|s| s.as_str())
|
|
||||||
.map(String::from);
|
|
||||||
if let Some(id) = id {
|
|
||||||
if let Ok(sc) = fetch_applicable_scans(id.clone()).await {
|
|
||||||
scans.set(sc.data.scans);
|
|
||||||
}
|
|
||||||
if let Ok(det) = detect_target(id).await {
|
|
||||||
suggested.set(
|
|
||||||
det.data
|
|
||||||
.get("classification")
|
|
||||||
.and_then(|c| c.get("suggested"))
|
|
||||||
.and_then(|s| s.as_str())
|
|
||||||
.map(String::from),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
step.set(3);
|
|
||||||
}
|
|
||||||
Err(e) => error.set(Some(e.to_string())),
|
|
||||||
}
|
|
||||||
creating.set(false);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
if creating() { "Creating..." } else { "Create target" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user