Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9919afa0ee | ||
|
|
9ec07ff7a1 | ||
|
|
0e57c2d7a7 |
@@ -348,3 +348,43 @@ pub async fn detect_target(
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/targets/{id}/scan — trigger a scan for the target.
|
||||
///
|
||||
/// Dispatches to the unified pipeline when `UNIFIED_PIPELINE` is set (else the
|
||||
/// legacy path). Runs in the background and returns immediately.
|
||||
#[tracing::instrument(skip_all, fields(target_id = %id))]
|
||||
pub async fn trigger_target_scan(
|
||||
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?;
|
||||
// 404 if the target doesn't exist for this tenant.
|
||||
if db
|
||||
.onboarded_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.is_none()
|
||||
{
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
let agent_clone = (*agent).clone();
|
||||
let tenant_id = tenant.0.tenant_id.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = agent_clone
|
||||
.run_scan(
|
||||
&tenant_id,
|
||||
&id,
|
||||
compliance_core::models::ScanTrigger::Manual,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Manual target scan failed for {id}: {e}");
|
||||
}
|
||||
});
|
||||
Ok(Json(serde_json::json!({ "status": "scan_triggered" })))
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ pub fn build_router() -> Router {
|
||||
"/api/v1/targets/{id}/detect",
|
||||
post(handlers::onboarding::detect_target),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/targets/{id}/scan",
|
||||
post(handlers::onboarding::trigger_target_scan),
|
||||
)
|
||||
.route("/api/v1/findings", get(handlers::list_findings))
|
||||
.route("/api/v1/findings/{id}", get(handlers::get_finding))
|
||||
.route(
|
||||
|
||||
@@ -498,6 +498,13 @@ impl PipelineOrchestrator {
|
||||
"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() {
|
||||
Some(code) if code.kind == ArtifactKind::GitRepo => {
|
||||
let repo = repo_view_from_target(target, code);
|
||||
@@ -527,6 +534,110 @@ 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`
|
||||
/// and advance the git artifact's `last_scanned_commit` watermark.
|
||||
async fn finalize_target(
|
||||
|
||||
@@ -105,3 +105,19 @@ pub async fn fetch_applicable_scans(id: String) -> Result<ApplicableScansRespons
|
||||
.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()))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use dioxus::prelude::*;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::infrastructure::onboarding::{
|
||||
create_target, detect_target, fetch_applicable_scans, ArtifactInputDto,
|
||||
create_target, detect_target, fetch_applicable_scans, trigger_target_scan, ArtifactInputDto,
|
||||
};
|
||||
|
||||
/// (value, label, one-line description) for the 9 target families.
|
||||
@@ -113,6 +113,8 @@ pub fn OnboardingPage() -> Element {
|
||||
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 mut created_id = use_signal(|| Option::<String>::None);
|
||||
let mut scan_msg = use_signal(|| Option::<String>::None);
|
||||
|
||||
let step_now = step();
|
||||
let can_advance_type = !name().trim().is_empty() && !target_type().trim().is_empty();
|
||||
@@ -290,7 +292,27 @@ pub fn OnboardingPage() -> Element {
|
||||
ScanRow { scan: s }
|
||||
}
|
||||
}
|
||||
div { style: "margin-top: 16px;",
|
||||
if let Some(msg) = scan_msg() {
|
||||
div { style: "margin-top: 8px; color: var(--success, #2a2);", "{msg}" }
|
||||
}
|
||||
div { style: "margin-top: 16px; display: flex; gap: 8px;",
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| {
|
||||
if let Some(id) = created_id() {
|
||||
scan_msg.set(Some("Scan triggered...".to_string()));
|
||||
spawn(async move {
|
||||
match trigger_target_scan(id).await {
|
||||
Ok(_) => scan_msg.set(Some(
|
||||
"Scan started — findings will appear as it runs.".to_string(),
|
||||
)),
|
||||
Err(e) => scan_msg.set(Some(format!("Failed to start scan: {e}"))),
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
"Run scan"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
onclick: move |_| {
|
||||
@@ -301,6 +323,8 @@ pub fn OnboardingPage() -> Element {
|
||||
artifacts.write().clear();
|
||||
scans.write().clear();
|
||||
suggested.set(None);
|
||||
created_id.set(None);
|
||||
scan_msg.set(None);
|
||||
error.set(None);
|
||||
},
|
||||
"Onboard another"
|
||||
@@ -348,6 +372,7 @@ pub fn OnboardingPage() -> Element {
|
||||
.and_then(|s| s.as_str())
|
||||
.map(String::from);
|
||||
if let Some(id) = id {
|
||||
created_id.set(Some(id.clone()));
|
||||
if let Ok(sc) = fetch_applicable_scans(id.clone()).await {
|
||||
scans.set(sc.data.scans);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user