diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d80680..048bf147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +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. 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 e1ee578b..7be996f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,9 @@ 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. @@ -49,6 +51,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. @@ -59,6 +85,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..2c0232c8 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"; @@ -27,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", @@ -45,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()) @@ -65,6 +74,19 @@ 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() + .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 @@ -137,6 +159,7 @@ impl CredentialRegistry { } } +/// 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/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..1ba3c1d6 --- /dev/null +++ b/src/runtime_config.rs @@ -0,0 +1,362 @@ +//! 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. Secret values and their credentials-file locator +//! remain owned by the separate credential-bootstrap boundary. + +use crate::{AppConfig, CRED_ADMIN_TOKEN, CredentialRegistry}; +#[cfg(test)] +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. + pub bind_addr: String, + /// 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. + /// 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()) + } + + /// 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> { + 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 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, + state_path, + dnsbl_origin, + event_limit: parse_event_limit(event_limit_raw.as_deref())?, + rate_limit: parse_u32_env( + "RATE_LIMIT", + rate_limit_raw.as_deref(), + Self::DEFAULT_RATE_LIMIT, + )?, + rate_limit_window: parse_u64_env( + "RATE_LIMIT_WINDOW", + rate_limit_window_raw.as_deref(), + Self::DEFAULT_RATE_LIMIT_WINDOW, + )?, + max_body_bytes: parse_u64_env( + "MAX_BODY_BYTES", + 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 + .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 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> { + 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 => 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)] +/// 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(); + 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; + + /// Build a deterministic runtime snapshot from in-memory bootstrap pairs. + 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] + /// 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); + 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] + /// 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"), + ("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(); + + assert_eq!(config.bind_addr, "127.0.0.1:9090"); + 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] + /// 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!( + 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] + /// 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()); + assert!(runtime_from_pairs(&[("RATE_LIMIT_WINDOW", "0")]).is_err()); + assert!(runtime_from_pairs(&[("MAX_BODY_BYTES", "0")]).is_err()); + } + + #[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(), + 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] + /// 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); + assert!( + offenders.is_empty(), + "direct runtime env reads escaped bootstrap adapters: {offenders:?}" + ); + } + + #[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) + .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"), + "fn bypass() { let _ = std::env::var(\"BIND_ADDR\"); }", + ) + .unwrap(); + + assert_eq!( + direct_runtime_env_read_offenders(&temp), + vec![PathBuf::from("gateway/delivery/leak.rs")] + ); + std::fs::remove_dir_all(&temp).unwrap(); + } +} diff --git a/tests/runtime_configuration_bounds.rs b/tests/runtime_configuration_bounds.rs new file mode 100644 index 00000000..2e94314d --- /dev/null +++ b/tests/runtime_configuration_bounds.rs @@ -0,0 +1,25 @@ +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 + ); +}