From c186ab0fa0d165961f033ad7805c0e8f24b062dd Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Tue, 1 Sep 2026 14:31:12 +0900 Subject: [PATCH 01/15] refactor(config): centralize runtime bootstrap snapshot --- docs/architecture.md | 6 +- src/credentials.rs | 18 ++- src/lib.rs | 102 ++-------------- src/runtime_config.rs | 275 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 305 insertions(+), 96 deletions(-) create mode 100644 src/runtime_config.rs diff --git a/docs/architecture.md b/docs/architecture.md index e1ee578b..540bcdbd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,10 @@ flowchart LR ## Components -- `src/main.rs`: process startup and operator configuration from `BIND_ADDR`, `ADMIN_TOKEN`, `WAF_IDS_STATE_PATH`, `DNSBL_ORIGIN`, and `EVENT_LIMIT`. +- `src/runtime_config.rs`: runtime-configuration supporting subdomain bootstrap. Reads non-secret process settings from env once, validates them into an immutable `RuntimeConfiguration`, and passes that snapshot inward to `run_from_env`. +- `src/credentials.rs`: secret bootstrap adapter. Reads `ADMIN_TOKEN`, `ADMIN_TOKENS`, and optional `WAF_IDS_CREDENTIALS_PATH` only at the process edge, then exposes a process-local `CredentialRegistry`. + +- `src/main.rs`: thin process entrypoint and shutdown-signal installation. - `src/lib.rs`: Axum app, routing, management APIs, optional JSON persistence, gateway handler, upstream proxying, admin console, support bundle assembly, NDJSON event export, and in-crate HTTP tests. - `crates/waf-ids-core`: reusable domain models plus validation, upsert, scoring, DNSBL zone export, event retention, threat-feed freshness, KPI snapshot, and commercial readiness logic. - `/admin`: embedded web console. @@ -59,6 +62,7 @@ flowchart LR - Default bind address is localhost. - Remote management requires `ADMIN_TOKEN` plus external TLS and identity controls. +- Runtime configuration is loaded once at bootstrap and handed inward as an immutable snapshot; application code does not read operational env vars directly. - `WAF_IDS_STATE_PATH` enables JSON state persistence for standalone operation. Without it, the service uses seeded in-memory state. - File-backed writes use temporary sibling files followed by atomic rename. Management API mutations roll back in memory if the state file cannot be replaced. - Block mode is route-scoped to avoid global accidental enforcement. diff --git a/src/credentials.rs b/src/credentials.rs index bcc07e50..42266686 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -7,7 +7,11 @@ //! [`CredentialRegistry::get_credential`]. use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, io::ErrorKind, path::Path}; +use std::{ + collections::HashMap, + io::ErrorKind, + path::{Path, PathBuf}, +}; /// Well-known credentials loaded into the registry at bootstrap. pub const CRED_ADMIN_TOKEN: &str = "admin_token"; @@ -65,6 +69,18 @@ impl CredentialRegistry { .is_some_and(|v| !v.trim().is_empty()) } + pub fn bootstrap_from_env() -> Result<(Self, Option), String> { + let credentials_path = std::env::var("WAF_IDS_CREDENTIALS_PATH") + .ok() + .map(PathBuf::from); + let registry = Self::bootstrap_secrets( + credentials_path.as_deref(), + std::env::var("ADMIN_TOKEN").ok(), + std::env::var("ADMIN_TOKENS").ok(), + )?; + Ok((registry, credentials_path)) + } + /// Bootstrap secret-bearing credentials plus the optional KEV fetch override. /// /// Precedence: JSON credentials file (when present) wins per-key; missing diff --git a/src/lib.rs b/src/lib.rs index ab902cae..d256103a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,10 +41,12 @@ mod credentials; mod kev_import; mod misp_import; mod opencti_import; +mod runtime_config; mod stix_import; mod suricata_eve; mod taxii; pub use credentials::{CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, CredentialSource}; +pub use runtime_config::{RuntimeConfiguration, parse_event_limit, parse_u32_env, parse_u64_env}; #[derive(Clone)] pub struct AppState { @@ -3208,66 +3210,6 @@ initSocLlm(); "##; -/// 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 @@ -3276,43 +3218,15 @@ pub fn parse_u64_env( 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()); - // 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().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 runtime = RuntimeConfiguration::from_env()?; + let (credentials, _) = CredentialRegistry::bootstrap_from_env()?; + let config = runtime.app_config(&credentials); let admin_tokens = parse_admin_tokens( credentials .get_credential(CRED_ADMIN_TOKENS) .unwrap_or_default(), ); - 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 listener = tokio::net::TcpListener::bind(&runtime.bind_addr).await?; let local_addr = listener.local_addr()?; println!("waf-ids-ai-soc listening on http://{local_addr}"); // Flush so a supervising parent process (the e2e test) sees the readiness @@ -3321,10 +3235,10 @@ pub async fn run_from_env( 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_rate_limit(runtime.rate_limit, runtime.rate_limit_window) .with_admin_tokens(admin_tokens) .with_credentials_source(credentials.source()) - .with_max_body_size(max_body_bytes); + .with_max_body_size(runtime.max_body_bytes); let served = axum::serve(listener, build_app(state)) .with_graceful_shutdown(shutdown) .await; diff --git a/src/runtime_config.rs b/src/runtime_config.rs new file mode 100644 index 00000000..3e23e915 --- /dev/null +++ b/src/runtime_config.rs @@ -0,0 +1,275 @@ +//! Bootstrap adapter for non-secret runtime configuration. +//! +//! Environment variables remain an outer delivery concern. The runtime crate +//! consumes one validated snapshot instead of scattering `std::env::var` reads +//! across application code. + +use crate::{AppConfig, CRED_ADMIN_TOKEN, CredentialRegistry}; +use std::path::PathBuf; + +#[cfg(test)] +use std::sync::Mutex; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeConfiguration { + pub bind_addr: String, + pub credentials_path: Option, + pub state_path: Option, + pub dnsbl_origin: String, + pub event_limit: usize, + pub rate_limit: u32, + pub rate_limit_window: u64, + pub max_body_bytes: usize, +} + +impl RuntimeConfiguration { + pub const DEFAULT_BIND_ADDR: &'static str = "127.0.0.1:8080"; + pub const DEFAULT_RATE_LIMIT: u32 = 0; + pub const DEFAULT_RATE_LIMIT_WINDOW: u64 = 60; + pub const DEFAULT_MAX_BODY_BYTES: usize = 1_048_576; + + pub fn from_env() -> Result> { + Ok(Self { + bind_addr: std::env::var("BIND_ADDR") + .unwrap_or_else(|_| Self::DEFAULT_BIND_ADDR.to_string()), + credentials_path: std::env::var("WAF_IDS_CREDENTIALS_PATH") + .ok() + .map(PathBuf::from), + state_path: std::env::var("WAF_IDS_STATE_PATH").ok().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())?, + rate_limit: parse_u32_env( + "RATE_LIMIT", + std::env::var("RATE_LIMIT").ok().as_deref(), + Self::DEFAULT_RATE_LIMIT, + )?, + rate_limit_window: parse_u64_env( + "RATE_LIMIT_WINDOW", + std::env::var("RATE_LIMIT_WINDOW").ok().as_deref(), + Self::DEFAULT_RATE_LIMIT_WINDOW, + )?, + max_body_bytes: parse_u64_env( + "MAX_BODY_BYTES", + std::env::var("MAX_BODY_BYTES").ok().as_deref(), + Self::DEFAULT_MAX_BODY_BYTES as u64, + )? as usize, + }) + } + + pub fn app_config(&self, credentials: &CredentialRegistry) -> AppConfig { + AppConfig { + admin_token: credentials + .get_credential(CRED_ADMIN_TOKEN) + .map(str::to_owned), + state_path: self.state_path.clone(), + dnsbl_origin: self.dnsbl_origin.clone(), + event_limit: self.event_limit, + } + } +} + +/// 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. +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 zero", + ) + .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), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CRED_ADMIN_TOKENS, CredentialRegistry}; + + static ENV_GUARD: Mutex<()> = Mutex::new(()); + + fn clear_runtime_env() { + for name in [ + "BIND_ADDR", + "WAF_IDS_CREDENTIALS_PATH", + "WAF_IDS_STATE_PATH", + "DNSBL_ORIGIN", + "EVENT_LIMIT", + "RATE_LIMIT", + "RATE_LIMIT_WINDOW", + "MAX_BODY_BYTES", + "ADMIN_TOKEN", + "ADMIN_TOKENS", + ] { + unsafe { std::env::remove_var(name) }; + } + } + + #[test] + fn runtime_configuration_defaults_when_env_is_unset() { + let _guard = ENV_GUARD.lock().unwrap(); + clear_runtime_env(); + + let config = RuntimeConfiguration::from_env().unwrap(); + assert_eq!(config.bind_addr, RuntimeConfiguration::DEFAULT_BIND_ADDR); + assert_eq!(config.credentials_path, None); + assert_eq!(config.state_path, None); + assert_eq!(config.dnsbl_origin, AppConfig::DEFAULT_DNSBL_ORIGIN); + assert_eq!(config.event_limit, AppConfig::DEFAULT_EVENT_LIMIT); + assert_eq!(config.rate_limit, RuntimeConfiguration::DEFAULT_RATE_LIMIT); + assert_eq!( + config.rate_limit_window, + RuntimeConfiguration::DEFAULT_RATE_LIMIT_WINDOW + ); + assert_eq!( + config.max_body_bytes, + RuntimeConfiguration::DEFAULT_MAX_BODY_BYTES + ); + } + + #[test] + fn runtime_configuration_reads_current_env_snapshot() { + let _guard = ENV_GUARD.lock().unwrap(); + clear_runtime_env(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:9090"); + std::env::set_var("WAF_IDS_CREDENTIALS_PATH", "/tmp/creds.json"); + std::env::set_var("WAF_IDS_STATE_PATH", "/tmp/state.json"); + std::env::set_var("DNSBL_ORIGIN", "wardnet.example."); + std::env::set_var("EVENT_LIMIT", "25"); + std::env::set_var("RATE_LIMIT", "5"); + std::env::set_var("RATE_LIMIT_WINDOW", "30"); + std::env::set_var("MAX_BODY_BYTES", "4096"); + } + + let config = RuntimeConfiguration::from_env().unwrap(); + assert_eq!(config.bind_addr, "127.0.0.1:9090"); + assert_eq!( + config.credentials_path, + Some(PathBuf::from("/tmp/creds.json")) + ); + assert_eq!(config.state_path, Some(PathBuf::from("/tmp/state.json"))); + assert_eq!(config.dnsbl_origin, "wardnet.example."); + assert_eq!(config.event_limit, 25); + assert_eq!(config.rate_limit, 5); + assert_eq!(config.rate_limit_window, 30); + assert_eq!(config.max_body_bytes, 4096); + } + + #[test] + fn runtime_configuration_rejects_malformed_bounds() { + let _guard = ENV_GUARD.lock().unwrap(); + clear_runtime_env(); + unsafe { std::env::set_var("EVENT_LIMIT", "0") }; + assert!(RuntimeConfiguration::from_env().is_err()); + + clear_runtime_env(); + unsafe { std::env::set_var("RATE_LIMIT_WINDOW", "abc") }; + assert!(RuntimeConfiguration::from_env().is_err()); + } + + #[test] + fn runtime_configuration_builds_app_config_from_registry() { + let runtime = RuntimeConfiguration { + bind_addr: RuntimeConfiguration::DEFAULT_BIND_ADDR.to_string(), + credentials_path: None, + state_path: Some(PathBuf::from("state.json")), + dnsbl_origin: "dnsbl.example".to_string(), + event_limit: 42, + rate_limit: 7, + rate_limit_window: 90, + max_body_bytes: 1024, + }; + let credentials = CredentialRegistry::bootstrap_secrets( + None, + Some("secret".to_string()), + Some("tok:ops".to_string()), + ) + .unwrap(); + + let app = runtime.app_config(&credentials); + assert_eq!(app.admin_token.as_deref(), Some("secret")); + assert_eq!(app.state_path, Some(PathBuf::from("state.json"))); + assert_eq!(app.dnsbl_origin, "dnsbl.example"); + assert_eq!(app.event_limit, 42); + assert_eq!( + credentials.get_credential(CRED_ADMIN_TOKENS), + Some("tok:ops") + ); + } + + #[test] + fn runtime_env_reads_stay_in_bootstrap_adapters() { + let src_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut offenders = Vec::new(); + for entry in std::fs::read_dir(&src_dir).unwrap() { + let path = entry.unwrap().path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("rs") { + continue; + } + let rel = path + .strip_prefix(env!("CARGO_MANIFEST_DIR")) + .unwrap() + .to_string_lossy() + .into_owned(); + let source = std::fs::read_to_string(&path).unwrap(); + if (source.contains("std::env::var(") || source.contains("std::env::var_os(")) + && rel != "src/credentials.rs" + && rel != "src/runtime_config.rs" + { + offenders.push(rel); + } + } + assert!( + offenders.is_empty(), + "direct runtime env reads escaped bootstrap adapters: {offenders:?}" + ); + } +} From 64dc67b033d9754d9691a19122b99956eab9de5e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 17:59:22 +0900 Subject: [PATCH 02/15] fix(config): make bootstrap tests deterministic and recursive --- src/runtime_config.rs | 201 +++++++++++++++++++++++++----------------- 1 file changed, 120 insertions(+), 81 deletions(-) diff --git a/src/runtime_config.rs b/src/runtime_config.rs index 3e23e915..2f44d4db 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -5,58 +5,88 @@ //! across application code. use crate::{AppConfig, CRED_ADMIN_TOKEN, CredentialRegistry}; -use std::path::PathBuf; - -#[cfg(test)] -use std::sync::Mutex; +use std::path::{Path, PathBuf}; +/// Immutable bootstrap snapshot for non-secret Wardnet runtime settings. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeConfiguration { + /// Socket address the gateway binds during process startup. pub bind_addr: String, + /// Optional secret-registry bootstrap file selected at the process edge. pub credentials_path: Option, + /// Optional standalone state file used by the gateway process. pub state_path: Option, + /// DNSBL zone origin published by the gateway. pub dnsbl_origin: String, + /// Maximum retained security-event count. pub event_limit: usize, + /// Per-client request allowance for the local limiter; zero disables it. pub rate_limit: u32, + /// Local limiter fixed-window duration in seconds. pub rate_limit_window: u64, + /// Maximum accepted HTTP request body size in bytes. pub max_body_bytes: usize, } impl RuntimeConfiguration { + /// Default loopback listener for standalone operation. pub const DEFAULT_BIND_ADDR: &'static str = "127.0.0.1:8080"; + /// Default local limiter allowance; zero keeps rate limiting disabled. pub const DEFAULT_RATE_LIMIT: u32 = 0; + /// Default local limiter fixed-window duration in seconds. pub const DEFAULT_RATE_LIMIT_WINDOW: u64 = 60; + /// Default maximum accepted request body size in bytes. pub const DEFAULT_MAX_BODY_BYTES: usize = 1_048_576; + /// Load the process-edge runtime snapshot from environment bootstrap input. + /// + /// Environment variables are deliberately restricted to this delivery + /// adapter. They are bootstrap transport, not an application/domain + /// configuration authority; callers receive the validated snapshot below. pub fn from_env() -> Result> { + Self::from_lookup(|name| std::env::var(name).ok()) + } + + fn from_lookup( + mut lookup: impl FnMut(&str) -> Option, + ) -> Result> { + let bind_addr = lookup("BIND_ADDR") + .unwrap_or_else(|| Self::DEFAULT_BIND_ADDR.to_string()); + let credentials_path = lookup("WAF_IDS_CREDENTIALS_PATH").map(PathBuf::from); + let state_path = lookup("WAF_IDS_STATE_PATH").map(PathBuf::from); + let dnsbl_origin = lookup("DNSBL_ORIGIN") + .unwrap_or_else(|| AppConfig::DEFAULT_DNSBL_ORIGIN.to_string()); + let event_limit_raw = lookup("EVENT_LIMIT"); + let rate_limit_raw = lookup("RATE_LIMIT"); + let rate_limit_window_raw = lookup("RATE_LIMIT_WINDOW"); + let max_body_bytes_raw = lookup("MAX_BODY_BYTES"); + Ok(Self { - bind_addr: std::env::var("BIND_ADDR") - .unwrap_or_else(|_| Self::DEFAULT_BIND_ADDR.to_string()), - credentials_path: std::env::var("WAF_IDS_CREDENTIALS_PATH") - .ok() - .map(PathBuf::from), - state_path: std::env::var("WAF_IDS_STATE_PATH").ok().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())?, + bind_addr, + credentials_path, + state_path, + dnsbl_origin, + event_limit: parse_event_limit(event_limit_raw.as_deref())?, rate_limit: parse_u32_env( "RATE_LIMIT", - std::env::var("RATE_LIMIT").ok().as_deref(), + rate_limit_raw.as_deref(), Self::DEFAULT_RATE_LIMIT, )?, rate_limit_window: parse_u64_env( "RATE_LIMIT_WINDOW", - std::env::var("RATE_LIMIT_WINDOW").ok().as_deref(), + rate_limit_window_raw.as_deref(), Self::DEFAULT_RATE_LIMIT_WINDOW, )?, max_body_bytes: parse_u64_env( "MAX_BODY_BYTES", - std::env::var("MAX_BODY_BYTES").ok().as_deref(), + max_body_bytes_raw.as_deref(), Self::DEFAULT_MAX_BODY_BYTES as u64, )? as usize, }) } + /// Derive the application configuration from this non-secret snapshot and + /// the independently bootstrapped secret registry. pub fn app_config(&self, credentials: &CredentialRegistry) -> AppConfig { AppConfig { admin_token: credentials @@ -128,36 +158,54 @@ pub fn parse_u64_env( } } +#[cfg(test)] +fn direct_runtime_env_read_offenders(root: &Path) -> Vec { + fn visit(root: &Path, current: &Path, offenders: &mut Vec) { + for entry in std::fs::read_dir(current).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + visit(root, &path, offenders); + continue; + } + if path.extension().and_then(|ext| ext.to_str()) != Some("rs") { + continue; + } + let rel = path.strip_prefix(root).unwrap().to_path_buf(); + let source = std::fs::read_to_string(&path).unwrap(); + if (source.contains("std::env::var(") || source.contains("std::env::var_os(")) + && rel != Path::new("credentials.rs") + && rel != Path::new("runtime_config.rs") + { + offenders.push(rel); + } + } + } + + let mut offenders = Vec::new(); + visit(root, root, &mut offenders); + offenders.sort(); + offenders +} + #[cfg(test)] mod tests { use super::*; use crate::{CRED_ADMIN_TOKENS, CredentialRegistry}; + use std::collections::HashMap; - static ENV_GUARD: Mutex<()> = Mutex::new(()); - - fn clear_runtime_env() { - for name in [ - "BIND_ADDR", - "WAF_IDS_CREDENTIALS_PATH", - "WAF_IDS_STATE_PATH", - "DNSBL_ORIGIN", - "EVENT_LIMIT", - "RATE_LIMIT", - "RATE_LIMIT_WINDOW", - "MAX_BODY_BYTES", - "ADMIN_TOKEN", - "ADMIN_TOKENS", - ] { - unsafe { std::env::remove_var(name) }; - } + fn runtime_from_pairs( + pairs: &[(&str, &str)], + ) -> Result> { + let values = pairs + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect::>(); + RuntimeConfiguration::from_lookup(|name| values.get(name).cloned()) } #[test] - fn runtime_configuration_defaults_when_env_is_unset() { - let _guard = ENV_GUARD.lock().unwrap(); - clear_runtime_env(); - - let config = RuntimeConfiguration::from_env().unwrap(); + fn runtime_configuration_defaults_when_bootstrap_input_is_unset() { + let config = runtime_from_pairs(&[]).unwrap(); assert_eq!(config.bind_addr, RuntimeConfiguration::DEFAULT_BIND_ADDR); assert_eq!(config.credentials_path, None); assert_eq!(config.state_path, None); @@ -175,21 +223,19 @@ mod tests { } #[test] - fn runtime_configuration_reads_current_env_snapshot() { - let _guard = ENV_GUARD.lock().unwrap(); - clear_runtime_env(); - unsafe { - std::env::set_var("BIND_ADDR", "127.0.0.1:9090"); - std::env::set_var("WAF_IDS_CREDENTIALS_PATH", "/tmp/creds.json"); - std::env::set_var("WAF_IDS_STATE_PATH", "/tmp/state.json"); - std::env::set_var("DNSBL_ORIGIN", "wardnet.example."); - std::env::set_var("EVENT_LIMIT", "25"); - std::env::set_var("RATE_LIMIT", "5"); - std::env::set_var("RATE_LIMIT_WINDOW", "30"); - std::env::set_var("MAX_BODY_BYTES", "4096"); - } + fn runtime_configuration_reads_one_bootstrap_snapshot() { + let config = runtime_from_pairs(&[ + ("BIND_ADDR", "127.0.0.1:9090"), + ("WAF_IDS_CREDENTIALS_PATH", "/tmp/creds.json"), + ("WAF_IDS_STATE_PATH", "/tmp/state.json"), + ("DNSBL_ORIGIN", "wardnet.example."), + ("EVENT_LIMIT", "25"), + ("RATE_LIMIT", "5"), + ("RATE_LIMIT_WINDOW", "30"), + ("MAX_BODY_BYTES", "4096"), + ]) + .unwrap(); - let config = RuntimeConfiguration::from_env().unwrap(); assert_eq!(config.bind_addr, "127.0.0.1:9090"); assert_eq!( config.credentials_path, @@ -204,15 +250,9 @@ mod tests { } #[test] - fn runtime_configuration_rejects_malformed_bounds() { - let _guard = ENV_GUARD.lock().unwrap(); - clear_runtime_env(); - unsafe { std::env::set_var("EVENT_LIMIT", "0") }; - assert!(RuntimeConfiguration::from_env().is_err()); - - clear_runtime_env(); - unsafe { std::env::set_var("RATE_LIMIT_WINDOW", "abc") }; - assert!(RuntimeConfiguration::from_env().is_err()); + fn runtime_configuration_rejects_malformed_bounds_without_mutating_process_env() { + assert!(runtime_from_pairs(&[("EVENT_LIMIT", "0")]).is_err()); + assert!(runtime_from_pairs(&[("RATE_LIMIT_WINDOW", "abc")]).is_err()); } #[test] @@ -246,30 +286,29 @@ mod tests { } #[test] - fn runtime_env_reads_stay_in_bootstrap_adapters() { + fn runtime_env_reads_stay_in_bootstrap_adapters_recursively() { let src_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut offenders = Vec::new(); - for entry in std::fs::read_dir(&src_dir).unwrap() { - let path = entry.unwrap().path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("rs") { - continue; - } - let rel = path - .strip_prefix(env!("CARGO_MANIFEST_DIR")) - .unwrap() - .to_string_lossy() - .into_owned(); - let source = std::fs::read_to_string(&path).unwrap(); - if (source.contains("std::env::var(") || source.contains("std::env::var_os(")) - && rel != "src/credentials.rs" - && rel != "src/runtime_config.rs" - { - offenders.push(rel); - } - } + let offenders = direct_runtime_env_read_offenders(&src_dir); assert!( offenders.is_empty(), "direct runtime env reads escaped bootstrap adapters: {offenders:?}" ); } + + #[test] + fn nested_runtime_env_read_is_detected_by_architecture_fitness_gate() { + let temp = tempfile::tempdir().unwrap(); + let nested = temp.path().join("gateway").join("delivery"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write( + nested.join("leak.rs"), + "fn bypass() { let _ = std::env::var(\"BIND_ADDR\"); }", + ) + .unwrap(); + + assert_eq!( + direct_runtime_env_read_offenders(temp.path()), + vec![PathBuf::from("gateway/delivery/leak.rs")] + ); + } } From 7a5b41006b75485f0d09307be5697ed3501e856d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 18:04:29 +0900 Subject: [PATCH 03/15] fix(config): keep credential locator in secret bootstrap boundary --- src/runtime_config.rs | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/runtime_config.rs b/src/runtime_config.rs index 2f44d4db..acb7d7d3 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -2,7 +2,8 @@ //! //! Environment variables remain an outer delivery concern. The runtime crate //! consumes one validated snapshot instead of scattering `std::env::var` reads -//! across application code. +//! across application code. Secret values and their credentials-file locator +//! remain owned by the separate credential-bootstrap boundary. use crate::{AppConfig, CRED_ADMIN_TOKEN, CredentialRegistry}; use std::path::{Path, PathBuf}; @@ -12,8 +13,6 @@ use std::path::{Path, PathBuf}; pub struct RuntimeConfiguration { /// Socket address the gateway binds during process startup. pub bind_addr: String, - /// Optional secret-registry bootstrap file selected at the process edge. - pub credentials_path: Option, /// Optional standalone state file used by the gateway process. pub state_path: Option, /// DNSBL zone origin published by the gateway. @@ -43,6 +42,8 @@ impl RuntimeConfiguration { /// Environment variables are deliberately restricted to this delivery /// adapter. They are bootstrap transport, not an application/domain /// configuration authority; callers receive the validated snapshot below. + /// Secret bootstrap, including `WAF_IDS_CREDENTIALS_PATH`, is deliberately + /// excluded and remains solely owned by [`CredentialRegistry`]. pub fn from_env() -> Result> { Self::from_lookup(|name| std::env::var(name).ok()) } @@ -52,7 +53,6 @@ impl RuntimeConfiguration { ) -> Result> { let bind_addr = lookup("BIND_ADDR") .unwrap_or_else(|| Self::DEFAULT_BIND_ADDR.to_string()); - let credentials_path = lookup("WAF_IDS_CREDENTIALS_PATH").map(PathBuf::from); let state_path = lookup("WAF_IDS_STATE_PATH").map(PathBuf::from); let dnsbl_origin = lookup("DNSBL_ORIGIN") .unwrap_or_else(|| AppConfig::DEFAULT_DNSBL_ORIGIN.to_string()); @@ -63,7 +63,6 @@ impl RuntimeConfiguration { Ok(Self { bind_addr, - credentials_path, state_path, dnsbl_origin, event_limit: parse_event_limit(event_limit_raw.as_deref())?, @@ -207,7 +206,6 @@ mod tests { fn runtime_configuration_defaults_when_bootstrap_input_is_unset() { let config = runtime_from_pairs(&[]).unwrap(); assert_eq!(config.bind_addr, RuntimeConfiguration::DEFAULT_BIND_ADDR); - assert_eq!(config.credentials_path, None); assert_eq!(config.state_path, None); assert_eq!(config.dnsbl_origin, AppConfig::DEFAULT_DNSBL_ORIGIN); assert_eq!(config.event_limit, AppConfig::DEFAULT_EVENT_LIMIT); @@ -223,10 +221,9 @@ mod tests { } #[test] - fn runtime_configuration_reads_one_bootstrap_snapshot() { + fn runtime_configuration_reads_one_non_secret_bootstrap_snapshot() { let config = runtime_from_pairs(&[ ("BIND_ADDR", "127.0.0.1:9090"), - ("WAF_IDS_CREDENTIALS_PATH", "/tmp/creds.json"), ("WAF_IDS_STATE_PATH", "/tmp/state.json"), ("DNSBL_ORIGIN", "wardnet.example."), ("EVENT_LIMIT", "25"), @@ -237,10 +234,6 @@ mod tests { .unwrap(); assert_eq!(config.bind_addr, "127.0.0.1:9090"); - assert_eq!( - config.credentials_path, - Some(PathBuf::from("/tmp/creds.json")) - ); assert_eq!(config.state_path, Some(PathBuf::from("/tmp/state.json"))); assert_eq!(config.dnsbl_origin, "wardnet.example."); assert_eq!(config.event_limit, 25); @@ -249,6 +242,19 @@ mod tests { assert_eq!(config.max_body_bytes, 4096); } + #[test] + fn runtime_configuration_never_reads_secret_bootstrap_locator() { + let config = RuntimeConfiguration::from_lookup(|name| { + assert_ne!( + name, "WAF_IDS_CREDENTIALS_PATH", + "credential-file selection belongs exclusively to CredentialRegistry bootstrap" + ); + None + }) + .unwrap(); + assert_eq!(config.bind_addr, RuntimeConfiguration::DEFAULT_BIND_ADDR); + } + #[test] fn runtime_configuration_rejects_malformed_bounds_without_mutating_process_env() { assert!(runtime_from_pairs(&[("EVENT_LIMIT", "0")]).is_err()); @@ -259,7 +265,6 @@ mod tests { fn runtime_configuration_builds_app_config_from_registry() { let runtime = RuntimeConfiguration { bind_addr: RuntimeConfiguration::DEFAULT_BIND_ADDR.to_string(), - credentials_path: None, state_path: Some(PathBuf::from("state.json")), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 42, From 43d1b6e9122d8bb5cb882f8fc6e066c63b39ae45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 05:36:43 +0900 Subject: [PATCH 04/15] fix(ci): apply rustfmt to runtime configuration --- src/runtime_config.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/runtime_config.rs b/src/runtime_config.rs index acb7d7d3..1ee32083 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -51,11 +51,10 @@ impl RuntimeConfiguration { fn from_lookup( mut lookup: impl FnMut(&str) -> Option, ) -> Result> { - let bind_addr = lookup("BIND_ADDR") - .unwrap_or_else(|| Self::DEFAULT_BIND_ADDR.to_string()); + let bind_addr = lookup("BIND_ADDR").unwrap_or_else(|| Self::DEFAULT_BIND_ADDR.to_string()); let state_path = lookup("WAF_IDS_STATE_PATH").map(PathBuf::from); - let dnsbl_origin = lookup("DNSBL_ORIGIN") - .unwrap_or_else(|| AppConfig::DEFAULT_DNSBL_ORIGIN.to_string()); + let dnsbl_origin = + lookup("DNSBL_ORIGIN").unwrap_or_else(|| AppConfig::DEFAULT_DNSBL_ORIGIN.to_string()); let event_limit_raw = lookup("EVENT_LIMIT"); let rate_limit_raw = lookup("RATE_LIMIT"); let rate_limit_window_raw = lookup("RATE_LIMIT_WINDOW"); From b2c73d9f0fd86aed92975e30092725cbc80ae7d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 12:15:21 +0900 Subject: [PATCH 05/15] fix(config): scope Path import to architecture tests --- src/runtime_config.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/runtime_config.rs b/src/runtime_config.rs index 1ee32083..472d8de0 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -6,7 +6,9 @@ //! remain owned by the separate credential-bootstrap boundary. use crate::{AppConfig, CRED_ADMIN_TOKEN, CredentialRegistry}; -use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::path::Path; +use std::path::PathBuf; /// Immutable bootstrap snapshot for non-secret Wardnet runtime settings. #[derive(Debug, Clone, PartialEq, Eq)] From 46ede019c9f35e52ed255fdcca7927b14e67d461 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 12:15:34 +0900 Subject: [PATCH 06/15] fix(config): declare tempfile test dependency --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index b2ec231f..05a9af4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ waf-ids-core = { path = "crates/waf-ids-core" } [dev-dependencies] tower = { version = "0.5", features = ["util"] } +tempfile = "3" # Property-based testing (MIT OR Apache-2.0); mirrors the cargo-fuzz target for # parse_admin_tokens so its invariants stay green in primary CI. proptest = "1" From 19d82ca701b63725f7dc58ba47bbfcca58534dbb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 12:16:52 +0900 Subject: [PATCH 07/15] fix(config): keep architecture test dependency-free --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 05a9af4d..b2ec231f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,6 @@ waf-ids-core = { path = "crates/waf-ids-core" } [dev-dependencies] tower = { version = "0.5", features = ["util"] } -tempfile = "3" # Property-based testing (MIT OR Apache-2.0); mirrors the cargo-fuzz target for # parse_admin_tokens so its invariants stay green in primary CI. proptest = "1" From 9389a2d63f0875f688de5ef3adff1ce6b54e1ab1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 12:18:07 +0900 Subject: [PATCH 08/15] fix(config): make nested env-read regression hermetic --- src/runtime_config.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/runtime_config.rs b/src/runtime_config.rs index 472d8de0..3265d519 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -303,8 +303,15 @@ mod tests { #[test] fn nested_runtime_env_read_is_detected_by_architecture_fitness_gate() { - let temp = tempfile::tempdir().unwrap(); - let nested = temp.path().join("gateway").join("delivery"); + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp = std::env::temp_dir().join(format!( + "wardnet-runtime-config-{}-{unique}", + std::process::id() + )); + let nested = temp.join("gateway").join("delivery"); std::fs::create_dir_all(&nested).unwrap(); std::fs::write( nested.join("leak.rs"), @@ -313,8 +320,9 @@ mod tests { .unwrap(); assert_eq!( - direct_runtime_env_read_offenders(temp.path()), + direct_runtime_env_read_offenders(&temp), vec![PathBuf::from("gateway/delivery/leak.rs")] ); + std::fs::remove_dir_all(&temp).unwrap(); } } From c95c301a0a84a91dcc4688c1331c9f4f582e49ab Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Fri, 4 Sep 2026 13:00:20 +0900 Subject: [PATCH 09/15] docs(config): clarify runtime snapshot migration --- CHANGELOG.md | 1 + src/runtime_config.rs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d80680..6959d604 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. +- Clarified the public `RuntimeConfiguration` bootstrap contract after the September 2026 removal of `credentials_path`: external callers now keep credential-file selection in `CredentialRegistry` and use `RuntimeConfiguration` only for non-secret runtime settings. diff --git a/src/runtime_config.rs b/src/runtime_config.rs index 3265d519..b206b4fa 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -11,6 +11,14 @@ use std::path::Path; use std::path::PathBuf; /// Immutable bootstrap snapshot for non-secret Wardnet runtime settings. +/// +/// As of September 2026, this public type no longer carries a +/// `credentials_path` field. External callers that previously built +/// `RuntimeConfiguration` struct literals with that field must now bootstrap +/// secret-file selection through [`CredentialRegistry::bootstrap_from_env`] or +/// [`CredentialRegistry::bootstrap_secrets`] and keep +/// `RuntimeConfiguration` limited to non-secret listener, DNSBL, and retention +/// settings. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeConfiguration { /// Socket address the gateway binds during process startup. From d9c00aad54b444f96194f6bdcb5c13e23e5e5f86 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Fri, 4 Sep 2026 15:18:36 +0900 Subject: [PATCH 10/15] docs(config): ground bootstrap authority split --- CHANGELOG.md | 9 ++++++++- docs/architecture.md | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6959d604..048bf147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,4 +10,11 @@ ### 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. -- Clarified the public `RuntimeConfiguration` bootstrap contract after the September 2026 removal of `credentials_path`: external callers now keep credential-file selection in `CredentialRegistry` and use `RuntimeConfiguration` only for non-secret runtime settings. +- Clarified the public `RuntimeConfiguration` bootstrap contract after the + September 2026 removal of `credentials_path`: external callers now keep + credential-file selection in `CredentialRegistry` and use + `RuntimeConfiguration` only for non-secret runtime settings. This separation + follows least privilege and fail-safe bootstrap boundaries rather than + treating process env as long-lived application authority; see Saltzer and + Schroeder (1975), NIST SP 800-57 Part 1 Rev. 5, and the repository copy at + `docs/papers/nist-sp-800-57-part-1-rev-5.pdf`. diff --git a/docs/architecture.md b/docs/architecture.md index 540bcdbd..2c745be8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,6 +52,30 @@ flowchart LR - **DNSBL Serving**: Hickory DNS should serve authoritative DNSBL responses directly after zone export semantics stabilize. - **AI SOC**: AI triage should summarize events, map likely ATT&CK tactics, and recommend actions. Enforcement-changing recommendations require human approval. +### Further reading (runtime bootstrap authority separation) + +- Saltzer, J. H., & Schroeder, M. D. (1975). The protection of information in + computer systems. *Proceedings of the IEEE, 63*(9), 1278-1308. + https://doi.org/10.1109/PROC.1975.9939 - least privilege and fail-safe + defaults support keeping secret bootstrap in `CredentialRegistry` and making + application code consume one validated non-secret snapshot instead of reading + mutable environment variables throughout the runtime. +- Barker, E. (2020). *Recommendation for key management: Part 1-General* (NIST + Special Publication 800-57 Part 1 Rev. 5). National Institute of Standards + and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r5 - + [`papers/nist-sp-800-57-part-1-rev-5.pdf`](papers/nist-sp-800-57-part-1-rev-5.pdf). + The protected-storage, access-control, replacement, and recovery lifecycle + maps to Wardnet's split between secret bootstrap inputs and non-secret + listener, DNSBL, and retention settings. +- Krause, A., Klemmer, J. H., Huaman, N., Wermke, D., Acar, Y., & Fahl, S. + (2023). Pushed by accident: A mixed-methods study on strategies of handling + secret information in source code repositories. In *32nd USENIX Security + Symposium (USENIX Security 23)* (pp. 2527-2544). + https://www.usenix.org/conference/usenixsecurity23/presentation/krause - + operational evidence that repository-visible secrets remain a recurring + failure mode, which is why Wardnet keeps credential-file selection and admin + tokens out of `RuntimeConfiguration`. + ### Further reading (CISA KEV catalog pull) - CISA. (2021). *Binding Operational Directive 22-01: Reducing the Significant Risk of Known Exploited Vulnerabilities.* Cybersecurity and Infrastructure Security Agency. https://www.cisa.gov/known-exploited-vulnerabilities — the directive establishing the catalog's confirmed-active-exploitation inclusion criterion, which is why `kev_import.rs` treats catalog membership alone as at least `High` severity rather than deriving it from a numeric score. From 6b0219dad241cfea9969e7e05c11a9937131b36b Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Fri, 4 Sep 2026 16:23:41 +0900 Subject: [PATCH 11/15] docs(config): tighten bootstrap coverage notes --- docs/architecture.md | 1 - src/credentials.rs | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 2c745be8..7be996f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,6 @@ flowchart LR - `src/runtime_config.rs`: runtime-configuration supporting subdomain bootstrap. Reads non-secret process settings from env once, validates them into an immutable `RuntimeConfiguration`, and passes that snapshot inward to `run_from_env`. - `src/credentials.rs`: secret bootstrap adapter. Reads `ADMIN_TOKEN`, `ADMIN_TOKENS`, and optional `WAF_IDS_CREDENTIALS_PATH` only at the process edge, then exposes a process-local `CredentialRegistry`. - - `src/main.rs`: thin process entrypoint and shutdown-signal installation. - `src/lib.rs`: Axum app, routing, management APIs, optional JSON persistence, gateway handler, upstream proxying, admin console, support bundle assembly, NDJSON event export, and in-crate HTTP tests. - `crates/waf-ids-core`: reusable domain models plus validation, upsert, scoring, DNSBL zone export, event retention, threat-feed freshness, KPI snapshot, and commercial readiness logic. diff --git a/src/credentials.rs b/src/credentials.rs index 42266686..372b0854 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -31,6 +31,7 @@ pub enum CredentialSource { } impl CredentialSource { + /// Return the redacted provenance label exposed in health and evidence APIs. pub fn as_str(self) -> &'static str { match self { Self::File => "file", @@ -49,18 +50,22 @@ pub struct CredentialRegistry { } impl CredentialRegistry { + /// Create an empty registry for tests and bootstrap paths with no secrets. pub fn empty() -> Self { Self::default() } + /// Look up a credential by its well-known registry key. pub fn get_credential(&self, name: &str) -> Option<&str> { self.values.get(name).map(String::as_str) } + /// Report where the registry's admin credentials came from. pub fn source(&self) -> CredentialSource { self.source } + /// Return whether at least one administrator credential is present. pub fn has_admin_auth(&self) -> bool { self.get_credential(CRED_ADMIN_TOKEN) .is_some_and(|v| !v.is_empty()) @@ -69,6 +74,7 @@ impl CredentialRegistry { .is_some_and(|v| !v.trim().is_empty()) } + /// Bootstrap the registry from the process-edge delivery environment. pub fn bootstrap_from_env() -> Result<(Self, Option), String> { let credentials_path = std::env::var("WAF_IDS_CREDENTIALS_PATH") .ok() @@ -153,6 +159,7 @@ impl CredentialRegistry { } } +/// Convert a credential JSON value into a stored non-empty string. fn json_value_as_nonempty_string(value: &serde_json::Value) -> Option { match value { serde_json::Value::String(text) if !text.is_empty() => Some(text.clone()), From fd9e86bdbb6d0bd622f8378087eeb25462f02666 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Sat, 5 Sep 2026 03:17:41 +0900 Subject: [PATCH 12/15] docs(runtime): cover bootstrap helpers --- src/runtime_config.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/runtime_config.rs b/src/runtime_config.rs index b206b4fa..60b6529a 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -58,6 +58,10 @@ impl RuntimeConfiguration { Self::from_lookup(|name| std::env::var(name).ok()) } + /// Build the same runtime snapshot from an injected lookup source. + /// + /// Tests use this seam to prove startup consumes one immutable bootstrap + /// view without mutating or re-reading process environment state. fn from_lookup( mut lookup: impl FnMut(&str) -> Option, ) -> Result> { @@ -167,6 +171,8 @@ pub fn parse_u64_env( } #[cfg(test)] +/// Walk the Rust source tree and return any file that performs direct runtime +/// environment reads outside the approved bootstrap adapters. fn direct_runtime_env_read_offenders(root: &Path) -> Vec { fn visit(root: &Path, current: &Path, offenders: &mut Vec) { for entry in std::fs::read_dir(current).unwrap() { From 0f22aaffcf1db5f54190497f9fece5969cd89441 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:00:37 +0900 Subject: [PATCH 13/15] test(config): reject zero runtime resource bounds --- tests/runtime_configuration_bounds.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/runtime_configuration_bounds.rs diff --git a/tests/runtime_configuration_bounds.rs b/tests/runtime_configuration_bounds.rs new file mode 100644 index 00000000..cc1dac26 --- /dev/null +++ b/tests/runtime_configuration_bounds.rs @@ -0,0 +1,19 @@ +use waf_ids_ai_soc::parse_u64_env; + +#[test] +fn zero_runtime_resource_bounds_fail_closed() { + assert!( + parse_u64_env("RATE_LIMIT_WINDOW", Some("0"), 60).is_err(), + "a zero limiter window must be rejected at bootstrap rather than silently clamped later" + ); + assert!( + parse_u64_env("MAX_BODY_BYTES", Some("0"), 1_048_576).is_err(), + "a zero request-body budget must be rejected as invalid runtime authority" + ); +} + +#[test] +fn positive_runtime_resource_bounds_and_defaults_remain_valid() { + assert_eq!(parse_u64_env("RATE_LIMIT_WINDOW", Some("1"), 60).unwrap(), 1); + assert_eq!(parse_u64_env("MAX_BODY_BYTES", None, 1_048_576).unwrap(), 1_048_576); +} From d28a0119d4708b535dc04763dd51a11c835dba45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:01:27 +0900 Subject: [PATCH 14/15] fix(config): fail closed on zero runtime bounds --- src/runtime_config.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/runtime_config.rs b/src/runtime_config.rs index 60b6529a..0d6f981d 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -152,22 +152,31 @@ pub fn parse_u32_env( } } -/// Parse a `u64` environment value (already read as an optional string), -/// returning `default` when absent and a configuration error when malformed. +/// Parse a positive `u64` environment value (already read as an optional +/// string), returning `default` when absent and a configuration error when the +/// supplied value is malformed or zero. pub fn parse_u64_env( name: &str, raw: Option<&str>, default: u64, ) -> Result> { - match raw { - Some(raw) => Ok(raw.parse::().map_err(|error| { + let value = match raw { + Some(raw) => 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), + })?, + None => default, + }; + if value == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{name} must be greater than zero"), + ) + .into()); } + Ok(value) } #[cfg(test)] @@ -274,6 +283,8 @@ mod tests { fn runtime_configuration_rejects_malformed_bounds_without_mutating_process_env() { assert!(runtime_from_pairs(&[("EVENT_LIMIT", "0")]).is_err()); assert!(runtime_from_pairs(&[("RATE_LIMIT_WINDOW", "abc")]).is_err()); + assert!(runtime_from_pairs(&[("RATE_LIMIT_WINDOW", "0")]).is_err()); + assert!(runtime_from_pairs(&[("MAX_BODY_BYTES", "0")]).is_err()); } #[test] From 054c11aafe835e497d6149efb09f1ccdee9d03bd Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Sat, 5 Sep 2026 06:58:25 +0900 Subject: [PATCH 15/15] docs: raise runtime bootstrap doc coverage --- src/credentials.rs | 2 +- src/runtime_config.rs | 9 +++++++++ tests/runtime_configuration_bounds.rs | 10 ++++++++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/credentials.rs b/src/credentials.rs index 372b0854..2c0232c8 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -159,7 +159,7 @@ impl CredentialRegistry { } } -/// Convert a credential JSON value into a stored non-empty string. +/// Convert one credential JSON value into a stored non-empty string. fn json_value_as_nonempty_string(value: &serde_json::Value) -> Option { match value { serde_json::Value::String(text) if !text.is_empty() => Some(text.clone()), diff --git a/src/runtime_config.rs b/src/runtime_config.rs index 0d6f981d..1ba3c1d6 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -183,6 +183,7 @@ pub fn parse_u64_env( /// Walk the Rust source tree and return any file that performs direct runtime /// environment reads outside the approved bootstrap adapters. fn direct_runtime_env_read_offenders(root: &Path) -> Vec { + /// Recurse through nested source directories and collect violating files. fn visit(root: &Path, current: &Path, offenders: &mut Vec) { for entry in std::fs::read_dir(current).unwrap() { let path = entry.unwrap().path(); @@ -216,6 +217,7 @@ mod tests { use crate::{CRED_ADMIN_TOKENS, CredentialRegistry}; use std::collections::HashMap; + /// Build a deterministic runtime snapshot from in-memory bootstrap pairs. fn runtime_from_pairs( pairs: &[(&str, &str)], ) -> Result> { @@ -227,6 +229,7 @@ mod tests { } #[test] + /// Defaults apply when no non-secret bootstrap values are provided. fn runtime_configuration_defaults_when_bootstrap_input_is_unset() { let config = runtime_from_pairs(&[]).unwrap(); assert_eq!(config.bind_addr, RuntimeConfiguration::DEFAULT_BIND_ADDR); @@ -245,6 +248,7 @@ mod tests { } #[test] + /// Every non-secret bootstrap field is read from the injected snapshot. fn runtime_configuration_reads_one_non_secret_bootstrap_snapshot() { let config = runtime_from_pairs(&[ ("BIND_ADDR", "127.0.0.1:9090"), @@ -267,6 +271,7 @@ mod tests { } #[test] + /// Runtime bootstrap must not request the secret credentials-path selector. fn runtime_configuration_never_reads_secret_bootstrap_locator() { let config = RuntimeConfiguration::from_lookup(|name| { assert_ne!( @@ -280,6 +285,7 @@ mod tests { } #[test] + /// Invalid numeric bounds fail closed before the listener binds. fn runtime_configuration_rejects_malformed_bounds_without_mutating_process_env() { assert!(runtime_from_pairs(&[("EVENT_LIMIT", "0")]).is_err()); assert!(runtime_from_pairs(&[("RATE_LIMIT_WINDOW", "abc")]).is_err()); @@ -288,6 +294,7 @@ mod tests { } #[test] + /// AppConfig combines non-secret runtime values with registry-backed secrets. fn runtime_configuration_builds_app_config_from_registry() { let runtime = RuntimeConfiguration { bind_addr: RuntimeConfiguration::DEFAULT_BIND_ADDR.to_string(), @@ -317,6 +324,7 @@ mod tests { } #[test] + /// The architecture fitness gate rejects direct env reads outside adapters. fn runtime_env_reads_stay_in_bootstrap_adapters_recursively() { let src_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); let offenders = direct_runtime_env_read_offenders(&src_dir); @@ -327,6 +335,7 @@ mod tests { } #[test] + /// Nested source files are scanned so deep env reads cannot evade the gate. fn nested_runtime_env_read_is_detected_by_architecture_fitness_gate() { let unique = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/tests/runtime_configuration_bounds.rs b/tests/runtime_configuration_bounds.rs index cc1dac26..2e94314d 100644 --- a/tests/runtime_configuration_bounds.rs +++ b/tests/runtime_configuration_bounds.rs @@ -14,6 +14,12 @@ fn zero_runtime_resource_bounds_fail_closed() { #[test] fn positive_runtime_resource_bounds_and_defaults_remain_valid() { - assert_eq!(parse_u64_env("RATE_LIMIT_WINDOW", Some("1"), 60).unwrap(), 1); - assert_eq!(parse_u64_env("MAX_BODY_BYTES", None, 1_048_576).unwrap(), 1_048_576); + assert_eq!( + parse_u64_env("RATE_LIMIT_WINDOW", Some("1"), 60).unwrap(), + 1 + ); + assert_eq!( + parse_u64_env("MAX_BODY_BYTES", None, 1_048_576).unwrap(), + 1_048_576 + ); }