Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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