cdfbb62f9d
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
Builds on PR M7.2-A. Every HTTP handler in compliance-agent/src/api/
now takes a TenantCtx extractor and pulls a tenant-scoped Database
from agent.db_pool.for_tenant(&ctx). The query bodies are unchanged —
`db.findings().find(doc! {...})` reads from the tenant's own physical
database, so the filter doc cannot leak data across tenants because
the wrong tenant's data is literally on a different db handle.
Changes
- New `dto::tenant_db(&agent, &tenant) -> Result<Database, StatusCode>`
helper. Every migrated handler calls it at the top of the body
instead of `let db = &agent.db;`. 500 on the rare pool failure;
4xx auth failures are already handled by the M7.1 status gate.
- New `api::server::inject_dev_tenant` middleware mounted only when
Keycloak is NOT configured. Synthesizes a TenantContext with
tenant_id = $DEV_TENANT_ID (default `dev`) so `cargo run` against
a bare Mongo + no KC still serves the API. Logged loudly as
"DO NOT use in any environment with real customer data".
- Test harness: TestServer mounts inject_dev_tenant so existing E2E
tests reach handlers; cleanup() now drops every <db_name>_*
per-tenant database, not just the legacy <db_name>.
Files migrated (handler count, all pass `cargo build`):
- chat.rs (3) — also rewires RagPipeline + EmbeddingStore to the
tenant DB's inner() so vector search is per-tenant
- dast.rs (5)
- findings.rs (5)
- graph.rs (7) — also rewires GraphStore inside trigger_build's
spawn to the tenant DB
- health.rs (1) — stats_overview migrated; public /health stays
un-scoped
- issues.rs (1)
- notifications.rs (5)
- pentest_handlers/session.rs (12) — both wizard + legacy paths,
plus pause/resume/stop/get_attack_chain/get_messages/
get_session_findings/lookup_repo. PentestOrchestrator now gets
the tenant DB clone in its spawn.
- pentest_handlers/export.rs (1) — fans out across sessions,
attack_chain_nodes, dast_findings, findings, sbom_entries,
graph_nodes from a single tenant_db acquisition
- pentest_handlers/stats.rs (1)
- pentest_handlers/stream.rs (1) — SSE handler verifies session
via the tenant DB before subscribing
- repos.rs (6)
- sbom.rs (5)
- scans.rs (1)
help_chat.rs has no DB queries and was skipped.
Test plan
- cargo fmt --all clean
- cargo clippy --workspace --exclude compliance-dashboard
-- -D warnings clean
- cargo test -p compliance-core --lib — 7 pass
- cargo test -p compliance-agent --lib — 228 pass
- cargo test -p compliance-agent --test tenant_isolation — 5 pass
(driver-level isolation still holds post-handler migration)
- cargo test -p compliance-agent --test tenant_status_middleware
— 6 pass
What's not yet migrated (PR-C / PR-D)
- scheduler.rs (6 sites), pipeline/orchestrator.rs (14),
pentest/orchestrator.rs (13), webhooks (gitea/github/gitlab),
trackers/jira.rs, pipeline/dedup.rs etc. — background paths
without a JWT-derived tenant context.
- agent.db is still in the ComplianceAgent struct as a transitional
handle for those paths. PR-D removes it once PR-C migrates the
background paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
236 lines
7.3 KiB
Rust
236 lines
7.3 KiB
Rust
use std::sync::Arc;
|
|
|
|
use axum::extract::{Extension, Path};
|
|
use axum::http::StatusCode;
|
|
use axum::response::IntoResponse;
|
|
use axum::Json;
|
|
use mongodb::bson::doc;
|
|
use serde::Deserialize;
|
|
|
|
use futures_util::StreamExt;
|
|
|
|
use compliance_core::models::dast::DastFinding;
|
|
use compliance_core::models::finding::Finding;
|
|
use compliance_core::models::pentest::*;
|
|
use compliance_core::models::sbom::SbomEntry;
|
|
use compliance_core::tenant_ctx::TenantCtx;
|
|
|
|
use crate::agent::ComplianceAgent;
|
|
|
|
use super::super::dto::{collect_cursor_async, tenant_db};
|
|
|
|
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ExportBody {
|
|
pub password: String,
|
|
/// Requester display name (from auth)
|
|
#[serde(default)]
|
|
pub requester_name: String,
|
|
/// Requester email (from auth)
|
|
#[serde(default)]
|
|
pub requester_email: String,
|
|
}
|
|
|
|
/// POST /api/v1/pentest/sessions/:id/export — Export an encrypted pentest report archive
|
|
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
|
pub async fn export_session_report(
|
|
Extension(agent): AgentExt,
|
|
tenant: TenantCtx,
|
|
Path(id): Path<String>,
|
|
Json(body): Json<ExportBody>,
|
|
) -> Result<axum::response::Response, (StatusCode, String)> {
|
|
let oid = mongodb::bson::oid::ObjectId::parse_str(&id)
|
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?;
|
|
let db = tenant_db(&agent, &tenant)
|
|
.await
|
|
.map_err(|s| (s, "failed to acquire tenant database".to_string()))?;
|
|
|
|
if body.password.len() < 8 {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
"Password must be at least 8 characters".to_string(),
|
|
));
|
|
}
|
|
|
|
// Fetch session
|
|
let session = db
|
|
.pentest_sessions()
|
|
.find_one(doc! { "_id": oid })
|
|
.await
|
|
.map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Database error: {e}"),
|
|
)
|
|
})?
|
|
.ok_or_else(|| (StatusCode::NOT_FOUND, "Session not found".to_string()))?;
|
|
|
|
// Resolve target name
|
|
let target = if let Ok(tid) = mongodb::bson::oid::ObjectId::parse_str(&session.target_id) {
|
|
db.dast_targets()
|
|
.find_one(doc! { "_id": tid })
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
} else {
|
|
None
|
|
};
|
|
let target_name = target
|
|
.as_ref()
|
|
.map(|t| t.name.clone())
|
|
.unwrap_or_else(|| "Unknown Target".to_string());
|
|
let target_url = target
|
|
.as_ref()
|
|
.map(|t| t.base_url.clone())
|
|
.unwrap_or_default();
|
|
|
|
// Fetch attack chain nodes
|
|
let nodes: Vec<AttackChainNode> = match db
|
|
.attack_chain_nodes()
|
|
.find(doc! { "session_id": &id })
|
|
.sort(doc! { "started_at": 1 })
|
|
.await
|
|
{
|
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
|
Err(_) => Vec::new(),
|
|
};
|
|
|
|
// Fetch DAST findings for this session, then deduplicate
|
|
let raw_findings: Vec<DastFinding> = match db
|
|
.dast_findings()
|
|
.find(doc! { "session_id": &id })
|
|
.sort(doc! { "severity": -1, "created_at": -1 })
|
|
.await
|
|
{
|
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
|
Err(_) => Vec::new(),
|
|
};
|
|
let raw_count = raw_findings.len();
|
|
let findings = crate::pipeline::dedup::dedup_dast_findings(raw_findings);
|
|
if findings.len() < raw_count {
|
|
tracing::info!(
|
|
"Deduped DAST findings for session {id}: {raw_count} → {}",
|
|
findings.len()
|
|
);
|
|
}
|
|
|
|
// Fetch SAST findings, SBOM, and code context for the linked repository
|
|
let repo_id = session
|
|
.repo_id
|
|
.clone()
|
|
.or_else(|| target.as_ref().and_then(|t| t.repo_id.clone()));
|
|
|
|
let (sast_findings, sbom_entries, code_context) = if let Some(ref rid) = repo_id {
|
|
let sast: Vec<Finding> = match db
|
|
.findings()
|
|
.find(doc! {
|
|
"repo_id": rid,
|
|
"status": { "$in": ["open", "triaged"] },
|
|
})
|
|
.sort(doc! { "severity": -1 })
|
|
.limit(100)
|
|
.await
|
|
{
|
|
Ok(mut cursor) => {
|
|
let mut results = Vec::new();
|
|
while let Some(Ok(f)) = cursor.next().await {
|
|
results.push(f);
|
|
}
|
|
results
|
|
}
|
|
Err(_) => Vec::new(),
|
|
};
|
|
|
|
let sbom: Vec<SbomEntry> = match db
|
|
.sbom_entries()
|
|
.find(doc! {
|
|
"repo_id": rid,
|
|
"known_vulnerabilities": { "$exists": true, "$ne": [] },
|
|
})
|
|
.limit(50)
|
|
.await
|
|
{
|
|
Ok(mut cursor) => {
|
|
let mut results = Vec::new();
|
|
while let Some(Ok(e)) = cursor.next().await {
|
|
results.push(e);
|
|
}
|
|
results
|
|
}
|
|
Err(_) => Vec::new(),
|
|
};
|
|
|
|
// Build code context from graph nodes
|
|
let code_ctx: Vec<CodeContextHint> = match db
|
|
.graph_nodes()
|
|
.find(doc! { "repo_id": rid, "is_entry_point": true })
|
|
.limit(50)
|
|
.await
|
|
{
|
|
Ok(mut cursor) => {
|
|
let mut nodes_vec = Vec::new();
|
|
while let Some(Ok(n)) = cursor.next().await {
|
|
let linked_vulns: Vec<String> = sast
|
|
.iter()
|
|
.filter(|f| f.file_path.as_deref() == Some(&n.file_path))
|
|
.map(|f| {
|
|
format!(
|
|
"[{}] {}: {} (line {})",
|
|
f.severity,
|
|
f.scanner,
|
|
f.title,
|
|
f.line_number.unwrap_or(0)
|
|
)
|
|
})
|
|
.collect();
|
|
nodes_vec.push(CodeContextHint {
|
|
endpoint_pattern: n.qualified_name.clone(),
|
|
handler_function: n.name.clone(),
|
|
file_path: n.file_path.clone(),
|
|
code_snippet: String::new(),
|
|
known_vulnerabilities: linked_vulns,
|
|
});
|
|
}
|
|
nodes_vec
|
|
}
|
|
Err(_) => Vec::new(),
|
|
};
|
|
|
|
(sast, sbom, code_ctx)
|
|
} else {
|
|
(Vec::new(), Vec::new(), Vec::new())
|
|
};
|
|
|
|
let config = session.config.clone();
|
|
let ctx = crate::pentest::report::ReportContext {
|
|
session,
|
|
target_name,
|
|
target_url,
|
|
findings,
|
|
attack_chain: nodes,
|
|
requester_name: if body.requester_name.is_empty() {
|
|
"Unknown".to_string()
|
|
} else {
|
|
body.requester_name
|
|
},
|
|
requester_email: body.requester_email,
|
|
config,
|
|
sast_findings,
|
|
sbom_entries,
|
|
code_context,
|
|
};
|
|
|
|
let report = crate::pentest::generate_encrypted_report(&ctx, &body.password)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
|
|
|
let response = serde_json::json!({
|
|
"archive_base64": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &report.archive),
|
|
"sha256": report.sha256,
|
|
"filename": format!("pentest-report-{id}.zip"),
|
|
});
|
|
|
|
Ok(Json(response).into_response())
|
|
}
|