1#![allow(deprecated)]
12
13use crate::threat_modeling::ThreatAnalyzer;
14use axum::{
15 extract::{Path, Query, State},
16 http::StatusCode,
17 response::Json,
18};
19use mockforge_foundation::threat_modeling_types::ThreatAssessment;
20use mockforge_openapi::OpenApiSpec;
21use serde::Deserialize;
22use std::sync::Arc;
23
24#[cfg(feature = "database")]
25use chrono::{DateTime, Utc};
26#[cfg(feature = "database")]
27use uuid::Uuid;
28
29#[cfg(feature = "database")]
30use crate::database::Database;
31
32#[cfg(feature = "database")]
34fn map_row_to_threat_assessment(
35 row: &sqlx::postgres::PgRow,
36) -> Result<ThreatAssessment, sqlx::Error> {
37 use mockforge_foundation::threat_modeling_types::{
38 AggregationLevel, RemediationSuggestion, ThreatCategory, ThreatFinding, ThreatLevel,
39 };
40 use sqlx::Row;
41
42 let workspace_id: Option<Uuid> = row.try_get("workspace_id")?;
44 let service_id: Option<String> = row.try_get("service_id")?;
45 let service_name: Option<String> = row.try_get("service_name")?;
46 let endpoint: Option<String> = row.try_get("endpoint")?;
47 let method: Option<String> = row.try_get("method")?;
48 let aggregation_level_str: String = row.try_get("aggregation_level")?;
49 let threat_level_str: String = row.try_get("threat_level")?;
50 let threat_score: f64 = row.try_get("threat_score")?;
51 let assessed_at: DateTime<Utc> = row.try_get("assessed_at")?;
52
53 let aggregation_level = match aggregation_level_str.as_str() {
55 "workspace" => AggregationLevel::Workspace,
56 "service" => AggregationLevel::Service,
57 "endpoint" => AggregationLevel::Endpoint,
58 _ => return Err(sqlx::Error::Decode("Invalid aggregation_level".into())),
59 };
60
61 let threat_level = match threat_level_str.as_str() {
63 "low" => ThreatLevel::Low,
64 "medium" => ThreatLevel::Medium,
65 "high" => ThreatLevel::High,
66 "critical" => ThreatLevel::Critical,
67 _ => return Err(sqlx::Error::Decode("Invalid threat_level".into())),
68 };
69
70 let threat_categories_json: serde_json::Value = row.try_get("threat_categories")?;
72 let threat_categories: Vec<ThreatCategory> =
73 serde_json::from_value(threat_categories_json).unwrap_or_default();
74
75 let findings_json: serde_json::Value = row.try_get("findings")?;
76 let findings: Vec<ThreatFinding> = serde_json::from_value(findings_json).unwrap_or_default();
77
78 let remediations_json: serde_json::Value = row.try_get("remediation_suggestions")?;
79 let remediation_suggestions: Vec<RemediationSuggestion> =
80 serde_json::from_value(remediations_json).unwrap_or_default();
81
82 Ok(ThreatAssessment {
83 workspace_id: workspace_id.map(|u| u.to_string()),
84 service_id,
85 service_name,
86 endpoint,
87 method,
88 aggregation_level,
89 threat_level,
90 threat_score,
91 threat_categories,
92 findings,
93 remediation_suggestions,
94 assessed_at,
95 })
96}
97
98#[derive(Clone)]
100pub struct ThreatModelingState {
101 pub analyzer: Arc<ThreatAnalyzer>,
103 pub webhook_configs: Vec<crate::incidents::integrations::WebhookConfig>,
105 #[cfg(feature = "database")]
107 pub database: Option<Database>,
108}
109
110impl ThreatModelingState {
111 pub fn new(
115 analyzer: Arc<ThreatAnalyzer>,
116 webhook_configs: Vec<crate::incidents::integrations::WebhookConfig>,
117 ) -> Self {
118 Self {
119 analyzer,
120 webhook_configs,
121 #[cfg(feature = "database")]
122 database: None,
123 }
124 }
125
126 #[cfg(feature = "database")]
128 pub fn with_database(mut self, database: Option<Database>) -> Self {
129 self.database = database;
130 self
131 }
132}
133
134#[cfg(feature = "database")]
138pub async fn get_workspace_threats(
139 State(state): State<ThreatModelingState>,
140 Path(workspace_id): Path<String>,
141) -> Result<Json<ThreatAssessment>, StatusCode> {
142 let pool = match state.database.as_ref().and_then(|db| db.pool()) {
143 Some(pool) => pool,
144 None => return Err(StatusCode::SERVICE_UNAVAILABLE),
145 };
146
147 let row = sqlx::query(
149 "SELECT * FROM contract_threat_assessments
150 WHERE workspace_id = $1 AND aggregation_level = 'workspace'
151 ORDER BY assessed_at DESC LIMIT 1",
152 )
153 .bind(&workspace_id)
154 .fetch_optional(pool)
155 .await
156 .map_err(|e| {
157 tracing::error!("Failed to query workspace threats: {}", e);
158 StatusCode::INTERNAL_SERVER_ERROR
159 })?;
160
161 match row {
162 Some(row) => match map_row_to_threat_assessment(&row) {
163 Ok(assessment) => Ok(Json(assessment)),
164 Err(e) => {
165 tracing::error!("Failed to map threat assessment: {}", e);
166 Err(StatusCode::INTERNAL_SERVER_ERROR)
167 }
168 },
169 None => Err(StatusCode::NOT_FOUND),
170 }
171}
172
173#[cfg(not(feature = "database"))]
177pub async fn get_workspace_threats(
178 State(_state): State<ThreatModelingState>,
179 Path(_workspace_id): Path<String>,
180) -> Result<Json<ThreatAssessment>, StatusCode> {
181 Err(StatusCode::SERVICE_UNAVAILABLE)
182}
183
184#[cfg(feature = "database")]
188pub async fn get_service_threats(
189 State(state): State<ThreatModelingState>,
190 Path(service_id): Path<String>,
191) -> Result<Json<ThreatAssessment>, StatusCode> {
192 let pool = match state.database.as_ref().and_then(|db| db.pool()) {
193 Some(pool) => pool,
194 None => return Err(StatusCode::SERVICE_UNAVAILABLE),
195 };
196
197 let row = sqlx::query(
198 "SELECT * FROM contract_threat_assessments
199 WHERE service_id = $1 AND aggregation_level = 'service'
200 ORDER BY assessed_at DESC LIMIT 1",
201 )
202 .bind(&service_id)
203 .fetch_optional(pool)
204 .await
205 .map_err(|e| {
206 tracing::error!("Failed to query service threats: {}", e);
207 StatusCode::INTERNAL_SERVER_ERROR
208 })?;
209
210 match row {
211 Some(row) => match map_row_to_threat_assessment(&row) {
212 Ok(assessment) => Ok(Json(assessment)),
213 Err(e) => {
214 tracing::error!("Failed to map threat assessment: {}", e);
215 Err(StatusCode::INTERNAL_SERVER_ERROR)
216 }
217 },
218 None => Err(StatusCode::NOT_FOUND),
219 }
220}
221
222#[cfg(not(feature = "database"))]
226pub async fn get_service_threats(
227 State(_state): State<ThreatModelingState>,
228 Path(_service_id): Path<String>,
229) -> Result<Json<ThreatAssessment>, StatusCode> {
230 Err(StatusCode::SERVICE_UNAVAILABLE)
231}
232
233#[cfg(feature = "database")]
237pub async fn get_endpoint_threats(
238 State(state): State<ThreatModelingState>,
239 Path(endpoint): Path<String>,
240 Query(params): Query<serde_json::Value>,
241) -> Result<Json<ThreatAssessment>, StatusCode> {
242 let pool = match state.database.as_ref().and_then(|db| db.pool()) {
243 Some(pool) => pool,
244 None => return Err(StatusCode::SERVICE_UNAVAILABLE),
245 };
246
247 let method = params.get("method").and_then(|v| v.as_str()).unwrap_or("%");
248
249 let row = sqlx::query(
250 "SELECT * FROM contract_threat_assessments
251 WHERE endpoint = $1 AND method LIKE $2 AND aggregation_level = 'endpoint'
252 ORDER BY assessed_at DESC LIMIT 1",
253 )
254 .bind(&endpoint)
255 .bind(method)
256 .fetch_optional(pool)
257 .await
258 .map_err(|e| {
259 tracing::error!("Failed to query endpoint threats: {}", e);
260 StatusCode::INTERNAL_SERVER_ERROR
261 })?;
262
263 match row {
264 Some(row) => match map_row_to_threat_assessment(&row) {
265 Ok(assessment) => Ok(Json(assessment)),
266 Err(e) => {
267 tracing::error!("Failed to map threat assessment: {}", e);
268 Err(StatusCode::INTERNAL_SERVER_ERROR)
269 }
270 },
271 None => Err(StatusCode::NOT_FOUND),
272 }
273}
274
275#[cfg(not(feature = "database"))]
279pub async fn get_endpoint_threats(
280 State(_state): State<ThreatModelingState>,
281 Path(_endpoint): Path<String>,
282 Query(_params): Query<serde_json::Value>,
283) -> Result<Json<ThreatAssessment>, StatusCode> {
284 Err(StatusCode::SERVICE_UNAVAILABLE)
285}
286
287#[derive(Debug, Deserialize)]
289pub struct AssessThreatsRequest {
290 pub spec: String,
292 pub workspace_id: Option<String>,
294 pub service_id: Option<String>,
296 pub service_name: Option<String>,
298 pub endpoint: Option<String>,
300 pub method: Option<String>,
302}
303
304pub async fn assess_threats(
308 State(state): State<ThreatModelingState>,
309 Json(request): Json<AssessThreatsRequest>,
310) -> Result<Json<ThreatAssessment>, StatusCode> {
311 let spec = match OpenApiSpec::from_string(&request.spec, None) {
313 Ok(spec) => spec,
314 Err(_) => return Err(StatusCode::BAD_REQUEST),
315 };
316
317 match state
319 .analyzer
320 .analyze_contract(
321 &spec,
322 request.workspace_id.clone(),
323 request.service_id.clone(),
324 request.service_name.clone(),
325 request.endpoint.clone(),
326 request.method.clone(),
327 )
328 .await
329 {
330 Ok(assessment) => {
331 #[cfg(feature = "database")]
333 if let Some(pool) = state.database.as_ref().and_then(|db| db.pool()) {
334 if let Err(e) = store_threat_assessment(pool, &assessment).await {
335 tracing::warn!("Failed to store threat assessment: {}", e);
336 }
337 }
338
339 trigger_threat_assessment_webhooks(&state.webhook_configs, &assessment).await;
341
342 Ok(Json(assessment))
343 }
344 Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
345 }
346}
347
348#[cfg(feature = "database")]
350async fn store_threat_assessment(
351 pool: &sqlx::PgPool,
352 assessment: &ThreatAssessment,
353) -> Result<(), sqlx::Error> {
354 let id = Uuid::new_v4();
355 let workspace_uuid = assessment.workspace_id.as_ref().and_then(|id| Uuid::parse_str(id).ok());
356 let service_uuid = assessment.service_id.as_ref().and_then(|id| Uuid::parse_str(id).ok());
357
358 sqlx::query(
360 r#"
361 INSERT INTO contract_threat_assessments (
362 id, workspace_id, service_id, service_name, endpoint, method, aggregation_level,
363 threat_level, threat_score, threat_categories, findings, remediation_suggestions,
364 assessed_at, last_updated, created_at
365 ) VALUES (
366 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
367 )
368 ON CONFLICT (workspace_id, service_id, endpoint, method, aggregation_level)
369 DO UPDATE SET
370 threat_level = EXCLUDED.threat_level,
371 threat_score = EXCLUDED.threat_score,
372 threat_categories = EXCLUDED.threat_categories,
373 findings = EXCLUDED.findings,
374 remediation_suggestions = EXCLUDED.remediation_suggestions,
375 assessed_at = EXCLUDED.assessed_at,
376 last_updated = EXCLUDED.last_updated
377 "#,
378 )
379 .bind(id)
380 .bind(workspace_uuid)
381 .bind(service_uuid)
382 .bind(assessment.service_name.as_deref())
383 .bind(assessment.endpoint.as_deref())
384 .bind(assessment.method.as_deref())
385 .bind(format!("{:?}", assessment.aggregation_level))
386 .bind(format!("{:?}", assessment.threat_level))
387 .bind(assessment.threat_score)
388 .bind(serde_json::to_value(&assessment.threat_categories).unwrap_or_default())
389 .bind(serde_json::to_value(&assessment.findings).unwrap_or_default())
390 .bind(serde_json::to_value(&assessment.remediation_suggestions).unwrap_or_default())
391 .bind(assessment.assessed_at)
392 .bind(Utc::now())
393 .bind(assessment.assessed_at)
394 .execute(pool)
395 .await?;
396
397 for finding in &assessment.findings {
399 let finding_id = Uuid::new_v4();
400 sqlx::query(
401 r#"
402 INSERT INTO threat_findings (
403 id, assessment_id, finding_type, severity, description, field_path,
404 context, remediation_suggestion, remediation_code_example, confidence,
405 ai_generated_remediation, detected_at, created_at, updated_at
406 ) VALUES (
407 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
408 )
409 "#,
410 )
411 .bind(finding_id)
412 .bind(id)
413 .bind(format!("{:?}", finding.finding_type))
414 .bind(format!("{:?}", finding.severity))
415 .bind(&finding.description)
416 .bind(finding.field_path.as_deref())
417 .bind(serde_json::to_value(&finding.context).unwrap_or_default())
418 .bind(None::<String>) .bind(None::<String>) .bind(finding.confidence)
421 .bind(false) .bind(Utc::now())
423 .bind(Utc::now())
424 .bind(Utc::now())
425 .execute(pool)
426 .await?;
427 }
428
429 Ok(())
430}
431
432#[cfg(feature = "database")]
436pub async fn list_findings(
437 State(state): State<ThreatModelingState>,
438 Query(_params): Query<serde_json::Value>,
439) -> Result<Json<serde_json::Value>, StatusCode> {
440 let pool = match state.database.as_ref().and_then(|db| db.pool()) {
441 Some(pool) => pool,
442 None => {
443 return Ok(Json(serde_json::json!({
444 "findings": []
445 })));
446 }
447 };
448
449 let rows = sqlx::query(
450 "SELECT tf.*, ta.workspace_id, ta.service_id, ta.endpoint, ta.method
451 FROM threat_findings tf
452 JOIN contract_threat_assessments ta ON tf.assessment_id = ta.id
453 ORDER BY tf.detected_at DESC LIMIT 100",
454 )
455 .fetch_all(pool)
456 .await
457 .map_err(|e| {
458 tracing::error!("Failed to query threat findings: {}", e);
459 StatusCode::INTERNAL_SERVER_ERROR
460 })?;
461
462 use sqlx::Row;
464 let mut findings = Vec::new();
465 for row in rows {
466 let finding_id: Uuid = row.try_get("id").map_err(|e| {
467 tracing::error!("Failed to get finding id from row: {}", e);
468 StatusCode::INTERNAL_SERVER_ERROR
469 })?;
470 let finding_type_str: String = row.try_get("finding_type").map_err(|e| {
471 tracing::error!("Failed to get finding_type from row: {}", e);
472 StatusCode::INTERNAL_SERVER_ERROR
473 })?;
474 let severity_str: String = row.try_get("severity").map_err(|e| {
475 tracing::error!("Failed to get severity from row: {}", e);
476 StatusCode::INTERNAL_SERVER_ERROR
477 })?;
478 let description: String = row.try_get("description").map_err(|e| {
479 tracing::error!("Failed to get description from row: {}", e);
480 StatusCode::INTERNAL_SERVER_ERROR
481 })?;
482 let field_path: Option<String> = row.try_get("field_path").map_err(|e| {
483 tracing::error!("Failed to get field_path from row: {}", e);
484 StatusCode::INTERNAL_SERVER_ERROR
485 })?;
486 let context_json: serde_json::Value = row.try_get("context").map_err(|e| {
487 tracing::error!("Failed to get context from row: {}", e);
488 StatusCode::INTERNAL_SERVER_ERROR
489 })?;
490 let confidence: f64 = row.try_get("confidence").map_err(|e| {
491 tracing::error!("Failed to get confidence from row: {}", e);
492 StatusCode::INTERNAL_SERVER_ERROR
493 })?;
494
495 use mockforge_foundation::threat_modeling_types::{ThreatCategory, ThreatLevel};
496 use std::collections::HashMap;
497
498 let _finding_type = match finding_type_str.as_str() {
499 "pii_exposure" => ThreatCategory::PiiExposure,
500 "dos_risk" => ThreatCategory::DoSRisk,
501 "error_leakage" => ThreatCategory::ErrorLeakage,
502 "schema_inconsistency" => ThreatCategory::SchemaInconsistency,
503 "unbounded_arrays" => ThreatCategory::UnboundedArrays,
504 "missing_rate_limits" => ThreatCategory::MissingRateLimits,
505 "stack_trace_leakage" => ThreatCategory::StackTraceLeakage,
506 "sensitive_data_exposure" => ThreatCategory::SensitiveDataExposure,
507 "insecure_schema_design" => ThreatCategory::InsecureSchemaDesign,
508 "missing_validation" => ThreatCategory::MissingValidation,
509 "excessive_optional_fields" => ThreatCategory::ExcessiveOptionalFields,
510 _ => continue, };
512
513 let _severity = match severity_str.as_str() {
514 "low" => ThreatLevel::Low,
515 "medium" => ThreatLevel::Medium,
516 "high" => ThreatLevel::High,
517 "critical" => ThreatLevel::Critical,
518 _ => continue, };
520
521 let context: HashMap<String, serde_json::Value> =
522 serde_json::from_value(context_json).unwrap_or_default();
523
524 findings.push(serde_json::json!({
525 "id": finding_id.to_string(),
526 "finding_type": finding_type_str,
527 "severity": severity_str,
528 "description": description,
529 "field_path": field_path,
530 "context": context,
531 "confidence": confidence,
532 }));
533 }
534
535 Ok(Json(serde_json::json!({
536 "findings": findings,
537 "total": findings.len()
538 })))
539}
540
541#[cfg(not(feature = "database"))]
545pub async fn list_findings(
546 State(_state): State<ThreatModelingState>,
547 Query(_params): Query<serde_json::Value>,
548) -> Result<Json<serde_json::Value>, StatusCode> {
549 Ok(Json(serde_json::json!({
550 "findings": []
551 })))
552}
553
554#[cfg(feature = "database")]
558pub async fn get_remediations(
559 State(state): State<ThreatModelingState>,
560 Query(_params): Query<serde_json::Value>,
561) -> Result<Json<serde_json::Value>, StatusCode> {
562 let pool = match state.database.as_ref().and_then(|db| db.pool()) {
563 Some(pool) => pool,
564 None => {
565 return Ok(Json(serde_json::json!({
566 "remediations": []
567 })));
568 }
569 };
570
571 let rows = sqlx::query(
573 "SELECT remediation_suggestions FROM contract_threat_assessments
574 WHERE remediation_suggestions IS NOT NULL AND jsonb_array_length(remediation_suggestions) > 0
575 ORDER BY assessed_at DESC LIMIT 50",
576 )
577 .fetch_all(pool)
578 .await
579 .map_err(|e| {
580 tracing::error!("Failed to query remediations: {}", e);
581 StatusCode::INTERNAL_SERVER_ERROR
582 })?;
583
584 use sqlx::Row;
586 let mut remediations = Vec::new();
587 for row in rows {
588 let remediations_json: serde_json::Value =
589 row.try_get("remediation_suggestions").map_err(|e| {
590 tracing::error!("Failed to get remediation_suggestions from row: {}", e);
591 StatusCode::INTERNAL_SERVER_ERROR
592 })?;
593 if let serde_json::Value::Array(remediation_array) = remediations_json {
594 for remediation in remediation_array {
595 remediations.push(remediation);
596 }
597 }
598 }
599
600 Ok(Json(serde_json::json!({
601 "remediations": remediations,
602 "total": remediations.len()
603 })))
604}
605
606#[cfg(not(feature = "database"))]
610pub async fn get_remediations(
611 State(_state): State<ThreatModelingState>,
612 Query(_params): Query<serde_json::Value>,
613) -> Result<Json<serde_json::Value>, StatusCode> {
614 Ok(Json(serde_json::json!({
615 "remediations": []
616 })))
617}
618
619async fn trigger_threat_assessment_webhooks(
621 webhook_configs: &[crate::incidents::integrations::WebhookConfig],
622 assessment: &ThreatAssessment,
623) {
624 use crate::incidents::integrations::send_webhook;
625 use serde_json::json;
626
627 for webhook in webhook_configs {
628 if !webhook.enabled {
629 continue;
630 }
631
632 let event_type = "threat.assessment.completed";
633 if !webhook.events.is_empty() && !webhook.events.contains(&event_type.to_string()) {
634 continue;
635 }
636
637 let payload = json!({
638 "event": event_type,
639 "assessment": {
640 "workspace_id": assessment.workspace_id,
641 "service_id": assessment.service_id,
642 "service_name": assessment.service_name,
643 "endpoint": assessment.endpoint,
644 "method": assessment.method,
645 "threat_level": format!("{:?}", assessment.threat_level),
646 "threat_score": assessment.threat_score,
647 "findings_count": assessment.findings.len(),
648 "assessed_at": assessment.assessed_at,
649 }
650 });
651
652 let webhook_clone = webhook.clone();
653 tokio::spawn(async move {
654 if let Err(e) = send_webhook(&webhook_clone, &payload).await {
655 tracing::warn!("Failed to send threat assessment webhook: {}", e);
656 }
657 });
658 }
659}
660
661pub fn threat_modeling_router(state: ThreatModelingState) -> axum::Router {
663 use axum::routing::{get, post};
664 use axum::Router;
665
666 Router::new()
667 .route("/api/v1/threats/workspace/{workspace_id}", get(get_workspace_threats))
668 .route("/api/v1/threats/service/{service_id}", get(get_service_threats))
669 .route("/api/v1/threats/endpoint/{endpoint}", get(get_endpoint_threats))
670 .route("/api/v1/threats/assess", post(assess_threats))
671 .route("/api/v1/threats/findings", get(list_findings))
672 .route("/api/v1/threats/remediations", get(get_remediations))
673 .with_state(state)
674}