Compare commits

..
Author SHA1 Message Date
Sharang ParnerkarandClaude Fable 5 e99c34630e fix(audit): bump crossbeam-epoch to 0.9.20 (RUSTSEC-2026-0204)
CI / Check (pull_request) Successful in 8m9s
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
Invalid pointer dereference in the `fmt::Pointer` impl for `Atomic`/`Shared`
when the underlying pointer is invalid. Lockfile-only bump; no code changes.

Refs #118.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:30:45 +02:00
Sharang ParnerkarandClaude Fable 5 def7371d6a feat(onboarding): unified multi-target model + scan matrix foundation
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 / Check (pull_request) Failing after 4m28s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
Backend foundation for the artifact-aware onboarding redesign (epic #118),
replacing the git-only TrackedRepository / DastTarget split with a unified
OnboardedTarget classified by target type and carrying its artifacts.

compliance-core:
- OnboardedTarget model: TargetType (9 families), ArtifactKind (8 kinds),
  Artifact with per-kind config, ArtifactAuth (folds git auth + DastAuthConfig),
  Classification, TargetScanConfig (reuses pentest + tracker config).
- Table-driven scan-applicability matrix: applicable_scans / rules_for /
  supports_pentest, with SAST umbrella + firmware/PLC/mobile/DAST gated on
  artifact presence.
- TargetClassifier port trait, mirroring the Scanner trait.
- Additive ScanType/ScanPhase variants (firmware/PLC/mobile/container);
  ScanType is now Copy.

compliance-agent:
- onboarded_targets collection accessor + indexes (artifacts.source_ref,
  artifacts.kind, target_type).
- Drop a now-redundant ScanType clone surfaced by the Copy derive.

Foundation only (steps 1-2 of the approved plan); legacy scan paths untouched.
17 new unit tests; passes fmt + clippy -D warnings (agent, dashboard, mcp).

Refs #118, #119, #122, #121.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:23:51 +02:00
sharang aed551231c feat(dashboard): UI for managing MCP tokens (#94)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Successful in 3m3s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
Adds /mcp-tokens page so a logged-in user can mint, list, and revoke bearer tokens for the MCP server without curl. Pairs with #92's tenant-scoped MCP middleware — copy a token from the dashboard straight into an LLM client config.
2026-06-30 16:32:54 +00:00
sharang b851f4267a feat(m7.3): scheduler pulls tenants from registry, env as fallback (#96)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
Replaces M7.2-C static SCHEDULER_TENANT_IDS with a live query to the tenant-registry at every tick. New tenants picked up without an agent restart; env stays as fallback so a registry outage never silences the scheduler. Resolution order: registry -> SCHEDULER_TENANT_IDS env -> DEFAULT_SCHEDULER_TENANT_ID. Logs the active source on startup.
2026-06-30 16:32:35 +00:00
sharang e9536b6d98 fix(audit): bump quinn-proto + ignore rmcp DNS-rebinding advisory (#97)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m55s
CI / Deploy Dashboard (push) Successful in 2m44s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m50s
RUSTSEC-2026-0185 (quinn-proto 0.11.14): patch-bump to 0.11.15. RUSTSEC-2026-0189 (rmcp 0.16 DNS rebinding): added to ignore with public-hostname + bearer-auth threat-model justification; rmcp 0.16->2.x migration tracked as a separate multi-hour PR.
2026-06-30 16:07:01 +00:00
sharang a3a96fe2cc feat(m7.3): MCP tenant-scoped bearer tokens (#92)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 8m13s
CI / Deploy Dashboard (push) Successful in 7m3s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m50s
MCP server validates per-tenant bearer tokens on incoming calls and routes each tool to the caller's tenant DB. Closes the cross-tenant data leak in the MCP path identified in M7.3.
2026-06-30 15:27:21 +00:00
sharang ac24ca766a feat(m7.3): cross-tenant admin HTTP endpoints (#95)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
GET /api/admin/tenants lists tenant DBs; DELETE /api/admin/tenants/{tenant_id} drops them (GDPR). Behind a separate auth path that rejects customer realm tokens.
2026-06-30 15:23:39 +00:00
sharang 485c3ff45e chore(agent): remove stale unscoped webhook routes from API router (#93)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
Webhook routes live on the separate webhook server (port 3002). M7.2-C URL form is /webhook/{tenant_id}/{platform}/{repo_id}; mounting unscoped variants on the API router would mismatch handler signatures.
2026-06-30 15:18:31 +00:00
21 changed files with 1464 additions and 36 deletions
+13
View File
@@ -7,4 +7,17 @@ ignore = [
# not a realistic attack surface here. Revisit when mongodb bumps hickory. # not a realistic attack surface here. Revisit when mongodb bumps hickory.
"RUSTSEC-2026-0118", # NSEC3 loop, no fix available upstream "RUSTSEC-2026-0118", # NSEC3 loop, no fix available upstream
"RUSTSEC-2026-0119", # O(n²) name compression, fixed in hickory-proto >=0.26.1 "RUSTSEC-2026-0119", # O(n²) name compression, fixed in hickory-proto >=0.26.1
# rmcp 0.16.0 — DNS rebinding in Streamable HTTP server transport (missing
# Host header validation). Patched in rmcp >= 1.4.0, which is a major API
# version jump from our pin; rmcp shipped 0.x → 1.x → 2.x in three months
# and the migration touches every tool handler + the auth middleware we
# just landed in #92. Threat model in our deployment: the MCP server is
# exposed at a public hostname (comp-mcp-dev.meghsakha.com) behind orca's
# TLS-terminating ingress with per-tenant bearer auth — the attack model
# (browser DNS-rebinding into localhost MCP server) doesn't directly apply.
# Defense-in-depth Host-header check is still a worthwhile follow-up.
# FOLLOW-UP: bump rmcp to 2.x in a dedicated PR (M7.3 follow-up, sized
# multi-hour due to API surface change).
"RUSTSEC-2026-0189",
] ]
Generated
+4 -4
View File
@@ -1118,9 +1118,9 @@ dependencies = [
[[package]] [[package]]
name = "crossbeam-epoch" name = "crossbeam-epoch"
version = "0.9.18" version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
@@ -4282,9 +4282,9 @@ dependencies = [
[[package]] [[package]]
name = "quinn-proto" name = "quinn-proto"
version = "0.11.14" version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [ dependencies = [
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.3.4",
+115
View File
@@ -0,0 +1,115 @@
//! Cross-tenant admin endpoints (`/api/v1/admin/*`).
//!
//! Operator-only. Auth is a **static bearer token** (`ADMIN_API_TOKEN`
//! env on the agent) — explicitly NOT a Keycloak JWT, because the
//! whole point of these endpoints is to operate ACROSS tenants. A
//! customer JWT (which always carries a single tenant_id) has no
//! business mounting them.
//!
//! Routes are only registered when `ADMIN_API_TOKEN` is set. With no
//! token, the endpoints don't exist at all (404), which is a stronger
//! guarantee than "401 if you guess the path".
//!
//! Operations:
//! - `GET /api/v1/admin/tenants` — list tenant DBs
//! - `DELETE /api/v1/admin/tenants/{tenant_id}` — GDPR delete
//!
//! Tenant ids in URLs are passed as-is to `DatabasePool::drop_tenant`,
//! which sanitises them the same way it does for creation. Listing
//! returns the raw DB names from `list_tenant_db_names` — operators
//! can reverse-derive the tenant_id from the prefix.
use axum::extract::{Extension, Path, Request};
use axum::http::{header, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use secrecy::ExposeSecret;
use serde::Serialize;
use super::dto::AgentExt;
#[derive(Serialize)]
pub struct ListTenantDbsResponse {
pub tenant_db_names: Vec<String>,
}
#[tracing::instrument(skip_all)]
pub async fn list_tenant_dbs(
Extension(agent): AgentExt,
) -> Result<Json<ListTenantDbsResponse>, StatusCode> {
let names = agent.db_pool.list_tenant_db_names().await.map_err(|e| {
tracing::error!("admin: list_tenant_db_names failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(ListTenantDbsResponse {
tenant_db_names: names,
}))
}
#[tracing::instrument(skip_all, fields(tenant_id = %tenant_id))]
pub async fn drop_tenant_db(
Extension(agent): AgentExt,
Path(tenant_id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
agent.db_pool.drop_tenant(&tenant_id).await.map_err(|e| {
tracing::error!("admin: drop_tenant failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(serde_json::json!({ "status": "dropped" })))
}
/// Constant-time-ish comparison of the configured admin token against
/// the incoming bearer. Uses `subtle`-style byte equality so timing
/// attacks can't probe the token character by character.
fn tokens_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.bytes().zip(b.bytes()) {
diff |= x ^ y;
}
diff == 0
}
/// Middleware enforcing the static `ADMIN_API_TOKEN`. Mounted only on
/// the admin sub-router, so this never runs on customer routes.
pub async fn require_admin_token(
Extension(agent): AgentExt,
request: Request,
next: Next,
) -> Response {
let Some(expected) = agent.config.admin_api_token.as_ref() else {
// Belt-and-braces — if the routes were somehow mounted without
// a token configured, refuse rather than no-op-pass.
return (StatusCode::NOT_FOUND, "admin disabled").into_response();
};
let presented = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(|s| s.trim());
let Some(presented) = presented.filter(|s| !s.is_empty()) else {
return (StatusCode::UNAUTHORIZED, "Missing bearer token").into_response();
};
if !tokens_eq(presented, expected.expose_secret()) {
return (StatusCode::UNAUTHORIZED, "Invalid admin token").into_response();
}
next.run(request).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokens_eq_basic() {
assert!(tokens_eq("abc", "abc"));
assert!(!tokens_eq("abc", "abd"));
assert!(!tokens_eq("abc", "abcd"));
assert!(!tokens_eq("", "x"));
assert!(tokens_eq("", ""));
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod admin;
pub mod chat; pub mod chat;
pub mod dast; pub mod dast;
pub mod dto; pub mod dto;
+6 -14
View File
@@ -2,7 +2,6 @@ use axum::routing::{delete, get, patch, post};
use axum::Router; use axum::Router;
use crate::api::handlers; use crate::api::handlers;
use crate::webhooks;
pub fn build_router() -> Router { pub fn build_router() -> Router {
Router::new() Router::new()
@@ -184,17 +183,10 @@ pub fn build_router() -> Router {
"/api/v1/pentest/stats", "/api/v1/pentest/stats",
get(handlers::pentest::pentest_stats), get(handlers::pentest::pentest_stats),
) )
// Webhook endpoints (proxied through dashboard) // Webhook routes live on the separate webhook server (port 3002,
.route( // see crate::webhooks::server). The M7.2-C tenant-in-URL form is
"/webhook/github/{repo_id}", // `/webhook/{tenant_id}/{platform}/{repo_id}` and the handlers
post(webhooks::github::handle_github_webhook), // expect a (tenant_id, repo_id) path tuple. Anything mounting
) // them here on the API server would mismatch the handler
.route( // signature, so the routes are not exported.
"/webhook/gitlab/{repo_id}",
post(webhooks::gitlab::handle_gitlab_webhook),
)
.route(
"/webhook/gitea/{repo_id}",
post(webhooks::gitea::handle_gitea_webhook),
)
} }
+24 -1
View File
@@ -4,7 +4,8 @@ use axum::extract::Request;
use axum::http::HeaderValue; use axum::http::HeaderValue;
use axum::middleware::Next; use axum::middleware::Next;
use axum::response::Response; use axum::response::Response;
use axum::{middleware, Extension}; use axum::routing::{delete, get};
use axum::{middleware, Extension, Router};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use tower_http::set_header::SetResponseHeaderLayer; use tower_http::set_header::SetResponseHeaderLayer;
@@ -14,6 +15,7 @@ use compliance_core::auth::{require_jwt_auth, require_tenant_status, JwksState};
use compliance_core::{TenantContext, TenantStatus}; use compliance_core::{TenantContext, TenantStatus};
use crate::agent::ComplianceAgent; use crate::agent::ComplianceAgent;
use crate::api::handlers;
use crate::api::routes; use crate::api::routes;
use crate::error::AgentError; use crate::error::AgentError;
@@ -50,7 +52,28 @@ pub async fn inject_dev_tenant(mut request: Request, next: Next) -> Response {
} }
pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), AgentError> { pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), AgentError> {
// Admin sub-router. Routes are only mounted when ADMIN_API_TOKEN is
// configured — without it, the paths don't exist at all (404 rather
// than 401), so an operator who hasn't opted in can't fingerprint
// the surface area.
let admin_router: Router = if agent.config.admin_api_token.is_some() {
tracing::info!("Admin API enabled — /api/v1/admin/* mounted behind ADMIN_API_TOKEN bearer");
Router::new()
.route(
"/api/v1/admin/tenants",
get(handlers::admin::list_tenant_dbs),
)
.route(
"/api/v1/admin/tenants/{tenant_id}",
delete(handlers::admin::drop_tenant_db),
)
.layer(middleware::from_fn(handlers::admin::require_admin_token))
} else {
Router::new()
};
let mut app = routes::build_router() let mut app = routes::build_router()
.merge(admin_router)
.layer(Extension(Arc::new(agent.clone()))) .layer(Extension(Arc::new(agent.clone())))
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
+2
View File
@@ -59,5 +59,7 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
.unwrap_or(true), .unwrap_or(true),
pentest_imap_username: env_var_opt("PENTEST_IMAP_USERNAME"), pentest_imap_username: env_var_opt("PENTEST_IMAP_USERNAME"),
pentest_imap_password: env_secret_opt("PENTEST_IMAP_PASSWORD"), pentest_imap_password: env_secret_opt("PENTEST_IMAP_PASSWORD"),
admin_api_token: env_secret_opt("ADMIN_API_TOKEN"),
tenant_registry_url: env_var_opt("TENANT_REGISTRY_URL"),
}) })
} }
+37
View File
@@ -428,6 +428,36 @@ impl Database {
) )
.await?; .await?;
// onboarded_targets: multikey on artifact source ref (webhook + dedupe
// lookup). Non-unique — "one git URL per tenant" is enforced in the
// create handler, since a unique multikey index on an array field has
// null-collision caveats.
self.onboarded_targets()
.create_index(
IndexModel::builder()
.keys(doc! { "artifacts.source_ref": 1 })
.build(),
)
.await?;
// onboarded_targets: multikey on artifact kind
self.onboarded_targets()
.create_index(
IndexModel::builder()
.keys(doc! { "artifacts.kind": 1 })
.build(),
)
.await?;
// onboarded_targets: target_type filter
self.onboarded_targets()
.create_index(
IndexModel::builder()
.keys(doc! { "target_type": 1 })
.build(),
)
.await?;
tracing::info!("Database indexes ensured"); tracing::info!("Database indexes ensured");
Ok(()) Ok(())
} }
@@ -484,6 +514,13 @@ impl Database {
self.inner.collection("dast_targets") self.inner.collection("dast_targets")
} }
/// The unified onboarding targets that replace `repositories` and
/// `dast_targets`. Ids are preserved from the legacy collections during
/// migration so downstream `repo_id` / `target_id` references keep resolving.
pub fn onboarded_targets(&self) -> Collection<OnboardedTarget> {
self.inner.collection("onboarded_targets")
}
pub fn dast_scan_runs(&self) -> Collection<DastScanRun> { pub fn dast_scan_runs(&self) -> Collection<DastScanRun> {
self.inner.collection("dast_scan_runs") self.inner.collection("dast_scan_runs")
} }
+2
View File
@@ -339,6 +339,8 @@ mod tests {
pentest_imap_tls: true, pentest_imap_tls: true,
pentest_imap_username: None, pentest_imap_username: None,
pentest_imap_password: None, pentest_imap_password: None,
admin_api_token: None,
tenant_registry_url: None,
} }
} }
+1 -1
View File
@@ -215,7 +215,7 @@ fn scan_with_patterns(
repo_id.to_string(), repo_id.to_string(),
fingerprint, fingerprint,
scanner_name.to_string(), scanner_name.to_string(),
scan_type.clone(), scan_type,
pattern.title.clone(), pattern.title.clone(),
pattern.description.clone(), pattern.description.clone(),
pattern.severity.clone(), pattern.severity.clone(),
+191 -11
View File
@@ -7,11 +7,18 @@ use crate::agent::ComplianceAgent;
use crate::database::Database; use crate::database::Database;
use crate::error::AgentError; use crate::error::AgentError;
/// Default tenant the scheduler runs against when `SCHEDULER_TENANT_IDS` /// Default tenant the scheduler runs against when neither the tenant
/// isn't set. Matches the dev-injector default so a bare `cargo run` has /// registry nor `SCHEDULER_TENANT_IDS` are configured. Matches the
/// the scheduler scanning whatever lives in `<prefix>_dev`. /// dev-injector default so a bare `cargo run` has the scheduler
/// scanning whatever lives in `<prefix>_dev`.
const DEFAULT_SCHEDULER_TENANT_ID: &str = "dev"; const DEFAULT_SCHEDULER_TENANT_ID: &str = "dev";
/// Request timeout when fetching the live tenant list from the
/// registry. Kept short — if the registry is slow we'd rather fall
/// back to env-configured ids and finish the tick than block the
/// scheduler loop.
const REGISTRY_FETCH_TIMEOUT_SECS: u64 = 5;
pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError> { pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError> {
let sched = JobScheduler::new() let sched = JobScheduler::new()
.await .await
@@ -24,7 +31,12 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
let agent = scan_agent.clone(); let agent = scan_agent.clone();
Box::pin(async move { Box::pin(async move {
tracing::info!("Scheduled scan triggered"); tracing::info!("Scheduled scan triggered");
for tenant_id in scheduler_tenants() { let tenants = scheduler_tenants(&agent).await;
tracing::debug!(
tenant_count = tenants.len(),
"Scheduled scan: tenants resolved"
);
for tenant_id in tenants {
scan_all_repos(&agent, &tenant_id).await; scan_all_repos(&agent, &tenant_id).await;
} }
}) })
@@ -42,7 +54,12 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
let agent = cve_agent.clone(); let agent = cve_agent.clone();
Box::pin(async move { Box::pin(async move {
tracing::info!("CVE monitor triggered"); tracing::info!("CVE monitor triggered");
for tenant_id in scheduler_tenants() { let tenants = scheduler_tenants(&agent).await;
tracing::debug!(
tenant_count = tenants.len(),
"CVE monitor: tenants resolved"
);
for tenant_id in tenants {
monitor_cves(&agent, &tenant_id).await; monitor_cves(&agent, &tenant_id).await;
} }
}) })
@@ -58,9 +75,14 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
.await .await
.map_err(|e| AgentError::Scheduler(format!("Failed to start scheduler: {e}")))?; .map_err(|e| AgentError::Scheduler(format!("Failed to start scheduler: {e}")))?;
let tenants = scheduler_tenants(); let tenants = scheduler_tenants(agent).await;
let source = if agent.config.tenant_registry_url.is_some() {
"tenant-registry (env fallback)"
} else {
"env (SCHEDULER_TENANT_IDS)"
};
tracing::info!( tracing::info!(
"Scheduler started: scans='{}', CVE monitor='{}', tenants={tenants:?}", "Scheduler started: scans='{}', CVE monitor='{}', tenant source={source}, tenants={tenants:?}",
agent.config.scan_schedule, agent.config.scan_schedule,
agent.config.cve_monitor_schedule, agent.config.cve_monitor_schedule,
); );
@@ -71,10 +93,40 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
} }
} }
/// Tenants the scheduler iterates each tick. From `SCHEDULER_TENANT_IDS` /// Tenants the scheduler iterates each tick.
/// (comma-separated), or `DEFAULT_SCHEDULER_TENANT_ID` if unset. M7.2-D ///
/// will replace this with a pull from the tenant-registry. /// Resolution order:
fn scheduler_tenants() -> Vec<String> { /// 1. **Tenant registry** at `agent.config.tenant_registry_url`
/// (`GET /v1/tenants`). Fresh on every tick — picks up newly
/// provisioned tenants without an agent restart.
/// 2. **`SCHEDULER_TENANT_IDS`** env (comma-separated) — fallback when
/// the registry is unreachable, the response is malformed, or no
/// registry URL is configured.
/// 3. **`DEFAULT_SCHEDULER_TENANT_ID`** (`"dev"`) — last-ditch fallback
/// so the scheduler keeps doing something useful in dev.
///
/// We never panic out of this function — the scheduler must keep
/// firing even if the registry is offline.
async fn scheduler_tenants(agent: &ComplianceAgent) -> Vec<String> {
if let Some(url) = agent.config.tenant_registry_url.as_deref() {
match fetch_tenants_from_registry(&agent.http, url).await {
Ok(v) if !v.is_empty() => return v,
Ok(_) => {
tracing::warn!("tenant-registry returned empty list; falling back to env");
}
Err(e) => {
tracing::warn!(
url = %url,
error = %e,
"tenant-registry fetch failed; falling back to env"
);
}
}
}
tenants_from_env()
}
fn tenants_from_env() -> Vec<String> {
std::env::var("SCHEDULER_TENANT_IDS") std::env::var("SCHEDULER_TENANT_IDS")
.ok() .ok()
.map(|s| { .map(|s| {
@@ -88,6 +140,134 @@ fn scheduler_tenants() -> Vec<String> {
.unwrap_or_else(|| vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()]) .unwrap_or_else(|| vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()])
} }
/// Shape we accept from the registry. Liberal in what we accept:
/// the registry can return any field shape as long as either `id` or
/// `tenant_id` is present. Other fields are ignored.
#[derive(serde::Deserialize)]
struct RegistryTenant {
#[serde(alias = "tenant_id")]
id: String,
/// Filter out non-running tenants if status is present. Missing
/// status defaults to "active" so older registry deployments keep
/// working.
#[serde(default = "default_status")]
status: String,
}
fn default_status() -> String {
"active".to_string()
}
#[derive(serde::Deserialize)]
struct RegistryListResponse {
data: Vec<RegistryTenant>,
}
async fn fetch_tenants_from_registry(
http: &reqwest::Client,
base_url: &str,
) -> Result<Vec<String>, String> {
let url = format!("{}/v1/tenants", base_url.trim_end_matches('/'));
let resp = http
.get(&url)
.timeout(std::time::Duration::from_secs(REGISTRY_FETCH_TIMEOUT_SECS))
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("registry returned {}", resp.status()));
}
let body: RegistryListResponse = resp
.json()
.await
.map_err(|e| format!("invalid JSON: {e}"))?;
Ok(filter_active(body.data))
}
/// Frozen/Archived tenants don't need scheduled scans; the M7.1
/// status gate would 402/410 anyway. Skip them so we don't waste
/// cycles. Active / trial / demo / anything-else-unknown all run.
fn filter_active(rows: Vec<RegistryTenant>) -> Vec<String> {
rows.into_iter()
.filter(|t| !matches!(t.status.as_str(), "frozen" | "archived"))
.map(|t| t.id)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn tenant(id: &str, status: &str) -> RegistryTenant {
RegistryTenant {
id: id.to_string(),
status: status.to_string(),
}
}
#[test]
fn filter_active_keeps_running_skips_frozen_archived() {
let rows = vec![
tenant("a", "active"),
tenant("b", "trial"),
tenant("c", "demo"),
tenant("d", "frozen"),
tenant("e", "archived"),
tenant("f", "weird-but-not-known-dead"),
];
let out = filter_active(rows);
assert_eq!(out, vec!["a", "b", "c", "f"]);
}
#[test]
fn deserialize_registry_response_accepts_id_or_tenant_id() {
let body = r#"{"data":[
{"id":"a","status":"active"},
{"tenant_id":"b","status":"trial"},
{"id":"c"}
]}"#;
let parsed: RegistryListResponse = serde_json::from_str(body).unwrap();
assert_eq!(parsed.data.len(), 3);
assert_eq!(parsed.data[0].id, "a");
assert_eq!(parsed.data[1].id, "b");
assert_eq!(parsed.data[2].id, "c");
// Default status for the third entry should be "active"
assert_eq!(parsed.data[2].status, "active");
}
/// Combined into a single test: cargo runs tests in parallel and
/// env vars are process-global, so two separate tests touching
/// `SCHEDULER_TENANT_IDS` race each other. Doing both checks in
/// one test keeps them in a deterministic order.
#[test]
fn tenants_from_env_resolution() {
std::env::remove_var("SCHEDULER_TENANT_IDS");
assert_eq!(
tenants_from_env(),
vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()],
"unset → default"
);
std::env::set_var("SCHEDULER_TENANT_IDS", "acme, globex ,,hello");
let out = tenants_from_env();
std::env::remove_var("SCHEDULER_TENANT_IDS");
assert_eq!(
out,
vec!["acme", "globex", "hello"],
"splits + trims + drops empty"
);
std::env::set_var("SCHEDULER_TENANT_IDS", "");
let out = tenants_from_env();
std::env::remove_var("SCHEDULER_TENANT_IDS");
assert_eq!(
out,
vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()],
"empty → default"
);
}
}
/// Resolve the per-tenant database. Logs and returns `None` on failure /// Resolve the per-tenant database. Logs and returns `None` on failure
/// so the loop in the caller can continue with other tenants. /// so the loop in the caller can continue with other tenants.
async fn tenant_db(agent: &ComplianceAgent, tenant_id: &str) -> Option<Database> { async fn tenant_db(agent: &ComplianceAgent, tenant_id: &str) -> Option<Database> {
+2
View File
@@ -66,6 +66,8 @@ impl TestServer {
pentest_imap_tls: false, pentest_imap_tls: false,
pentest_imap_username: None, pentest_imap_username: None,
pentest_imap_password: None, pentest_imap_password: None,
admin_api_token: None,
tenant_registry_url: None,
}; };
let agent = ComplianceAgent::new(config, db_pool); let agent = ComplianceAgent::new(config, db_pool);
+12 -4
View File
@@ -63,16 +63,24 @@ struct Claims {
const PUBLIC_ENDPOINTS: &[&str] = &["/api/v1/health"]; const PUBLIC_ENDPOINTS: &[&str] = &["/api/v1/health"];
/// Path prefixes that bypass JWT validation. The admin sub-router
/// (`/api/v1/admin/*`) has its own static-bearer middleware and must
/// not be routed through the customer-JWT path — a Keycloak token
/// always carries a single tenant_id and would semantically conflict
/// with cross-tenant admin operations.
const PUBLIC_PREFIXES: &[&str] = &["/api/v1/admin/"];
/// Middleware that validates Bearer JWT tokens against Keycloak's JWKS /// Middleware that validates Bearer JWT tokens against Keycloak's JWKS
/// and attaches a `TenantContext` extension on success. /// and attaches a `TenantContext` extension on success.
/// ///
/// Skips validation for the health endpoint. /// Skips validation for the health endpoint and any path under one of
/// If `JwksState` is not present (Keycloak not configured), requests /// the [`PUBLIC_PREFIXES`]. If `JwksState` is not present (Keycloak
/// pass through and downstream code must handle the missing context. /// not configured), requests pass through and downstream code must
/// handle the missing context.
pub async fn require_jwt_auth(mut request: Request, next: Next) -> Response { pub async fn require_jwt_auth(mut request: Request, next: Next) -> Response {
let path = request.uri().path(); let path = request.uri().path();
if PUBLIC_ENDPOINTS.contains(&path) { if PUBLIC_ENDPOINTS.contains(&path) || PUBLIC_PREFIXES.iter().any(|p| path.starts_with(p)) {
return next.run(request).await; return next.run(request).await;
} }
+9
View File
@@ -37,6 +37,15 @@ pub struct AgentConfig {
pub pentest_imap_tls: bool, pub pentest_imap_tls: bool,
pub pentest_imap_username: Option<String>, pub pentest_imap_username: Option<String>,
pub pentest_imap_password: Option<SecretString>, pub pentest_imap_password: Option<SecretString>,
/// Static bearer for the cross-tenant admin endpoints under
/// `/api/v1/admin/*`. When `None`, those endpoints are not
/// mounted at all (defense-in-depth: ops endpoints never reach
/// any auth path if no operator has explicitly opted in).
pub admin_api_token: Option<SecretString>,
/// Live tenant-registry URL the scheduler consults for the list
/// of tenants to iterate. When `None` or unreachable, scheduler
/// falls back to `SCHEDULER_TENANT_IDS` env (M7.2-C).
pub tenant_registry_url: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
+1
View File
@@ -2,6 +2,7 @@ pub mod config;
pub mod db; pub mod db;
pub mod error; pub mod error;
pub mod models; pub mod models;
pub mod scan_matrix;
#[cfg(feature = "telemetry")] #[cfg(feature = "telemetry")]
pub mod telemetry; pub mod telemetry;
pub mod tenant; pub mod tenant;
+6
View File
@@ -9,6 +9,7 @@ pub mod issue;
pub mod mcp; pub mod mcp;
pub mod mcp_token; pub mod mcp_token;
pub mod notification; pub mod notification;
pub mod onboarding;
pub mod pentest; pub mod pentest;
pub mod repository; pub mod repository;
pub mod sbom; pub mod sbom;
@@ -31,6 +32,11 @@ pub use issue::{IssueStatus, TrackerIssue, TrackerType};
pub use mcp::{McpServerConfig, McpServerStatus, McpTransport}; pub use mcp::{McpServerConfig, McpServerStatus, McpTransport};
pub use mcp_token::{McpToken, McpTokenView}; pub use mcp_token::{McpToken, McpTokenView};
pub use notification::{CveNotification, NotificationSeverity, NotificationStatus}; pub use notification::{CveNotification, NotificationSeverity, NotificationStatus};
pub use onboarding::{
Artifact, ArtifactAuth, ArtifactKind, Classification, DetectedFact, GitArtifactConfig,
IssueTrackerConfig, OnboardedTarget, PlcArtifactConfig, PlcFormat, TargetScanConfig,
TargetType, TargetTypeCandidate, WebArtifactConfig,
};
pub use pentest::{ pub use pentest::{
AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, IdentityProvider, AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, IdentityProvider,
PentestAuthConfig, PentestConfig, PentestEvent, PentestMessage, PentestSession, PentestStats, PentestAuthConfig, PentestConfig, PentestEvent, PentestMessage, PentestSession, PentestStats,
+587
View File
@@ -0,0 +1,587 @@
//! The unified onboarding model.
//!
//! An [`OnboardedTarget`] is the single source of truth for anything the scanner
//! can analyze. It records *what kind of software* the target is ([`TargetType`]),
//! the concrete [`Artifact`]s that were provided for it (a git repo, a firmware
//! image, a live URL, a PLC project, ...), the classifier's verdict, and the scan
//! configuration. It replaces the older git-only `TrackedRepository` and the
//! standalone `DastTarget`, both of which fold into this type as artifacts.
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::dast::{DastAuthConfig, DastTargetType};
use super::issue::TrackerType;
use super::pentest::{Environment, PentestConfig, PentestStrategy};
use super::scan::ScanType;
/// The family of software a target belongs to.
///
/// Targets look endlessly varied but fall into a small enumerable set classified
/// by where the analyzable signal lives. This drives the scan-applicability
/// matrix and the onboarding wizard's type selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetType {
/// Browser-facing web application (front end + server).
WebApp,
/// Headless backend service / API (REST, GraphQL, gRPC).
BackendService,
/// Desktop application (Windows/macOS/Linux GUI or CLI binary).
DesktopApp,
/// Android application (APK / AAB).
AndroidApp,
/// iOS application (IPA).
IosApp,
/// Bare-metal embedded firmware (no operating system).
FirmwareBareMetal,
/// Embedded firmware running on an RTOS (Zephyr, FreeRTOS, ...).
FirmwareRtos,
/// Embedded Linux built with Yocto / OpenEmbedded (BSP + image).
EmbeddedLinuxYocto,
/// Programmable logic controller software (IEC 61131-3, PLCopen / SPS).
PlcSps,
}
impl std::fmt::Display for TargetType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WebApp => write!(f, "web_app"),
Self::BackendService => write!(f, "backend_service"),
Self::DesktopApp => write!(f, "desktop_app"),
Self::AndroidApp => write!(f, "android_app"),
Self::IosApp => write!(f, "ios_app"),
Self::FirmwareBareMetal => write!(f, "firmware_bare_metal"),
Self::FirmwareRtos => write!(f, "firmware_rtos"),
Self::EmbeddedLinuxYocto => write!(f, "embedded_linux_yocto"),
Self::PlcSps => write!(f, "plc_sps"),
}
}
}
/// The kind of artifact provided for a target.
///
/// Which scans are possible is a function of the target type *and* which of
/// these are present (SAST needs code, DAST needs a running URL, and so on).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactKind {
/// A git repository (cloned for static analysis).
GitRepo,
/// A source archive (zip / tarball) with no live git remote.
SourceArchive,
/// A firmware image or binary blob.
FirmwareImage,
/// A mobile package: Android APK/AAB or iOS IPA.
MobilePackage,
/// An OCI/Docker container image reference.
ContainerImage,
/// A reachable running instance (base URL / endpoint) for dynamic testing.
LiveUrl,
/// A PLC project: PLCopen XML or Structured Text source.
PlcProject,
/// Free-form plaintext describing the target (feeds classification only).
PlaintextDescription,
}
impl std::fmt::Display for ArtifactKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::GitRepo => write!(f, "git_repo"),
Self::SourceArchive => write!(f, "source_archive"),
Self::FirmwareImage => write!(f, "firmware_image"),
Self::MobilePackage => write!(f, "mobile_package"),
Self::ContainerImage => write!(f, "container_image"),
Self::LiveUrl => write!(f, "live_url"),
Self::PlcProject => write!(f, "plc_project"),
Self::PlaintextDescription => write!(f, "plaintext_description"),
}
}
}
/// Credentials attached to an artifact.
///
/// This folds both `TrackedRepository`'s git auth (`auth_token` / `auth_username`
/// / SSH key) and `DastAuthConfig`'s HTTP auth (form / bearer / cookie) into one
/// shape so a single artifact carries whatever it needs to be fetched or probed.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ArtifactAuth {
/// Auth method: `none` | `token` | `basic` | `bearer` | `cookie` | `form` | `ssh`.
#[serde(default)]
pub method: String,
/// Username (git user, basic-auth user, or `x-access-token` for PATs).
pub username: Option<String>,
/// The secret credential: PAT, password, or bearer token. Encrypted at rest.
pub secret: Option<String>,
/// Path to an SSH private key for git-over-SSH.
pub ssh_key_path: Option<String>,
/// Login URL for form-based authentication.
pub login_url: Option<String>,
/// Extra headers to send when authenticating / probing.
pub headers: Option<HashMap<String, String>>,
}
impl From<DastAuthConfig> for ArtifactAuth {
fn from(c: DastAuthConfig) -> Self {
Self {
method: c.method,
username: c.username,
// Prefer a bearer token; otherwise fall back to the password.
secret: c.token.or(c.password),
ssh_key_path: None,
login_url: c.login_url,
headers: c.headers,
}
}
}
/// Git-specific configuration for a [`ArtifactKind::GitRepo`] artifact.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitArtifactConfig {
/// Branch to scan.
pub default_branch: String,
/// Commit SHA of the last completed scan (change-detection watermark).
pub last_scanned_commit: Option<String>,
/// Local clone path once the repo has been fetched.
pub local_path: Option<String>,
}
impl GitArtifactConfig {
/// Config for a fresh git artifact on the given branch.
pub fn on_branch(branch: impl Into<String>) -> Self {
Self {
default_branch: branch.into(),
last_scanned_commit: None,
local_path: None,
}
}
}
impl Default for GitArtifactConfig {
fn default() -> Self {
Self::on_branch("main")
}
}
/// Dynamic-analysis configuration for a [`ArtifactKind::LiveUrl`] artifact.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebArtifactConfig {
/// Whether the endpoint is a web app, REST API, or GraphQL API.
pub target_kind: DastTargetType,
/// URL paths to exclude from crawling / scanning.
#[serde(default)]
pub excluded_paths: Vec<String>,
/// Maximum crawl depth.
pub max_crawl_depth: u32,
/// Rate limit in requests per second.
pub rate_limit: u32,
/// Whether destructive methods (DELETE / PUT) are permitted.
#[serde(default)]
pub allow_destructive: bool,
}
impl Default for WebArtifactConfig {
fn default() -> Self {
Self {
target_kind: DastTargetType::WebApp,
excluded_paths: Vec::new(),
max_crawl_depth: 3,
rate_limit: 10,
allow_destructive: false,
}
}
}
/// The source format of a PLC project artifact.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlcFormat {
/// PLCopen XML project export.
PlcopenXml,
/// IEC 61131-3 Structured Text source.
StructuredText,
}
/// PLC-specific configuration for a [`ArtifactKind::PlcProject`] artifact.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlcArtifactConfig {
/// The project source format.
pub format: PlcFormat,
}
/// A single fact discovered about a target by ingest or classification
/// (e.g. `language=rust`, `build_system=cmake`, `mcu=stm32f429`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectedFact {
/// The fact name.
pub key: String,
/// The fact value.
pub value: String,
/// What produced the fact (e.g. `tramiton`, `language-fingerprint`).
pub source: String,
}
impl DetectedFact {
/// Build a fact from its parts.
pub fn new(
key: impl Into<String>,
value: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self {
key: key.into(),
value: value.into(),
source: source.into(),
}
}
}
/// One concrete thing provided for a target: code, a binary, a URL, etc.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artifact {
/// Stable per-artifact id (UUID v4) — scan steps reference this.
pub id: String,
/// What kind of artifact this is.
pub kind: ArtifactKind,
/// The source reference: git URL, blob id, live URL, or image ref.
pub source_ref: String,
/// Optional human-friendly label.
pub display_name: Option<String>,
/// Content-addressed storage path once ingested (blobs only).
pub stored_path: Option<String>,
/// SHA-256 of the ingested content (git artifacts store the head SHA).
pub content_hash: Option<String>,
/// Size of the stored blob in bytes.
pub size_bytes: Option<u64>,
/// Credentials for fetching or probing this artifact.
pub auth: Option<ArtifactAuth>,
/// Git configuration (present for [`ArtifactKind::GitRepo`]).
pub git: Option<GitArtifactConfig>,
/// Dynamic-analysis configuration (present for [`ArtifactKind::LiveUrl`]).
pub web: Option<WebArtifactConfig>,
/// PLC configuration (present for [`ArtifactKind::PlcProject`]).
pub plc: Option<PlcArtifactConfig>,
/// Facts discovered about this artifact by ingest / classification.
#[serde(default)]
pub detected: Vec<DetectedFact>,
/// When this artifact was last ingested.
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
pub ingested_at: Option<DateTime<Utc>>,
}
impl Artifact {
/// A bare artifact of the given kind and source reference.
fn bare(kind: ArtifactKind, source_ref: impl Into<String>) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
kind,
source_ref: source_ref.into(),
display_name: None,
stored_path: None,
content_hash: None,
size_bytes: None,
auth: None,
git: None,
web: None,
plc: None,
detected: Vec::new(),
ingested_at: None,
}
}
/// A git-repository artifact tracking the given branch.
pub fn git_repo(url: impl Into<String>, branch: impl Into<String>) -> Self {
let mut a = Self::bare(ArtifactKind::GitRepo, url);
a.git = Some(GitArtifactConfig::on_branch(branch));
a
}
/// A live-URL artifact with default crawl settings.
pub fn live_url(url: impl Into<String>) -> Self {
let mut a = Self::bare(ArtifactKind::LiveUrl, url);
a.web = Some(WebArtifactConfig::default());
a
}
/// A firmware-image artifact referenced by name (blob ingested later).
pub fn firmware_image(source_ref: impl Into<String>) -> Self {
Self::bare(ArtifactKind::FirmwareImage, source_ref)
}
/// A source-archive artifact referenced by name (blob ingested later).
pub fn source_archive(source_ref: impl Into<String>) -> Self {
Self::bare(ArtifactKind::SourceArchive, source_ref)
}
/// A mobile-package artifact (APK/AAB/IPA) referenced by name.
pub fn mobile_package(source_ref: impl Into<String>) -> Self {
Self::bare(ArtifactKind::MobilePackage, source_ref)
}
/// A container-image artifact referenced by OCI ref.
pub fn container_image(source_ref: impl Into<String>) -> Self {
Self::bare(ArtifactKind::ContainerImage, source_ref)
}
/// A PLC-project artifact in the given format.
pub fn plc_project(source_ref: impl Into<String>, format: PlcFormat) -> Self {
let mut a = Self::bare(ArtifactKind::PlcProject, source_ref);
a.plc = Some(PlcArtifactConfig { format });
a
}
/// A plaintext-description artifact (classification input only).
pub fn plaintext(text: impl Into<String>) -> Self {
Self::bare(ArtifactKind::PlaintextDescription, text)
}
}
/// One ranked candidate produced by the classifier.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetTypeCandidate {
/// The candidate target type.
pub target_type: TargetType,
/// Confidence in `[0.0, 1.0]`.
pub confidence: f32,
/// Why this candidate was proposed.
pub rationale: String,
}
/// The classifier's verdict for a target: a suggested type plus ranked
/// alternatives and the facts the decision rested on.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Classification {
/// The top-ranked target type.
pub suggested: TargetType,
/// All candidates, sorted by descending confidence.
#[serde(default)]
pub candidates: Vec<TargetTypeCandidate>,
/// Facts gathered during classification.
#[serde(default)]
pub facts: Vec<DetectedFact>,
/// Which classifiers contributed (e.g. `["tramiton", "language-fingerprint"]`).
#[serde(default)]
pub detected_by: Vec<String>,
/// When classification ran.
#[serde(with = "super::serde_helpers::bson_datetime")]
pub detected_at: DateTime<Utc>,
/// Whether a human confirmed the suggestion.
#[serde(default)]
pub confirmed: bool,
}
/// Issue-tracker linkage, migrated from `TrackedRepository`'s `tracker_*` fields.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IssueTrackerConfig {
/// The tracker platform.
pub tracker_type: Option<TrackerType>,
/// Tracker owner / organization.
pub owner: Option<String>,
/// Tracker repository / project.
pub repo: Option<String>,
/// Per-target tracker access token.
pub token: Option<String>,
}
/// How a target should be scanned.
///
/// `enabled_scans` / `disabled_scans` override the scan-applicability matrix
/// defaults; the pentest and tracker blocks reuse the existing wizard config.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TargetScanConfig {
/// Scans explicitly turned on (empty means "use matrix defaults").
#[serde(default)]
pub enabled_scans: Vec<ScanType>,
/// Scans explicitly turned off.
#[serde(default)]
pub disabled_scans: Vec<ScanType>,
/// Target environment (gates destructive / active testing).
#[serde(default)]
pub environment: Environment,
/// Whether destructive tests are permitted for this target.
#[serde(default)]
pub allow_destructive: bool,
/// Pentest strategy selector.
pub strategy: Option<PentestStrategy>,
/// Full pentest wizard configuration.
pub pentest: Option<PentestConfig>,
/// Issue-tracker linkage.
pub issue_tracker: Option<IssueTrackerConfig>,
}
/// A target onboarded for scanning: the unified replacement for the legacy
/// `TrackedRepository` (SAST) and `DastTarget` (DAST) records.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnboardedTarget {
/// Mongo id. Preserved from the legacy record during migration so every
/// downstream collection keyed by `repo_id` / `target_id` keeps resolving.
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<bson::oid::ObjectId>,
/// Human-friendly name.
#[serde(default)]
pub name: String,
/// The software family this target belongs to.
pub target_type: TargetType,
/// Optional free-form description (also a classification input).
pub description: Option<String>,
/// The artifacts provided for this target.
#[serde(default)]
pub artifacts: Vec<Artifact>,
/// The classifier's verdict, once run.
pub classification: Option<Classification>,
/// How this target should be scanned.
#[serde(default)]
pub scan_config: TargetScanConfig,
/// Cron schedule for recurring scans, if any.
pub scan_schedule: Option<String>,
/// Whether inbound webhooks are enabled for this target.
#[serde(default)]
pub webhook_enabled: bool,
/// HMAC secret for verifying inbound webhooks.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webhook_secret: Option<String>,
/// Cached count of findings across this target's scans.
#[serde(default)]
pub findings_count: u32,
/// Creation timestamp.
#[serde(
default = "chrono::Utc::now",
with = "super::serde_helpers::bson_datetime"
)]
pub created_at: DateTime<Utc>,
/// Last-update timestamp.
#[serde(
default = "chrono::Utc::now",
with = "super::serde_helpers::bson_datetime"
)]
pub updated_at: DateTime<Utc>,
}
impl OnboardedTarget {
/// A new target of the given type with a freshly generated webhook secret.
pub fn new(name: String, target_type: TargetType) -> Self {
let now = Utc::now();
let webhook_secret = uuid::Uuid::new_v4().to_string().replace('-', "");
Self {
id: None,
name,
target_type,
description: None,
artifacts: Vec::new(),
classification: None,
scan_config: TargetScanConfig::default(),
scan_schedule: None,
webhook_enabled: false,
webhook_secret: Some(webhook_secret),
findings_count: 0,
created_at: now,
updated_at: now,
}
}
/// The first artifact of the given kind, if present.
pub fn first_of(&self, kind: ArtifactKind) -> Option<&Artifact> {
self.artifacts.iter().find(|a| a.kind == kind)
}
/// Whether the target has at least one artifact of the given kind.
pub fn has(&self, kind: ArtifactKind) -> bool {
self.artifacts.iter().any(|a| a.kind == kind)
}
/// The primary code artifact (git repo or source archive), if any.
pub fn code_artifact(&self) -> Option<&Artifact> {
self.artifacts
.iter()
.find(|a| matches!(a.kind, ArtifactKind::GitRepo | ArtifactKind::SourceArchive))
}
/// The live-URL artifact, if any.
pub fn live_url(&self) -> Option<&Artifact> {
self.first_of(ArtifactKind::LiveUrl)
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
fn sample_target() -> OnboardedTarget {
let mut t = OnboardedTarget::new("acme-web".to_string(), TargetType::WebApp);
t.artifacts.push(Artifact::git_repo(
"https://git.example.com/acme.git",
"main",
));
t.artifacts
.push(Artifact::live_url("https://acme.example.com"));
t
}
#[test]
fn onboarded_target_bson_round_trip() {
let t = sample_target();
let b = bson::to_bson(&t).expect("serialize");
let back: OnboardedTarget = bson::from_bson(b.clone()).expect("deserialize");
let b2 = bson::to_bson(&back).expect("re-serialize");
assert_eq!(b, b2);
}
#[test]
fn enum_display_is_snake_case() {
assert_eq!(
TargetType::FirmwareBareMetal.to_string(),
"firmware_bare_metal"
);
assert_eq!(TargetType::PlcSps.to_string(), "plc_sps");
assert_eq!(ArtifactKind::PlcProject.to_string(), "plc_project");
assert_eq!(ArtifactKind::MobilePackage.to_string(), "mobile_package");
}
#[test]
fn helpers_locate_artifacts() {
let t = sample_target();
assert!(t.has(ArtifactKind::GitRepo));
assert!(t.live_url().is_some());
assert!(t.code_artifact().is_some());
assert!(!t.has(ArtifactKind::FirmwareImage));
assert_eq!(
t.first_of(ArtifactKind::GitRepo).map(|a| a.kind),
Some(ArtifactKind::GitRepo)
);
}
#[test]
fn new_target_generates_webhook_secret() {
let t = OnboardedTarget::new("t".to_string(), TargetType::BackendService);
let secret = t.webhook_secret.expect("secret present");
assert_eq!(secret.len(), 32);
assert!(!secret.contains('-'));
}
#[test]
fn dast_auth_folds_into_artifact_auth() {
let dast = DastAuthConfig {
method: "bearer".to_string(),
login_url: Some("https://x/login".to_string()),
username: Some("user".to_string()),
password: Some("pw".to_string()),
token: Some("tok".to_string()),
headers: None,
};
let auth = ArtifactAuth::from(dast);
assert_eq!(auth.method, "bearer");
// Bearer token wins over password.
assert_eq!(auth.secret.as_deref(), Some("tok"));
assert_eq!(auth.login_url.as_deref(), Some("https://x/login"));
}
#[test]
fn each_artifact_gets_a_unique_id() {
let a = Artifact::firmware_image("fw.bin");
let b = Artifact::firmware_image("fw.bin");
assert_ne!(a.id, b.id);
}
}
+19 -1
View File
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
use super::repository::ScanTrigger; use super::repository::ScanTrigger;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ScanType { pub enum ScanType {
Sast, Sast,
@@ -16,6 +16,14 @@ pub enum ScanType {
SecretDetection, SecretDetection,
Lint, Lint,
CodeReview, CodeReview,
/// Static analysis of a firmware image (unpack + component CVE).
FirmwareStatic,
/// Control-logic security analysis of PLC / SPS programs.
PlcControlLogic,
/// Static analysis of a mobile package (APK / AAB / IPA).
MobileStatic,
/// Static analysis of a container image.
ContainerScan,
} }
impl std::fmt::Display for ScanType { impl std::fmt::Display for ScanType {
@@ -31,6 +39,10 @@ impl std::fmt::Display for ScanType {
Self::SecretDetection => write!(f, "secret_detection"), Self::SecretDetection => write!(f, "secret_detection"),
Self::Lint => write!(f, "lint"), Self::Lint => write!(f, "lint"),
Self::CodeReview => write!(f, "code_review"), Self::CodeReview => write!(f, "code_review"),
Self::FirmwareStatic => write!(f, "firmware_static"),
Self::PlcControlLogic => write!(f, "plc_control_logic"),
Self::MobileStatic => write!(f, "mobile_static"),
Self::ContainerScan => write!(f, "container_scan"),
} }
} }
} }
@@ -47,6 +59,8 @@ pub enum ScanRunStatus {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ScanPhase { pub enum ScanPhase {
ChangeDetection, ChangeDetection,
ArtifactIngest,
Classification,
Sast, Sast,
SbomGeneration, SbomGeneration,
CveScanning, CveScanning,
@@ -55,6 +69,10 @@ pub enum ScanPhase {
LintScanning, LintScanning,
CodeReview, CodeReview,
GraphBuilding, GraphBuilding,
FirmwareStatic,
PlcAnalysis,
MobileStatic,
ContainerScan,
LlmTriage, LlmTriage,
IssueCreation, IssueCreation,
DastScanning, DastScanning,
+379
View File
@@ -0,0 +1,379 @@
//! The scan-applicability matrix.
//!
//! Which scans are possible for a target is a function of its [`TargetType`] and
//! which [`ArtifactKind`]s are actually present: SAST needs code, DAST needs a
//! running URL, firmware-static analysis needs a firmware image, and so on. This
//! module encodes that as a table — one rule set per target type — and resolves
//! it against a concrete [`OnboardedTarget`] into a list of [`ScanOption`]s the
//! onboarding wizard and the scan pipeline both consume.
use crate::models::{ArtifactKind, OnboardedTarget, ScanType, TargetType};
/// What an artifact a scan needs in order to run.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtifactRequirement {
/// Source code — a git repo or a source archive.
Code,
/// A reachable running instance (live URL / endpoint).
RunningUrl,
/// A firmware image / binary blob.
Firmware,
/// A PLC project (PLCopen XML or Structured Text).
Plc,
/// A mobile package (APK / AAB / IPA).
Mobile,
/// A container image.
Container,
/// No specific artifact required.
Any,
}
/// A static rule: this scan applies to a target type, needs this artifact, and
/// defaults on/off. The rationale explains the entry to the user.
#[derive(Debug, Clone, Copy)]
pub struct ScanRule {
/// The scan this rule governs.
pub scan: ScanType,
/// Whether the scan is on by default (only when its artifact is present).
pub default_on: bool,
/// Human-readable explanation of what the scan does here.
pub rationale: &'static str,
/// The artifact the scan consumes.
pub requires: ArtifactRequirement,
}
impl ScanRule {
const fn new(
scan: ScanType,
default_on: bool,
rationale: &'static str,
requires: ArtifactRequirement,
) -> Self {
Self {
scan,
default_on,
rationale,
requires,
}
}
}
/// A resolved scan choice for a specific target: a rule intersected with the
/// artifacts actually present. `blocked_reason` is `Some` when the required
/// artifact is missing.
#[derive(Debug, Clone)]
pub struct ScanOption {
/// The scan.
pub scan: ScanType,
/// Whether to pre-select the scan (false when blocked).
pub default_on: bool,
/// Why the scan is offered.
pub rationale: String,
/// The artifact kind the scan needs, if any specific one.
pub required_artifact: Option<ArtifactKind>,
/// Set when the required artifact is absent, explaining the block.
pub blocked_reason: Option<String>,
}
/// The SAST umbrella: every static-analysis sub-scan that runs over source code.
fn sast_umbrella() -> Vec<ScanRule> {
use ArtifactRequirement::Code;
vec![
ScanRule::new(
ScanType::Sast,
true,
"Static analysis (Semgrep) over source",
Code,
),
ScanRule::new(
ScanType::Sbom,
true,
"Software bill of materials from source",
Code,
),
ScanRule::new(
ScanType::Cve,
true,
"Match dependencies against known CVEs",
Code,
),
ScanRule::new(
ScanType::SecretDetection,
true,
"Scan source for committed secrets",
Code,
),
ScanRule::new(ScanType::Lint, true, "Language linters over source", Code),
ScanRule::new(
ScanType::Gdpr,
true,
"GDPR data-handling pattern checks",
Code,
),
ScanRule::new(
ScanType::OAuth,
true,
"OAuth misconfiguration patterns",
Code,
),
ScanRule::new(
ScanType::Graph,
true,
"Build the code graph for impact analysis",
Code,
),
ScanRule::new(
ScanType::CodeReview,
false,
"LLM code review over changed source",
Code,
),
]
}
/// The rule set for a target type. Scans that are never applicable to a type are
/// simply absent (e.g. DAST is not listed for a PLC target).
pub fn rules_for(target_type: TargetType) -> Vec<ScanRule> {
use ArtifactRequirement::{Firmware, Mobile, Plc, RunningUrl};
match target_type {
TargetType::WebApp | TargetType::BackendService => {
let mut r = sast_umbrella();
r.push(ScanRule::new(
ScanType::Dast,
true,
"Dynamic scan of the running endpoint",
RunningUrl,
));
r
}
TargetType::DesktopApp => sast_umbrella(),
TargetType::AndroidApp | TargetType::IosApp => {
let mut r = sast_umbrella();
r.push(ScanRule::new(
ScanType::MobileStatic,
true,
"Static analysis of the mobile package (manifest, permissions, libs)",
Mobile,
));
r
}
TargetType::FirmwareBareMetal | TargetType::FirmwareRtos => {
let mut r = sast_umbrella();
r.push(ScanRule::new(
ScanType::FirmwareStatic,
true,
"Unpack and statically analyze the firmware image",
Firmware,
));
r.push(ScanRule::new(
ScanType::Sbom,
true,
"SBOM from the firmware image (binwalk / tramiton)",
Firmware,
));
r.push(ScanRule::new(
ScanType::Cve,
true,
"Match firmware components against known CVEs",
Firmware,
));
r
}
TargetType::EmbeddedLinuxYocto => {
let mut r = sast_umbrella();
r.push(ScanRule::new(
ScanType::FirmwareStatic,
true,
"EMBA / binwalk static analysis of the image",
Firmware,
));
r.push(ScanRule::new(
ScanType::Sbom,
true,
"SBOM from image layers / recipes",
Firmware,
));
r.push(ScanRule::new(
ScanType::Cve,
true,
"Match image components against known CVEs",
Firmware,
));
r.push(ScanRule::new(
ScanType::Dast,
false,
"Dynamic scan of exposed network services (if any)",
RunningUrl,
));
r
}
TargetType::PlcSps => vec![ScanRule::new(
ScanType::PlcControlLogic,
true,
"Control-logic security rules over the PLC program",
Plc,
)],
}
}
/// Whether an active penetration test is applicable to this target type.
///
/// Pentest runs as its own session (not a [`ScanType`] scan) and needs a
/// reachable running target, so it is offered only for the network-reachable
/// families.
pub fn supports_pentest(target_type: TargetType) -> bool {
matches!(
target_type,
TargetType::WebApp
| TargetType::BackendService
| TargetType::AndroidApp
| TargetType::IosApp
| TargetType::EmbeddedLinuxYocto
)
}
/// The representative artifact kind a requirement is satisfied by.
fn representative_kind(req: ArtifactRequirement) -> Option<ArtifactKind> {
match req {
ArtifactRequirement::Code => Some(ArtifactKind::GitRepo),
ArtifactRequirement::RunningUrl => Some(ArtifactKind::LiveUrl),
ArtifactRequirement::Firmware => Some(ArtifactKind::FirmwareImage),
ArtifactRequirement::Plc => Some(ArtifactKind::PlcProject),
ArtifactRequirement::Mobile => Some(ArtifactKind::MobilePackage),
ArtifactRequirement::Container => Some(ArtifactKind::ContainerImage),
ArtifactRequirement::Any => None,
}
}
/// Whether the target carries an artifact that satisfies the requirement.
fn requirement_satisfied(req: ArtifactRequirement, target: &OnboardedTarget) -> bool {
match req {
ArtifactRequirement::Code => target.code_artifact().is_some(),
ArtifactRequirement::RunningUrl => target.has(ArtifactKind::LiveUrl),
ArtifactRequirement::Firmware => target.has(ArtifactKind::FirmwareImage),
ArtifactRequirement::Plc => target.has(ArtifactKind::PlcProject),
ArtifactRequirement::Mobile => target.has(ArtifactKind::MobilePackage),
ArtifactRequirement::Container => target.has(ArtifactKind::ContainerImage),
ArtifactRequirement::Any => true,
}
}
/// Resolve the matrix for a concrete target into the scans it can run, marking
/// any whose required artifact is missing as blocked.
pub fn applicable_scans(target: &OnboardedTarget) -> Vec<ScanOption> {
rules_for(target.target_type)
.into_iter()
.map(|rule| {
let satisfied = requirement_satisfied(rule.requires, target);
let required_artifact = representative_kind(rule.requires);
let blocked_reason = if satisfied {
None
} else {
Some(match required_artifact {
Some(kind) => format!("no {kind} artifact provided"),
None => "required artifact missing".to_string(),
})
};
ScanOption {
scan: rule.scan,
default_on: rule.default_on && satisfied,
rationale: rule.rationale.to_string(),
required_artifact,
blocked_reason,
}
})
.collect()
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::models::{Artifact, PlcFormat};
fn target_with(target_type: TargetType, artifacts: Vec<Artifact>) -> OnboardedTarget {
let mut t = OnboardedTarget::new("t".to_string(), target_type);
t.artifacts = artifacts;
t
}
fn option<'a>(opts: &'a [ScanOption], scan: ScanType) -> Option<&'a ScanOption> {
opts.iter().find(|o| o.scan == scan)
}
#[test]
fn webapp_with_code_and_url_offers_sast_and_dast() {
let t = target_with(
TargetType::WebApp,
vec![
Artifact::git_repo("u", "main"),
Artifact::live_url("http://x"),
],
);
let opts = applicable_scans(&t);
let sast = option(&opts, ScanType::Sast).expect("sast offered");
assert!(sast.default_on && sast.blocked_reason.is_none());
let dast = option(&opts, ScanType::Dast).expect("dast offered");
assert!(dast.default_on && dast.blocked_reason.is_none());
}
#[test]
fn webapp_without_url_blocks_dast() {
let t = target_with(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]);
let opts = applicable_scans(&t);
let dast = option(&opts, ScanType::Dast).expect("dast listed");
assert!(!dast.default_on);
assert!(dast.blocked_reason.is_some());
assert_eq!(dast.required_artifact, Some(ArtifactKind::LiveUrl));
}
#[test]
fn firmware_offers_firmware_static_and_not_dast() {
let t = target_with(
TargetType::FirmwareBareMetal,
vec![Artifact::firmware_image("fw.bin")],
);
let opts = applicable_scans(&t);
let fw = option(&opts, ScanType::FirmwareStatic).expect("firmware static offered");
assert!(fw.default_on && fw.blocked_reason.is_none());
assert!(option(&opts, ScanType::Dast).is_none());
}
#[test]
fn plc_offers_only_control_logic() {
let t = target_with(
TargetType::PlcSps,
vec![Artifact::plc_project("p.xml", PlcFormat::PlcopenXml)],
);
let opts = applicable_scans(&t);
assert_eq!(opts.len(), 1);
assert_eq!(opts[0].scan, ScanType::PlcControlLogic);
assert!(opts[0].default_on);
}
#[test]
fn pentest_support_matches_reachable_families() {
assert!(supports_pentest(TargetType::WebApp));
assert!(supports_pentest(TargetType::BackendService));
assert!(!supports_pentest(TargetType::PlcSps));
assert!(!supports_pentest(TargetType::FirmwareBareMetal));
assert!(!supports_pentest(TargetType::DesktopApp));
}
#[test]
fn every_target_type_has_at_least_one_rule() {
for tt in [
TargetType::WebApp,
TargetType::BackendService,
TargetType::DesktopApp,
TargetType::AndroidApp,
TargetType::IosApp,
TargetType::FirmwareBareMetal,
TargetType::FirmwareRtos,
TargetType::EmbeddedLinuxYocto,
TargetType::PlcSps,
] {
assert!(!rules_for(tt).is_empty(), "{tt} has no rules");
}
}
}
+51
View File
@@ -0,0 +1,51 @@
//! The target-classification port.
//!
//! A [`TargetClassifier`] inspects a target's artifacts (and optionally their
//! ingested working directories) and proposes one or more [`ClassifierVerdict`]s
//! — a target type, a confidence, and the facts the decision rested on. Concrete
//! classifiers live in the agent (language/build-system fingerprinting, a
//! firmware detector backed by tramiton, etc.); a registry merges and ranks
//! their verdicts. This mirrors the [`crate::traits::Scanner`] port so the two
//! read the same way.
use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::CoreError;
use crate::models::{Artifact, DetectedFact, TargetType};
/// Everything a classifier needs to reason about a target.
pub struct ClassificationInput<'a> {
/// The artifacts declared for the target.
pub artifacts: &'a [Artifact],
/// Ingested working paths, keyed by [`Artifact::id`]. Absent for artifacts
/// with no on-disk form (e.g. a live URL).
pub working_paths: &'a HashMap<String, PathBuf>,
/// Free-form description of the target, if provided.
pub description: Option<&'a str>,
}
/// A single classifier's proposal for a target.
pub struct ClassifierVerdict {
/// The proposed target type.
pub target_type: TargetType,
/// Confidence in `[0.0, 1.0]`.
pub confidence: f32,
/// Facts that informed the proposal.
pub facts: Vec<DetectedFact>,
/// Human-readable explanation.
pub rationale: String,
}
/// A source of target-type classification.
#[allow(async_fn_in_trait)]
pub trait TargetClassifier: Send + Sync {
/// Stable identifier for this classifier (recorded in `detected_by`).
fn name(&self) -> &str;
/// Propose zero or more ranked verdicts for the given input.
async fn classify(
&self,
input: &ClassificationInput<'_>,
) -> Result<Vec<ClassifierVerdict>, CoreError>;
}
+2
View File
@@ -1,9 +1,11 @@
pub mod classifier;
pub mod dast_agent; pub mod dast_agent;
pub mod graph_builder; pub mod graph_builder;
pub mod issue_tracker; pub mod issue_tracker;
pub mod pentest_tool; pub mod pentest_tool;
pub mod scanner; pub mod scanner;
pub use classifier::{ClassificationInput, ClassifierVerdict, TargetClassifier};
pub use dast_agent::{DastAgent, DastContext, DiscoveredEndpoint, EndpointParameter}; pub use dast_agent::{DastAgent, DastContext, DiscoveredEndpoint, EndpointParameter};
pub use graph_builder::{LanguageParser, ParseOutput}; pub use graph_builder::{LanguageParser, ParseOutput};
pub use issue_tracker::IssueTracker; pub use issue_tracker::IssueTracker;