Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
16 changes: 16 additions & 0 deletions crates/waf-ids-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,14 @@ fn buyer_evidence_endpoints() -> Vec<BuyerEvidenceEndpoint> {
"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",
Expand Down Expand Up @@ -1289,6 +1297,14 @@ fn buyer_evidence_endpoints() -> Vec<BuyerEvidenceEndpoint> {
"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",
Expand Down
6 changes: 5 additions & 1 deletion docs/commercial/20b-krw-sale-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/commercial/buyer-due-diligence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
76 changes: 63 additions & 13 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThreatFeedFreshness>,
pub route_count: usize,
pub threat_indicator_count: usize,
Expand All @@ -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";
Expand Down Expand Up @@ -885,24 +893,16 @@ async fn version() -> Json<serde_json::Value> {
/// 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<AppState>) -> 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> {
Expand Down Expand Up @@ -1104,7 +1104,7 @@ async fn evaluate_request(
async fn metrics(State(state): State<AppState>) -> impl IntoResponse {
let body = {
let data = state.inner.read().await;
prometheus_exposition(&kpi_snapshot_at(&data, now_unix()))
prometheus_metrics_text(&data, now_unix())
};
(
[(
Expand All @@ -1115,6 +1115,18 @@ async fn metrics(State(state): State<AppState>) -> 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<AppState>) -> Json<CommercialProfile> {
Json(state.inner.read().await.commercial.clone())
}
Expand Down Expand Up @@ -2270,13 +2282,16 @@ fn is_loopback_host(host: &str) -> bool {
async fn support_bundle(State(state): State<AppState>) -> Json<SupportBundle> {
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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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();
Expand Down
Loading