Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ concurrency:

jobs:
fuzz:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/scorecard-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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
4 changes: 3 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,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
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
49 changes: 49 additions & 0 deletions tests/workflow_runner_contract.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//! 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"
);

let runners = workflow
.lines()
.filter_map(|line| line.trim().strip_prefix("runs-on:"))
.map(str::trim)
.collect::<Vec<_>>();
assert!(
!runners.is_empty(),
"{relative} must define at least one runs-on value"
);
assert!(
runners.iter().all(|runner| *runner == PINNED_UBUNTU_RUNNER),
"{relative} must use {PINNED_UBUNTU_RUNNER} for every runs-on value; found {runners:?}"
);
}
}
Loading