diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d8068..0d5521f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,4 @@ ### Operations - Documented administrator credential provisioning, rotation, rollout verification, rollback, evidence handling, and the boundary with the separate runtime-authentication fail-closed work tracked in issue #78. +- Added gateway readiness and Prometheus metrics evidence to `GET /api/support-bundle` so buyer/support handoff can compare the bundle directly against `/readyz` and `/metrics`. diff --git a/README.md b/README.md index d158758..92594bb 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The 2B KRW sale readiness baseline means the runtime can prove a buyer-facing pi - `GET /api/threat-feeds/freshness` returns fresh/stale feed evidence from TTL and last update time. - `GET /api/events.ndjson` exports events as newline-delimited JSON for SOC/SIEM ingestion tests. - `GET /api/commercial/evidence-manifest` returns the buyer-verifiable runtime, document, and deployment evidence map. -- `GET /api/support-bundle` returns health, KPIs, license, readiness, and evidence counts without admin secrets. +- `GET /api/support-bundle` returns health, gateway readiness, KPIs, license, readiness, Prometheus metrics text, and evidence counts without admin secrets. The formal acceptance criteria are in `docs/commercial/20b-krw-sale-readiness.md`. diff --git a/crates/waf-ids-core/src/lib.rs b/crates/waf-ids-core/src/lib.rs index f9673e0..dc56c5f 100644 --- a/crates/waf-ids-core/src/lib.rs +++ b/crates/waf-ids-core/src/lib.rs @@ -1233,6 +1233,14 @@ fn buyer_evidence_endpoints() -> Vec { "runtime health, persistence mode, DNSBL origin, and event retention limit", true, ), + buyer_evidence_endpoint( + "gateway_readiness", + "GET", + "/readyz", + "application/json", + "enabled-route readiness snapshot for probes, load balancers, and buyer validation", + true, + ), buyer_evidence_endpoint( "license", "GET", @@ -1289,6 +1297,14 @@ fn buyer_evidence_endpoints() -> Vec { "support and due-diligence handoff package without admin secrets", true, ), + buyer_evidence_endpoint( + "prometheus_metrics", + "GET", + "/metrics", + "text/plain; version=0.0.4; charset=utf-8", + "Prometheus exposition for KPI and readiness scraping evidence", + true, + ), buyer_evidence_endpoint( "dnsbl_zone", "GET", diff --git a/docs/commercial/20b-krw-sale-readiness.md b/docs/commercial/20b-krw-sale-readiness.md index 502188b..8cfa5c1 100644 --- a/docs/commercial/20b-krw-sale-readiness.md +++ b/docs/commercial/20b-krw-sale-readiness.md @@ -13,7 +13,7 @@ This project treats a 2B KRW sale as an enterprise due-diligence threshold, not 7. The product must expose SOC event export through `GET /api/events.ndjson`. 8. The product must retain threat feed status, imported HTTP indicators, DNSBL entries, gateway routes, and security events across restart when `WAF_IDS_STATE_PATH` is configured. 9. The readiness API must report blockers instead of returning a vague success state. -10. The support bundle API must return health, KPIs, license metadata, readiness checks, feed freshness, and evidence counts without secrets. +10. The support bundle API must return health, gateway readiness, KPIs, license metadata, readiness checks, Prometheus metrics text, feed freshness, and evidence counts without secrets. 11. The product must expose a buyer evidence manifest through `GET /api/commercial/evidence-manifest` so evaluators can verify required runtime APIs, committed documents, and deployment assets from one contract. 12. The product must expose management write audit logs through `GET /api/audit-logs` without persisting admin tokens or request bodies. 13. Docker, Compose, and Kubernetes deployment assets must exist for buyer lab validation. @@ -35,11 +35,15 @@ This project treats a 2B KRW sale as an enterprise due-diligence threshold, not `GET /api/commercial/evidence-manifest` returns the buyer validation map: - current readiness state and blockers +- a required-endpoint entry for `GET /readyz`, including its method, path, content type, and buyer-validation purpose +- a required-endpoint entry for `GET /metrics`, including its method, path, content type, and buyer-validation purpose - runtime counts for routes, indicators, DNSBL entries, feeds, fresh/stale feeds, and events - required evidence endpoints with method, path, content type, and what each endpoint proves - management audit-log count and the `GET /api/audit-logs` endpoint for successful admin writes - committed document paths and deployment assets that should be reviewed during procurement +The live `/readyz` snapshot and Prometheus exposition text are included in `GET /api/support-bundle`; the evidence manifest identifies the endpoints but does not duplicate those payloads. + ## Required Passing Checks - `license`: active or evaluation license metadata is present. diff --git a/docs/commercial/buyer-due-diligence.md b/docs/commercial/buyer-due-diligence.md index a940253..8a04a84 100644 --- a/docs/commercial/buyer-due-diligence.md +++ b/docs/commercial/buyer-due-diligence.md @@ -3,6 +3,7 @@ ## Product Evidence - Runtime health: `GET /healthz` +- Gateway readiness probe: `GET /readyz` - Web control plane: `GET /admin` - Gateway routes: `GET /api/routes` - Threat indicators: `GET /api/threats` @@ -17,6 +18,7 @@ - Threat feed status: `GET /api/threat-feeds` - Threat feed freshness: `GET /api/threat-feeds/freshness` - Support bundle: `GET /api/support-bundle` +- Prometheus operations metrics: `GET /metrics` ## Engineering Evidence @@ -27,6 +29,7 @@ - Authenticated management writes through `X-Admin-Token`. - Automated tests for management APIs, gateway scoring, DNSBL export, event NDJSON export, feed freshness, persistence failures, commercial readiness, and legacy state compatibility. - Buyer evidence manifest that lists required runtime endpoints, committed document paths, deployment assets, blockers, and runtime evidence counts from one API. +- Support bundle payload that includes the same route-readiness snapshot as `/readyz` and the same Prometheus exposition text as `/metrics`. - `scripts/smoke.sh` verifies a full local lifecycle including restart persistence. ## Security Review Packet @@ -58,6 +61,8 @@ Then inspect: ```bash curl -fsS http://127.0.0.1:8080/api/commercial/readiness curl -fsS http://127.0.0.1:8080/api/commercial/evidence-manifest +curl -fsS http://127.0.0.1:8080/readyz +curl -fsS http://127.0.0.1:8080/metrics curl -fsS http://127.0.0.1:8080/api/threat-feeds/freshness curl -fsS http://127.0.0.1:8080/api/events.ndjson curl -fsS http://127.0.0.1:8080/api/support-bundle diff --git a/src/lib.rs b/src/lib.rs index ab902ca..5c66ba8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -381,10 +381,12 @@ fn normalized_origin(origin: &str) -> String { pub struct SupportBundle { pub generated_at_unix: u64, pub health: HealthStatus, + pub gateway_readiness: GatewayReadinessStatus, pub kpis: SocKpiSnapshot, pub commercial: CommercialProfile, pub readiness: CommercialReadiness, pub evidence_manifest: BuyerEvidenceManifest, + pub prometheus_metrics_text: String, pub threat_feed_freshness: Vec, pub route_count: usize, pub threat_indicator_count: usize, @@ -406,6 +408,12 @@ pub struct HealthStatus { pub admin_auth_configured: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GatewayReadinessStatus { + pub ready: bool, + pub routes_enabled: usize, +} + const PHISHING_DATABASE_DEFAULT_FEED_ID: &str = "phishing-database-active"; const PHISHING_DATABASE_DEFAULT_SOURCE: &str = "https://github.com/Phishing-Database/Phishing.Database"; @@ -885,24 +893,16 @@ async fn version() -> Json { /// 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 readiness = { let data = state.inner.read().await; - data.routes.iter().filter(|route| route.enabled).count() + gateway_readiness_status(&data) }; - let ready = routes_enabled > 0; - let status = if ready { + let status = if readiness.ready { StatusCode::OK } else { StatusCode::SERVICE_UNAVAILABLE }; - ( - status, - Json(serde_json::json!({ - "ready": ready, - "routes_enabled": routes_enabled, - })), - ) - .into_response() + (status, Json(readiness)).into_response() } async fn admin_console() -> Html<&'static str> { @@ -1104,7 +1104,7 @@ async fn evaluate_request( async fn metrics(State(state): State) -> impl IntoResponse { let body = { let data = state.inner.read().await; - prometheus_exposition(&kpi_snapshot_at(&data, now_unix())) + prometheus_metrics_text(&data, now_unix()) }; ( [( @@ -1115,6 +1115,18 @@ async fn metrics(State(state): State) -> impl IntoResponse { ) } +fn gateway_readiness_status(data: &AppData) -> GatewayReadinessStatus { + let routes_enabled = data.routes.iter().filter(|route| route.enabled).count(); + GatewayReadinessStatus { + ready: routes_enabled > 0, + routes_enabled, + } +} + +fn prometheus_metrics_text(data: &AppData, now_unix: u64) -> String { + prometheus_exposition(&kpi_snapshot_at(data, now_unix)) +} + async fn get_commercial_license(State(state): State) -> Json { Json(state.inner.read().await.commercial.clone()) } @@ -2270,13 +2282,16 @@ fn is_loopback_host(host: &str) -> bool { async fn support_bundle(State(state): State) -> Json { let data = state.inner.read().await; let generated_at_unix = now_unix(); + let gateway_readiness = gateway_readiness_status(&data); Json(SupportBundle { generated_at_unix, health: state.health_status(), + gateway_readiness, 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), + prometheus_metrics_text: prometheus_metrics_text(&data, generated_at_unix), threat_feed_freshness: threat_feed_freshness_snapshot( &data.threat_feeds, generated_at_unix, @@ -4782,6 +4797,20 @@ mod tests { .iter() .any(|endpoint| endpoint.path == "/api/audit-logs" && endpoint.required_for_sale) ); + assert!( + manifest + .required_endpoints + .iter() + .any(|endpoint| endpoint.path == "/readyz" && endpoint.required_for_sale) + ); + assert!( + manifest + .required_endpoints + .iter() + .any(|endpoint| endpoint.path == "/metrics" + && endpoint.content_type == "text/plain; version=0.0.4; charset=utf-8" + && endpoint.required_for_sale) + ); assert!( manifest .document_paths @@ -4792,9 +4821,28 @@ mod tests { let support: SupportBundle = json_body(app_request(&app, empty_request(Method::GET, "/api/support-bundle")).await) .await; + let readyz: GatewayReadinessStatus = + json_body(app_request(&app, empty_request(Method::GET, "/readyz")).await).await; + let metrics_text = + body_text(app_request(&app, empty_request(Method::GET, "/metrics")).await).await; assert!(support.generated_at_unix > 0); + assert_eq!(support.gateway_readiness, readyz); 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 == "/readyz") + ); + assert!( + support + .evidence_manifest + .required_endpoints + .iter() + .any(|endpoint| endpoint.path == "/metrics") + ); assert!( support .evidence_manifest @@ -4813,6 +4861,8 @@ mod tests { assert_eq!(support.threat_feed_freshness.len(), 1); assert!(!support.threat_feed_freshness[0].stale); assert!(support.event_count >= 1); + assert_eq!(support.prometheus_metrics_text, metrics_text); + assert!(support.prometheus_metrics_text.contains("waf_ids_routes 1")); let persisted: AppData = serde_json::from_str(&fs::read_to_string(&path).await.unwrap()).unwrap();