From aa548076fba4c122de50f8821e880e222fedf5fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:06:57 +0900 Subject: [PATCH 1/8] test(ci): require explicit hosted runner image --- tests/workflow_runner_contract.rs | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/workflow_runner_contract.rs diff --git a/tests/workflow_runner_contract.rs b/tests/workflow_runner_contract.rs new file mode 100644 index 0000000..f63eee0 --- /dev/null +++ b/tests/workflow_runner_contract.rs @@ -0,0 +1,39 @@ +//! Repository contract for deterministic GitHub-hosted runner selection. +//! +//! Wardnet's required pull-request workflows must not depend on GitHub's floating +//! `ubuntu-latest` alias. A floating image can change independently of the +//! repository and, during hosted-runner transitions, can leave exact-head jobs +//! queued before checkout. Pinning the Ubuntu image makes runner acquisition a +//! reviewed repository change while preserving GitHub-hosted execution. + +use std::fs; +use std::path::Path; + +const PINNED_UBUNTU_RUNNER: &str = "ubuntu-24.04"; +const FLOATING_UBUNTU_RUNNER: &str = "ubuntu-latest"; + +const RUNNER_BACKED_WORKFLOWS: &[&str] = &[ + ".github/workflows/ci.yml", + ".github/workflows/fuzz.yml", + ".github/workflows/scorecard-analysis.yml", +]; + +#[test] +fn runner_backed_workflows_pin_the_hosted_ubuntu_image() { + let repository = Path::new(env!("CARGO_MANIFEST_DIR")); + + for relative in RUNNER_BACKED_WORKFLOWS { + let path = repository.join(relative); + let workflow = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + + assert!( + !workflow.contains(FLOATING_UBUNTU_RUNNER), + "{relative} must not use the floating {FLOATING_UBUNTU_RUNNER} runner alias" + ); + assert!( + workflow.contains(&format!("runs-on: {PINNED_UBUNTU_RUNNER}")), + "{relative} must pin runner-backed jobs to {PINNED_UBUNTU_RUNNER}" + ); + } +} From 2770b57abebacae00b8624ba4213cdc032d9a9c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:07:11 +0900 Subject: [PATCH 2/8] fix(ci): pin hosted Ubuntu runner --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df09275..9409a89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: jobs: rust: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable From 33ebae0208382f7e6582bd88188050d42b5ddbcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:07:28 +0900 Subject: [PATCH 3/8] fix(ci): pin fuzz runner image --- .github/workflows/fuzz.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index ebcb3ce..b2a5366 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -23,7 +23,7 @@ concurrency: jobs: fuzz: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: From 2d41c4079f9a4465c3142a0aa2dd5895cb11f793 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:07:41 +0900 Subject: [PATCH 4/8] fix(ci): pin scorecard runner image --- .github/workflows/scorecard-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard-analysis.yml b/.github/workflows/scorecard-analysis.yml index dfa6420..2d147be 100644 --- a/.github/workflows/scorecard-analysis.yml +++ b/.github/workflows/scorecard-analysis.yml @@ -13,7 +13,7 @@ permissions: jobs: analysis: name: Scorecard Analysis - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read security-events: write From 9a159dde773a970166e1c38619fc2094d2273f79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:04:15 +0900 Subject: [PATCH 5/8] test(ci): validate every workflow runner declaration --- tests/workflow_runner_contract.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/workflow_runner_contract.rs b/tests/workflow_runner_contract.rs index f63eee0..f8a626e 100644 --- a/tests/workflow_runner_contract.rs +++ b/tests/workflow_runner_contract.rs @@ -31,9 +31,21 @@ fn runner_backed_workflows_pin_the_hosted_ubuntu_image() { !workflow.contains(FLOATING_UBUNTU_RUNNER), "{relative} must not use the floating {FLOATING_UBUNTU_RUNNER} runner alias" ); + + let runners = workflow + .lines() + .filter_map(|line| line.trim().strip_prefix("runs-on:")) + .map(str::trim) + .collect::>(); + assert!( + !runners.is_empty(), + "{relative} must define at least one runs-on value" + ); assert!( - workflow.contains(&format!("runs-on: {PINNED_UBUNTU_RUNNER}")), - "{relative} must pin runner-backed jobs to {PINNED_UBUNTU_RUNNER}" + runners + .iter() + .all(|runner| *runner == PINNED_UBUNTU_RUNNER), + "{relative} must use {PINNED_UBUNTU_RUNNER} for every runs-on value; found {runners:?}" ); } } From b663f9d200e5f385c7dd067d074940a02836c68e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:33:50 +0900 Subject: [PATCH 6/8] fix(ci): format runner contract test --- tests/workflow_runner_contract.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/workflow_runner_contract.rs b/tests/workflow_runner_contract.rs index f8a626e..ff42bad 100644 --- a/tests/workflow_runner_contract.rs +++ b/tests/workflow_runner_contract.rs @@ -42,9 +42,7 @@ fn runner_backed_workflows_pin_the_hosted_ubuntu_image() { "{relative} must define at least one runs-on value" ); assert!( - runners - .iter() - .all(|runner| *runner == PINNED_UBUNTU_RUNNER), + runners.iter().all(|runner| *runner == PINNED_UBUNTU_RUNNER), "{relative} must use {PINNED_UBUNTU_RUNNER} for every runs-on value; found {runners:?}" ); } From 9ee83d6aa8ec2906bc31b799580ef3dbe07d088b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 06:59:29 +0900 Subject: [PATCH 7/8] feat(support): add operability evidence surfaces (#146) Co-authored-by: OpenAI Codex --- CHANGELOG.md | 1 + README.md | 2 +- crates/waf-ids-core/src/lib.rs | 16 +++++ docs/commercial/20b-krw-sale-readiness.md | 4 +- docs/commercial/buyer-due-diligence.md | 5 ++ src/lib.rs | 76 +++++++++++++++++++---- 6 files changed, 89 insertions(+), 15 deletions(-) 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..8ab25b7 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,6 +35,8 @@ 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 +- the same `readyz` route-readiness snapshot that operators and load balancers consume +- the same Prometheus text currently served by `GET /metrics` - 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 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(); From 76037b8ae206ace8dab0e6622dfc9fc88c57deb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:22:13 +0900 Subject: [PATCH 8/8] docs(commercial): distinguish manifest entries from support snapshots --- docs/commercial/20b-krw-sale-readiness.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/commercial/20b-krw-sale-readiness.md b/docs/commercial/20b-krw-sale-readiness.md index 8ab25b7..8cfa5c1 100644 --- a/docs/commercial/20b-krw-sale-readiness.md +++ b/docs/commercial/20b-krw-sale-readiness.md @@ -35,13 +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 -- the same `readyz` route-readiness snapshot that operators and load balancers consume -- the same Prometheus text currently served by `GET /metrics` +- 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.