Skip to content
Draft
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c186ab0
refactor(config): centralize runtime bootstrap snapshot
codex Sep 1, 2026
64dc67b
fix(config): make bootstrap tests deterministic and recursive
seonghobae Sep 1, 2026
7a5b410
fix(config): keep credential locator in secret bootstrap boundary
seonghobae Sep 1, 2026
492fba1
merge: synchronize runtime configuration refactor with protected main
seonghobae Sep 1, 2026
43d1b6e
fix(ci): apply rustfmt to runtime configuration
seonghobae Sep 1, 2026
b2c73d9
fix(config): scope Path import to architecture tests
seonghobae Sep 3, 2026
46ede01
fix(config): declare tempfile test dependency
seonghobae Sep 3, 2026
19d82ca
fix(config): keep architecture test dependency-free
seonghobae Sep 3, 2026
9389a2d
fix(config): make nested env-read regression hermetic
seonghobae Sep 3, 2026
c95c301
docs(config): clarify runtime snapshot migration
codex Sep 4, 2026
d9c00aa
docs(config): ground bootstrap authority split
codex Sep 4, 2026
6b0219d
docs(config): tighten bootstrap coverage notes
codex Sep 4, 2026
fd9e86b
docs(runtime): cover bootstrap helpers
codex Sep 4, 2026
45733f0
chore: integrate protected workflow foundation into runtime config
seonghobae Sep 4, 2026
0f22aaf
test(config): reject zero runtime resource bounds
seonghobae Sep 4, 2026
d28a011
fix(config): fail closed on zero runtime bounds
seonghobae Sep 4, 2026
054c11a
docs: raise runtime bootstrap doc coverage
codex Sep 4, 2026
a904558
merge(main): adopt protected anti-bot boundary into runtime configura…
seonghobae Sep 6, 2026
2ae4ee7
test(config): reject aliased runtime env reads
seonghobae Sep 7, 2026
13da592
fix(config): detect imported runtime env aliases
seonghobae Sep 7, 2026
520db29
test(config): expose std alias env-read bypass
seonghobae Sep 8, 2026
3d93314
fix(config): reject std-root runtime env aliases
seonghobae Sep 8, 2026
0f9ad5c
test(config): expose grouped std-root alias bypass
seonghobae Sep 8, 2026
7765da4
fix(config): reject extern-crate std root aliases
seonghobae Sep 8, 2026
e282257
fix(config): reject grouped std-root aliases
seonghobae Sep 8, 2026
93a51f9
style(config): apply rustfmt EOF newline
seonghobae Sep 8, 2026
87635bb
test(config): preserve blank state-path semantics
seonghobae Sep 11, 2026
d72a67a
fix(config): ignore blank state paths
seonghobae Sep 11, 2026
f371d28
docs(config): preserve blank state-path semantics
seonghobae Sep 11, 2026
a7eeb26
docs(changelog): record blank state-path repair
seonghobae Sep 11, 2026
0c678a9
fix(runtime-config): adopt protected credential bootstrap hardening
seonghobae Sep 11, 2026
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
29 changes: 28 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down
25 changes: 24 additions & 1 deletion src/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand All @@ -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())
Expand All @@ -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<PathBuf>), 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
Expand Down Expand Up @@ -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<String> {
match value {
serde_json::Value::String(text) if !text.is_empty() => Some(text.clone()),
Expand Down
102 changes: 8 additions & 94 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -3208,66 +3210,6 @@ initSocLlm();
</body>
</html>"##;

/// 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<usize, Box<dyn std::error::Error>> {
let value = match raw {
Some(raw) => raw.parse::<usize>().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<u32, Box<dyn std::error::Error>> {
match raw {
Some(raw) => Ok(raw.parse::<u32>().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<u64, Box<dyn std::error::Error>> {
match raw {
Some(raw) => Ok(raw.parse::<u64>().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
Expand All @@ -3276,43 +3218,15 @@ pub fn parse_u64_env(
pub async fn run_from_env(
shutdown: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
) -> Result<(), Box<dyn std::error::Error>> {
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()?;
Comment thread
seonghobae marked this conversation as resolved.
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
Expand All @@ -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;
Expand Down
Loading
Loading