From eb2a99dce4fff5f0da32052711388f6f5984351d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 23:12:13 +0900 Subject: [PATCH 01/17] feat(security): fail-closed destination policy for outbound HTTP One DestinationPolicy mediates gateway upstreams, threat-intel fetches, Clearfolio, SOC LLM, and the Coraza sidecar URL. Private, loopback, link-local, CGNAT, and metadata classes are denied unless DESTINATION_ALLOWLIST (or loopback development) permits them; DESTINATION_DENYLIST wins. Clients ignore ambient HTTP proxies and do not follow redirects. Refs #79. --- CHANGELOG.md | 1 + README.md | 1 + .../fail-closed-destination-policy.md | 41 ++ docs/product-technical-gap-baseline.md | 1 + docs/security/threat-model.md | 2 +- src/destination.rs | 502 ++++++++++++++++++ src/lib.rs | 125 ++++- 7 files changed, 660 insertions(+), 13 deletions(-) create mode 100644 docs/doctoring/fail-closed-destination-policy.md create mode 100644 src/destination.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f774bc..2fffca94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - Live `/gateway` transactions consult a Coraza sidecar when `CORAZA_WAF_URL` is set. The sidecar response is parsed with the existing Coraza audit adapter (OWASP CRS authority, not a hand-rolled engine). Sidecar outage is fail-closed when `PROVEN_ENGINE_FAIL_CLOSED` is true. `GET /api/waf/engine-status` and `/healthz.proven_engine` report the mode. +- Fail-closed destination policy on every outbound `http`/`https` call, including the Coraza sidecar URL (gateway upstream, threat-intel fetch, Clearfolio, SOC LLM). Private/loopback/metadata classes are denied unless `DESTINATION_ALLOWLIST` (or loopback development) permits them; `DESTINATION_DENYLIST` wins. Clients ignore ambient HTTP proxies and do not follow redirects. diff --git a/README.md b/README.md index d1587583..28198cf8 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Useful environment variables: - `BIND_ADDR`: listen address, default `127.0.0.1:8080` - `ADMIN_TOKEN`: optional write token for management writes via `X-Admin-Token` +- `DESTINATION_ALLOWLIST` / `DESTINATION_DENYLIST`: comma-separated hosts, `*.suffix`, or CIDRs for outbound `http`/`https`. Denylist wins. Loopback/private/metadata destinations are denied unless allowlisted (loopback development still permits loopback-class destinations). - `WAF_IDS_STATE_PATH`: optional JSON state path. When omitted, the service runs with seeded in-memory state. - `DNSBL_ORIGIN`: DNSBL zone origin, default `dnsbl.local` - `EVENT_LIMIT`: retained event count, default `1000`; must be greater than zero diff --git a/docs/doctoring/fail-closed-destination-policy.md b/docs/doctoring/fail-closed-destination-policy.md new file mode 100644 index 00000000..3de76ce5 --- /dev/null +++ b/docs/doctoring/fail-closed-destination-policy.md @@ -0,0 +1,41 @@ +# Doctoring — fail-closed destination policy + +This note grounds issue #79 (every outbound `http`/`https` call is mediated by +one destination-policy component). IEEE PDFs are not redistributed. + +## Adopted standards and literature + +OWASP Foundation. (n.d.). *Server-Side Request Forgery Prevention Cheat Sheet*. +https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html + +- **Design impact:** Parse URLs structurally, disable redirects, ignore ambient + proxy variables, and deny internal address classes unless an operator + allowlist names them. Deny-overrides (`DESTINATION_DENYLIST`) win. + +OWASP Foundation. (2025). *OWASP Application Security Verification Standard +5.0.0*. https://owasp.org/www-project-application-security-verification-standard/ + +- **Design impact:** ASVS V13 SSRF and V4 access control — administrative + route upserts and request-time proxying both call the same checker. + +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 + +- **Design impact:** Fail-safe defaults. A mixed public+private DNS answer set + is deny, not allow. Unresolvable hosts are deny. + +National Institute of Standards and Technology. (2022). *Secure Software +Development Framework (SSDF) version 1.1* (NIST SP 800-218). +https://doi.org/10.6028/NIST.SP.800-218 + +- **Design impact:** PW.1 — well-secured software. Kubernetes NetworkPolicy + remains defense in depth; application checks are mandatory. + +## Operator next action + +If a legitimate internal origin is denied, add it to `DESTINATION_ALLOWLIST` +(`host`, `*.suffix`, or `CIDR`) and restart. To block a previously allowed +name, put it in `DESTINATION_DENYLIST`. Loopback development still permits +loopback-class destinations so local fixtures work; production non-loopback +listeners use the strict class list. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index dc3db612..71acbf7f 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -25,6 +25,7 @@ not “waiting on review/CI time”. | PR | Title | Head | Checks | Reviews | Merge blocker | | --- | --- | --- | --- | --- | --- | +| stacked | feat(security): fail-closed destination policy for outbound HTTP | `feat/issue-79-destination-policy` on #95 | local lib tests green | Author this pass. | Open as stacked PR after this commit. Org 2-approval + self-author. Restores issue #79 unscoped from #94. | | [#95](https://github.com/ContextualWisdomLab/wardnet/pull/95) | feat(waf): consult Coraza sidecar on live gateway transactions | `54604c2` (`feat/issue-86-in-path-coraza`) | local fmt/test/clippy + two smokes green; GitHub Checks pending at open. Copilot review requested. | Author this pass. | Org 2-approval + self-author. Runtime gap #86 sidecar slice is this PR. | | [#94](https://github.com/ContextualWisdomLab/wardnet/pull/94) | fix(auth): fail closed without write-capable admin on public bind | `868a7e5` (`fix/issue-78-fail-closed-credentials`) | Restored to issue-#78-only scope (destination/#86 unscoped). Copilot review requested this hour. | Author `seonghobae`; Devin COMMENTED. | Org ruleset `18156473` 2-approval + self-author. Do not `--admin` merge. | | [#93](https://github.com/ContextualWisdomLab/wardnet/pull/93) | test(persistence): replace permission-based fault injection with a deterministic seam | `f77eb697` | rust + Security Scan green; **strix FAILURE** (job `97189711094`). Artifact `strix-reports` id `9493001688`. Root cause is org LiteLLM provider `openai-direct/gpt-5.6-luna` (0 vulns then fail-closed). Not a wardnet code finding. | Author `seonghobae`; Devin COMMENTED. | strix org-provider FAILURE + 2-approval + self-author. Do not rotate review-agent keys. | diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 8cf0a35b..022149af 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -25,7 +25,7 @@ | Unauthorized management write | Route takeover or false blocking | `X-Admin-Token` write gate; multi-token RBAC with actor labels and readonly role; audit log for successful writes | SSO/OIDC, mTLS or identity proxy, SCIM | | Malicious threat feed import | False positives or broad blocks | Validation, route-scoped enforcement | Source signing, feed confidence, staged promotion | | State file corruption | Startup failure or stale policy | JSON parse failure surfaces startup error | Database, backup, schema migration | -| Upstream SSRF through routes | Internal network exposure | Upstream scheme validation | Upstream allowlists, egress policy | +| Upstream SSRF through routes | Internal network exposure | Scheme validation plus fail-closed destination policy (`src/destination.rs`): deny loopback/private/link-local/metadata unless allowlisted; denylist wins; no ambient HTTP proxy; no redirects. Coraza sidecar URLs use the same policy. | Kubernetes NetworkPolicy egress as defense in depth | | Gateway DoS | Availability loss | Rust memory safety, event retention limit | Rate limits, body limits, async event sink | | DNSBL abuse | Reputation damage | Loopback response-code validation | Authoritative DNS service, signing, publisher workflow | | Secret disclosure | Admin compromise | Support bundle excludes admin token; secrets bootstrapped into credential registry (`WAF_IDS_CREDENTIALS_PATH` preferred over long-lived env); health exposes source label only | External secret manager / SSO, rotation, access review | diff --git a/src/destination.rs b/src/destination.rs new file mode 100644 index 00000000..3cf16c03 --- /dev/null +++ b/src/destination.rs @@ -0,0 +1,502 @@ +//! Fail-closed destination policy for every outbound URL (issue #79). +//! +//! One checker is used for gateway upstreams, threat-intel fetches, Clearfolio, +//! and SOC-LLM calls. Structural URL parse happens first; DNS answers are then +//! classified. Deny-overrides win over allowlists. Loopback-class destinations +//! are allowed only when [`DestinationPolicy::development`] is selected (the +//! process itself is loopback-only). + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs}; +use waf_ids_core::ip_in_network; + +/// Outcome of a destination-policy check. `reason` never includes credentials +/// or query strings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DestinationDecision { + pub allowed: bool, + pub reason: String, + pub host: String, + pub ips: Vec, +} + +/// Hostname, suffix, or CIDR entry parsed from an operator allow/deny list. +#[derive(Debug, Clone, PartialEq, Eq)] +enum ListEntry { + Hostname(String), + Suffix(String), + Cidr { network: IpAddr, prefix_len: u8 }, +} + +/// Fail-closed policy applied to outbound http/https URLs. +#[derive(Debug, Clone)] +pub struct DestinationPolicy { + /// When true, loopback destinations are an allowed class (local development). + allow_loopback_class: bool, + allow: Vec, + deny: Vec, +} + +impl DestinationPolicy { + /// Production default: deny loopback, private, link-local, metadata, and + /// other non-global unicast classes unless an allowlist entry matches. + pub fn production() -> Self { + Self { + allow_loopback_class: false, + allow: Vec::new(), + deny: Vec::new(), + } + } + + /// Loopback-listener development: same denies except loopback-class IPs + /// and `localhost` are permitted so in-process fixtures can run. + pub fn development() -> Self { + Self { + allow_loopback_class: true, + allow: Vec::new(), + deny: Vec::new(), + } + } + + /// Parse comma-separated allow/deny lists (`host`, `*.suffix`, `cidr`). + pub fn with_lists(mut self, allow: &str, deny: &str) -> Result { + self.allow = parse_list(allow)?; + self.deny = parse_list(deny)?; + Ok(self) + } + + /// Evaluate `raw` with `resolver`. Denied destinations return `Err`. + pub fn evaluate( + &self, + raw: &str, + resolver: &dyn HostResolver, + ) -> Result { + let parsed = parse_outbound_url(raw)?; + let host_allowlisted = self.allow.iter().any(|entry| match entry { + ListEntry::Hostname(_) | ListEntry::Suffix(_) => { + matching_host(entry, &parsed.host, &[]) + } + ListEntry::Cidr { .. } => false, + }); + let literal_loopback = parsed.host == "localhost" + || parsed + .host + .parse::() + .map(|ip| canonicalize_ip(ip).is_loopback()) + .unwrap_or(false); + if parsed.port != 80 + && parsed.port != 443 + && !host_allowlisted + && !(self.allow_loopback_class && literal_loopback) + { + return Err(format!( + "destination port {} is not a default http/https port", + parsed.port + )); + } + let mut ips = Vec::new(); + if parsed.host == "localhost" { + ips.push(IpAddr::V4(Ipv4Addr::LOCALHOST)); + } else if let Ok(ip) = parsed.host.parse::() { + ips.push(canonicalize_ip(ip)); + } else { + ips = resolver + .resolve(&parsed.host) + .map_err(|error| format!("destination DNS failed for {}: {error}", parsed.host))?; + if ips.is_empty() { + return Err(format!( + "destination {} resolved to no addresses", + parsed.host + )); + } + ips = ips.into_iter().map(canonicalize_ip).collect(); + } + + if let Some(entry) = self.matching_entry(&self.deny, &parsed.host, &ips) { + return Err(format!( + "destination {} denied by denylist ({})", + parsed.host, + entry_label(entry) + )); + } + + let allowlisted = self + .matching_entry(&self.allow, &parsed.host, &ips) + .is_some(); + + for ip in &ips { + if ip_is_denied_class(*ip) { + if allowlisted || (self.allow_loopback_class && ip.is_loopback()) { + continue; + } + return Err(format!( + "destination {} resolved to denied address class {ip}", + parsed.host + )); + } + } + + Ok(DestinationDecision { + allowed: true, + reason: format!("destination {} permitted", parsed.host), + host: parsed.host, + ips, + }) + } +} + +struct ParsedOutbound { + host: String, + port: u16, +} + +/// Resolve a hostname to A/AAAA addresses. Tests inject a fake. +pub trait HostResolver { + fn resolve(&self, host: &str) -> Result, String>; +} + +/// Operating-system DNS via [`ToSocketAddrs`]. +#[derive(Debug, Default, Clone, Copy)] +pub struct SystemHostResolver; + +impl HostResolver for SystemHostResolver { + fn resolve(&self, host: &str) -> Result, String> { + let addrs = (host, 0) + .to_socket_addrs() + .map_err(|error| error.to_string())?; + let mut ips = Vec::new(); + for addr in addrs { + let ip = canonicalize_ip(addr.ip()); + if !ips.contains(&ip) { + ips.push(ip); + } + } + Ok(ips) + } +} + +fn parse_outbound_url(raw: &str) -> Result { + let parsed = reqwest::Url::parse(raw).map_err(|_| "destination URL must be absolute")?; + match parsed.scheme() { + "http" | "https" => {} + other => { + return Err(format!("destination scheme {other} is not http or https")); + } + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("destination URL must not contain userinfo".to_string()); + } + if parsed.fragment().is_some() { + return Err("destination URL must not contain a fragment".to_string()); + } + let host = parsed + .host_str() + .ok_or_else(|| "destination URL host is required".to_string())?; + let host = host.trim_start_matches('[').trim_end_matches(']'); + if host.is_empty() || host == "." { + return Err("destination URL host is ambiguous".to_string()); + } + if host_is_ambiguous_literal(host) { + return Err(format!( + "destination host {host} uses a forbidden numeric spelling" + )); + } + let port = parsed.port_or_known_default().unwrap_or(0); + Ok(ParsedOutbound { + host: host.trim_end_matches('.').to_ascii_lowercase(), + port, + }) +} + +fn host_is_ambiguous_literal(host: &str) -> bool { + if host.chars().all(|c| c.is_ascii_digit()) { + return true; + } + let lowered = host.to_ascii_lowercase(); + if lowered.contains("0x") { + return true; + } + let octets: Vec<&str> = host.split('.').collect(); + octets.len() == 4 + && octets + .iter() + .all(|octet| !octet.is_empty() && octet.chars().all(|c| c.is_ascii_digit())) + && octets + .iter() + .any(|octet| octet.len() > 1 && octet.starts_with('0')) +} + +fn canonicalize_ip(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip), + IpAddr::V4(_) => ip, + } +} + +fn ip_is_denied_class(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() + || v4.is_unspecified() + || v4.is_private() + || v4.is_link_local() + || v4.is_broadcast() + || v4.is_multicast() + || v4.is_documentation() + || v4.octets()[0] == 0 + || is_metadata_v4(v4) + || ip_in_network(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 0)), 10, ip) + || ip_in_network(IpAddr::V4(Ipv4Addr::new(198, 18, 0, 0)), 15, ip) + } + IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unspecified() + || v6.is_multicast() + || v6.is_unicast_link_local() + || v6.is_unique_local() + || ip_in_network( + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0)), + 32, + ip, + ) + || v6 + .to_ipv4_mapped() + .is_some_and(|v4| ip_is_denied_class(IpAddr::V4(v4))) + } + } +} + +fn is_metadata_v4(v4: Ipv4Addr) -> bool { + v4.octets() == [169, 254, 169, 254] +} + +fn parse_list(raw: &str) -> Result, String> { + let mut out = Vec::new(); + for item in raw.split(',') { + let item = item.trim(); + if item.is_empty() { + continue; + } + if let Some((addr, prefix)) = item.split_once('/') { + let network: IpAddr = addr + .parse() + .map_err(|_| format!("invalid CIDR address {addr}"))?; + let prefix_len: u8 = prefix + .parse() + .map_err(|_| format!("invalid CIDR prefix {prefix}"))?; + out.push(ListEntry::Cidr { + network, + prefix_len, + }); + continue; + } + let host = item.trim_start_matches("*").trim_start_matches('.'); + let host = host.trim_end_matches('.').to_ascii_lowercase(); + if item.starts_with("*.") || item.starts_with('.') { + out.push(ListEntry::Suffix(format!(".{host}"))); + } else { + out.push(ListEntry::Hostname(host)); + } + } + Ok(out) +} + +fn matching_host(entry: &ListEntry, host: &str, ips: &[IpAddr]) -> bool { + match entry { + ListEntry::Hostname(expected) => host.eq_ignore_ascii_case(expected), + ListEntry::Suffix(suffix) => host.ends_with(suffix) && host != &suffix[1..], + ListEntry::Cidr { + network, + prefix_len, + } => ips + .iter() + .any(|ip| ip_in_network(*network, *prefix_len, *ip)), + } +} + +impl DestinationPolicy { + fn matching_entry<'a>( + &'a self, + list: &'a [ListEntry], + host: &str, + ips: &[IpAddr], + ) -> Option<&'a ListEntry> { + list.iter().find(|entry| matching_host(entry, host, ips)) + } +} + +fn entry_label(entry: &ListEntry) -> String { + match entry { + ListEntry::Hostname(h) => h.clone(), + ListEntry::Suffix(s) => format!("*{s}"), + ListEntry::Cidr { + network, + prefix_len, + } => format!("{network}/{prefix_len}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + struct MapResolver(HashMap>); + + impl HostResolver for MapResolver { + fn resolve(&self, host: &str) -> Result, String> { + self.0 + .get(host) + .cloned() + .ok_or_else(|| format!("no fixture for {host}")) + } + } + + fn resolver(pairs: &[(&str, &str)]) -> MapResolver { + let mut map = HashMap::new(); + for (host, ip) in pairs { + map.insert( + (*host).to_string(), + vec![ip.parse::().expect("fixture ip")], + ); + } + MapResolver(map) + } + + fn deny(policy: &DestinationPolicy, url: &str, resolver: &MapResolver, needle: &str) { + let err = policy.evaluate(url, resolver).unwrap_err(); + assert!( + err.contains(needle), + "expected {needle:?} in {err:?} for {url}" + ); + assert!( + !err.contains('@') && !err.contains("://user"), + "decision must not leak credentials: {err}" + ); + } + + #[test] + fn production_denies_ssrf_classes_and_ambiguous_spellings() { + let policy = DestinationPolicy::production(); + let dns = resolver(&[ + ("evil.example", "10.0.0.5"), + ("meta.example", "169.254.169.254"), + ("mixed.example", "203.0.113.10"), + ("cgnat.example", "100.64.0.1"), + ("ula.example", "fd12:3456:789a::1"), + ]); + deny(&policy, "http://127.0.0.1/", &dns, "denied address class"); + deny(&policy, "http://0.0.0.0/", &dns, "denied address class"); + deny( + &policy, + "http://192.168.1.10/", + &dns, + "denied address class", + ); + deny( + &policy, + "http://169.254.169.254/", + &dns, + "denied address class", + ); + deny(&policy, "http://[::1]/", &dns, "denied address class"); + deny(&policy, "http://[fe80::1]/", &dns, "denied address class"); + deny( + &policy, + "http://[::ffff:127.0.0.1]/", + &dns, + "denied address class", + ); + deny(&policy, "http://2130706433/", &dns, "denied"); + deny(&policy, "http://0x7f.0.0.1/", &dns, "denied"); + deny(&policy, "http://0177.0.0.1/", &dns, "denied"); + deny(&policy, "https://user:pass@example.com/", &dns, "userinfo"); + deny(&policy, "https://example.com/#frag", &dns, "fragment"); + deny(&policy, "ftp://example.com/", &dns, "not http or https"); + deny( + &policy, + "http://evil.example/", + &dns, + "denied address class", + ); + deny( + &policy, + "http://meta.example/", + &dns, + "denied address class", + ); + deny( + &policy, + "http://cgnat.example/", + &dns, + "denied address class", + ); + deny(&policy, "http://ula.example/", &dns, "denied address class"); + deny( + &policy, + "https://example.com:8443/", + &dns, + "not a default http/https port", + ); + } + + #[test] + fn mixed_public_and_denied_answers_fail_closed() { + let policy = DestinationPolicy::production(); + let mut map = HashMap::new(); + map.insert( + "split.example".to_string(), + vec!["8.8.8.8".parse().unwrap(), "10.1.1.1".parse().unwrap()], + ); + let dns = MapResolver(map); + deny( + &policy, + "https://split.example/", + &dns, + "denied address class 10.1.1.1", + ); + } + + #[test] + fn allowlist_permits_otherwise_denied_class_and_denylist_wins() { + let policy = DestinationPolicy::production() + .with_lists("10.0.0.0/8,*.internal.example", "blocked.internal.example") + .unwrap(); + let dns = resolver(&[ + ("svc.internal.example", "10.2.3.4"), + ("blocked.internal.example", "10.2.3.5"), + ("public.example", "8.8.8.8"), + ]); + policy + .evaluate("https://svc.internal.example/", &dns) + .unwrap(); + deny( + &policy, + "https://blocked.internal.example/", + &dns, + "denied by denylist", + ); + policy.evaluate("https://public.example/", &dns).unwrap(); + } + + #[test] + fn development_allows_loopback_but_still_denies_rfc1918() { + let policy = DestinationPolicy::development(); + let dns = resolver(&[("app.local", "127.0.0.1")]); + policy + .evaluate("http://127.0.0.1:80/healthz", &dns) + .unwrap(); + policy.evaluate("http://localhost/", &dns).unwrap(); + deny(&policy, "http://10.0.0.8/", &dns, "denied address class"); + } + + #[test] + fn trailing_dot_host_still_matches_allowlist() { + let policy = DestinationPolicy::production() + .with_lists("origin.example", "") + .unwrap(); + let dns = resolver(&[("origin.example", "8.8.4.4")]); + policy + .evaluate("https://origin.example./path", &dns) + .unwrap(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 4d19cde3..5f382c6a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,7 @@ pub use waf_ids_core::{ mod coraza_audit; mod credentials; +mod destination; mod misp_import; mod opencti_import; mod proven_engine; @@ -44,6 +45,7 @@ mod stix_import; mod suricata_eve; mod taxii; pub use credentials::{CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, CredentialSource}; +pub use destination::{DestinationPolicy, HostResolver, SystemHostResolver}; pub use proven_engine::{ProvenEngineConfig, ProvenEngineOutcome}; #[derive(Clone)] @@ -75,6 +77,9 @@ pub struct AppState { soc_llm: Option, /// In-path Coraza sidecar consult. Disabled unless `CORAZA_WAF_URL` is set. proven_engine: ProvenEngineConfig, + /// Fail-closed destination policy for every outbound http/https call. + destination: DestinationPolicy, + resolver: Arc, } /// Configuration for the optional LLM-backed SOC analysis. Points at an @@ -122,11 +127,8 @@ impl AppState { Self { inner: Arc::new(RwLock::new(data)), persist_lock: Arc::new(Mutex::new(())), - http: reqwest::Client::new(), - feed_http: reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("failed to build no-redirect feed client"), + http: outbound_http_client(), + feed_http: outbound_http_client(), admin_token: config.admin_token, admin_tokens: HashMap::new(), credentials_source: CredentialSource::None, @@ -140,6 +142,8 @@ impl AppState { clearfolio: None, soc_llm: None, proven_engine: ProvenEngineConfig::disabled(), + destination: DestinationPolicy::development(), + resolver: Arc::new(SystemHostResolver), } } @@ -170,6 +174,19 @@ impl AppState { self } + /// Replace the outbound destination policy. Builder-style. + pub fn with_destination_policy(mut self, policy: DestinationPolicy) -> Self { + self.destination = policy; + self + } + + /// Fail closed before any outbound http/https send. + fn assert_outbound(&self, url: &str) -> Result<(), String> { + self.destination + .evaluate(url, self.resolver.as_ref()) + .map(|_| ()) + } + /// Enable per-client-IP rate limiting: at most `limit` gateway requests per /// `window_secs`. `limit == 0` disables it (the default). Builder-style so /// callers keep using [`AppConfig`] unchanged. @@ -612,10 +629,11 @@ async fn clearfolio_submit( .mime_str("text/plain") .expect("text/plain is a valid MIME type"); let form = reqwest::multipart::Form::new().part("file", part); - let mut request = state - .http - .post(clearfolio_submit_url(&config.base_url)) - .multipart(form); + let submit_url = clearfolio_submit_url(&config.base_url); + if let Err(message) = state.assert_outbound(&submit_url) { + return error(StatusCode::BAD_REQUEST, message); + } + let mut request = state.http.post(submit_url).multipart(form); for (name, value) in clearfolio_tenant_headers(&config) { request = request.header(name, value); } @@ -644,9 +662,11 @@ async fn clearfolio_status( "Clearfolio integration is not configured", ); }; - let mut request = state - .http - .get(clearfolio_status_url(&config.base_url, &job_id)); + let status_url = clearfolio_status_url(&config.base_url, &job_id); + if let Err(message) = state.assert_outbound(&status_url) { + return error(StatusCode::BAD_REQUEST, message); + } + let mut request = state.http.get(status_url); for (name, value) in clearfolio_tenant_headers(&config) { request = request.header(name, value); } @@ -785,6 +805,9 @@ async fn soc_analyze( "{}/v1/chat/completions", config.base_url.trim_end_matches('/') ); + if let Err(message) = state.assert_outbound(&endpoint) { + return error(StatusCode::BAD_REQUEST, message); + } let response = state .http .post(endpoint) @@ -872,6 +895,11 @@ async fn create_route( if let Err(message) = validate_route(&route) { return error(StatusCode::BAD_REQUEST, message); } + if (route.upstream.starts_with("http://") || route.upstream.starts_with("https://")) + && let Err(message) = state.assert_outbound(&route.upstream) + { + return error(StatusCode::BAD_REQUEST, message); + } let actor = audit_actor(&state, &headers); match state @@ -1595,6 +1623,7 @@ async fn fetch_taxii_objects( ) -> Result { use futures_util::StreamExt; + state.assert_outbound(url)?; let mut request = state .feed_http .get(url) @@ -2309,6 +2338,9 @@ async fn consult_proven_engine( else { return ProvenEngineOutcome::NotConfigured; }; + if let Err(reason) = state.assert_outbound(&url) { + return ProvenEngineOutcome::Unavailable { reason }; + } proven_engine::evaluate_sidecar(&state.http, &url, method, request_uri, body_text, client_ip) .await } @@ -2347,6 +2379,7 @@ async fn proxy_request( body: Bytes, ) -> Result { let target = upstream_target(route, path, query)?; + state.assert_outbound(&target)?; let method = reqwest::Method::from_bytes(method.as_str().as_bytes()) .expect("axum HTTP methods are valid reqwest HTTP methods"); let response = state @@ -2609,6 +2642,7 @@ async fn fetch_text_feed(state: &AppState, url: &str) -> Result validate_http_url(url, /* allow_non_default_hosts */ true) .map_err(|message| format!("invalid feed URL {url}: {message}"))?; + state.assert_outbound(url)?; let response = state .feed_http .get(url) @@ -3116,6 +3150,45 @@ pub fn parse_u64_env( } } +fn outbound_http_client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .expect("failed to build fail-closed outbound HTTP client") +} + +fn bind_is_loopback(bind_addr: &str) -> bool { + let trimmed = bind_addr.trim(); + if let Ok(addr) = trimmed.parse::() { + return addr.ip().is_loopback(); + } + let host = trimmed + .rsplit_once(':') + .map(|(host, _)| host.trim_start_matches('[').trim_end_matches(']')) + .unwrap_or(trimmed); + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + +fn startup_destination_policy( + bind_addr: &str, +) -> Result> { + let base = if bind_is_loopback(bind_addr) { + DestinationPolicy::development() + } else { + DestinationPolicy::production() + }; + let allow = std::env::var("DESTINATION_ALLOWLIST").unwrap_or_default(); + let deny = std::env::var("DESTINATION_DENYLIST").unwrap_or_default(); + Ok(base + .with_lists(&allow, &deny) + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?) +} + /// 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 @@ -3189,6 +3262,7 @@ pub async fn run_from_env( .with_admin_tokens(admin_tokens) .with_credentials_source(credentials.source()) .with_proven_engine(proven_engine) + .with_destination_policy(startup_destination_policy(&bind_addr)?) .with_max_body_size(max_body_bytes); let served = axum::serve(listener, build_app(state)) .with_graceful_shutdown(shutdown) @@ -5938,6 +6012,33 @@ mod tests { assert!(result.err().unwrap().contains("upstream must use http://")); } + #[tokio::test] + async fn create_route_fail_closes_metadata_upstream() { + let app = build_app(AppState::seeded(None)); + let denied = app_request( + &app, + json_request( + Method::POST, + "/api/routes", + None, + &serde_json::json!({ + "id": "pivot", + "path_prefix": "/pivot", + "upstream": "http://169.254.169.254/", + "mode": "monitor", + "enabled": true + }), + ), + ) + .await; + assert_eq!(denied.status(), StatusCode::BAD_REQUEST); + let body = body_text(denied).await; + assert!( + body.contains("denied address class"), + "operator must see the denied class: {body}" + ); + } + fn temp_state_path(name: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) From cf8adf196028987450acb13ecb1ec53ffaadf7e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 23:12:26 +0900 Subject: [PATCH 02/17] docs: record PR #96 in the product-technical gap baseline --- docs/product-technical-gap-baseline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 71acbf7f..aa5a41f4 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -25,7 +25,7 @@ not “waiting on review/CI time”. | PR | Title | Head | Checks | Reviews | Merge blocker | | --- | --- | --- | --- | --- | --- | -| stacked | feat(security): fail-closed destination policy for outbound HTTP | `feat/issue-79-destination-policy` on #95 | local lib tests green | Author this pass. | Open as stacked PR after this commit. Org 2-approval + self-author. Restores issue #79 unscoped from #94. | +| [#96](https://github.com/ContextualWisdomLab/wardnet/pull/96) | feat(security): fail-closed destination policy for outbound HTTP | `feat/issue-79-destination-policy` stacked on #95 | local fmt/test/clippy green | Author this pass. | Org 2-approval + self-author. Restores issue #79 unscoped from #94. Merge #95 first. | | [#95](https://github.com/ContextualWisdomLab/wardnet/pull/95) | feat(waf): consult Coraza sidecar on live gateway transactions | `54604c2` (`feat/issue-86-in-path-coraza`) | local fmt/test/clippy + two smokes green; GitHub Checks pending at open. Copilot review requested. | Author this pass. | Org 2-approval + self-author. Runtime gap #86 sidecar slice is this PR. | | [#94](https://github.com/ContextualWisdomLab/wardnet/pull/94) | fix(auth): fail closed without write-capable admin on public bind | `868a7e5` (`fix/issue-78-fail-closed-credentials`) | Restored to issue-#78-only scope (destination/#86 unscoped). Copilot review requested this hour. | Author `seonghobae`; Devin COMMENTED. | Org ruleset `18156473` 2-approval + self-author. Do not `--admin` merge. | | [#93](https://github.com/ContextualWisdomLab/wardnet/pull/93) | test(persistence): replace permission-based fault injection with a deterministic seam | `f77eb697` | rust + Security Scan green; **strix FAILURE** (job `97189711094`). Artifact `strix-reports` id `9493001688`. Root cause is org LiteLLM provider `openai-direct/gpt-5.6-luna` (0 vulns then fail-closed). Not a wardnet code finding. | Author `seonghobae`; Devin COMMENTED. | strix org-provider FAILURE + 2-approval + self-author. Do not rotate review-agent keys. | From 5aca11d7eecdc92c2061ae133afb49d48aad527b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 00:05:17 +0900 Subject: [PATCH 03/17] feat(security): harden destination policy per-IP CIDR and readiness order CIDR allowlist matches apply per resolved address, authorize non-default ports, and reject prefixes outside the address-family width. IPv6 site-local is a denied class. Hostnames that merely contain 0x are not hex IP literals. AppState constructors default to production policy; seeded fixtures opt into development. Blocking DNS runs on spawn_blocking with a timeout. Persistence and destination-list validation complete before the readiness line. --- CHANGELOG.md | 2 +- Cargo.toml | 2 +- README.md | 2 +- docs/architecture.md | 3 +- .../fail-closed-destination-policy.md | 22 +- docs/product-technical-gap-baseline.md | 69 ++++-- scripts/smoke.sh | 8 + src/destination.rs | 233 ++++++++++++++---- src/lib.rs | 161 +++++++++--- tests/binary.rs | 40 +++ 10 files changed, 429 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fffca94..bf7c71bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,4 +10,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - Live `/gateway` transactions consult a Coraza sidecar when `CORAZA_WAF_URL` is set. The sidecar response is parsed with the existing Coraza audit adapter (OWASP CRS authority, not a hand-rolled engine). Sidecar outage is fail-closed when `PROVEN_ENGINE_FAIL_CLOSED` is true. `GET /api/waf/engine-status` and `/healthz.proven_engine` report the mode. -- Fail-closed destination policy on every outbound `http`/`https` call, including the Coraza sidecar URL (gateway upstream, threat-intel fetch, Clearfolio, SOC LLM). Private/loopback/metadata classes are denied unless `DESTINATION_ALLOWLIST` (or loopback development) permits them; `DESTINATION_DENYLIST` wins. Clients ignore ambient HTTP proxies and do not follow redirects. +- Fail-closed destination policy on every outbound `http`/`https` call, including the Coraza sidecar URL (gateway upstream, threat-intel fetch, Clearfolio, SOC LLM). Private/loopback/metadata classes are denied unless `DESTINATION_ALLOWLIST` (or loopback development) permits them; `DESTINATION_DENYLIST` wins. Clients ignore ambient HTTP proxies and do not follow redirects. CIDR allowlist matches apply per resolved address; CIDR entries authorize non-default ports; IPv6 site-local (`fec0::/10`) is denied; invalid CIDR prefixes fail startup; `/healthz.destination_mode` reports the policy class. Blocking DNS runs on `spawn_blocking` with a 2s timeout. Persistence and destination-list validation complete before the readiness line is printed. diff --git a/Cargo.toml b/Cargo.toml index b2ec231f..1ffb6539 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" futures-util = { version = "0.3", default-features = false, features = ["std"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync"] } +tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } waf-ids-core = { path = "crates/waf-ids-core" } [dev-dependencies] diff --git a/README.md b/README.md index 28198cf8..ad7b0e9a 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Useful environment variables: - `BIND_ADDR`: listen address, default `127.0.0.1:8080` - `ADMIN_TOKEN`: optional write token for management writes via `X-Admin-Token` -- `DESTINATION_ALLOWLIST` / `DESTINATION_DENYLIST`: comma-separated hosts, `*.suffix`, or CIDRs for outbound `http`/`https`. Denylist wins. Loopback/private/metadata destinations are denied unless allowlisted (loopback development still permits loopback-class destinations). +- `DESTINATION_ALLOWLIST` / `DESTINATION_DENYLIST`: comma-separated hosts, `*.suffix`, or CIDRs for outbound `http`/`https`. Denylist wins. CIDR matches apply per resolved address and also authorize non-default ports. Loopback/private/metadata/site-local destinations are denied unless allowlisted (loopback development still permits loopback-class destinations). `/healthz.destination_mode` reports `production` or `development`. - `WAF_IDS_STATE_PATH`: optional JSON state path. When omitted, the service runs with seeded in-memory state. - `DNSBL_ORIGIN`: DNSBL zone origin, default `dnsbl.local` - `EVENT_LIMIT`: retained event count, default `1000`; must be greater than zero diff --git a/docs/architecture.md b/docs/architecture.md index b84a4b72..b560a077 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,8 @@ 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/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. +- `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. Persistence, destination-list, and sidecar settings validate before the readiness line is printed. +- `src/destination.rs`: fail-closed outbound URL policy (issue #79) for every `http`/`https` send. CIDR allowlist exceptions are per resolved address; blocking DNS is offloaded from Tokio workers. TCP-peer pinning remains follow-up. - `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. - `/gateway/{path}`: route selection, request scoring, monitor/block decision, optional upstream proxying. diff --git a/docs/doctoring/fail-closed-destination-policy.md b/docs/doctoring/fail-closed-destination-policy.md index 3de76ce5..7c787152 100644 --- a/docs/doctoring/fail-closed-destination-policy.md +++ b/docs/doctoring/fail-closed-destination-policy.md @@ -25,6 +25,17 @@ https://doi.org/10.1109/PROC.1975.9939 - **Design impact:** Fail-safe defaults. A mixed public+private DNS answer set is deny, not allow. Unresolvable hosts are deny. +Jackson, C., Barth, A., Bortz, A., Truelove, W., & Boneh, D. (2007). Protecting +browsers from DNS rebinding attacks. *Proceedings of the 14th ACM Conference on +Computer and Communications Security*, 421–431. +https://doi.org/10.1145/1315245.1315298 + +- **Design impact:** CIDR allowlist exceptions apply per resolved address so a + private-range answer cannot exempt a sibling metadata or link-local record. + Blocking OS DNS is offloaded from Tokio workers with a two-second timeout. + Connecting to the evaluated IP (full TOCTOU close) remains follow-up work; + the ACM paper is not redistributed. + National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) version 1.1* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 @@ -35,7 +46,12 @@ https://doi.org/10.6028/NIST.SP.800-218 ## Operator next action If a legitimate internal origin is denied, add it to `DESTINATION_ALLOWLIST` -(`host`, `*.suffix`, or `CIDR`) and restart. To block a previously allowed -name, put it in `DESTINATION_DENYLIST`. Loopback development still permits +(`host`, `*.suffix`, or `CIDR`) and restart. A CIDR entry also authorizes +non-default ports on matching addresses. To block a previously allowed name, +put it in `DESTINATION_DENYLIST`. Loopback development still permits loopback-class destinations so local fixtures work; production non-loopback -listeners use the strict class list. +listeners use the strict class list. `/healthz.destination_mode` reports +`production` or `development`. CIDR prefixes outside `/32` (IPv4) or `/128` +(IPv6) fail startup. Deprecated IPv6 site-local (`fec0::/10`) is a denied +class. Hostnames that merely contain `0x` (for example `0x0.st`) are not +treated as hex IP literals. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index aa5a41f4..5705f50d 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product and technical gap baseline -Snapshot date: 2026-08-23T13:30Z (exact-head inventory of then-open GitHub PRs +Snapshot date: 2026-08-23T15:05Z (exact-head inventory of then-open GitHub PRs and Issues plus operator-perceptible gaps). Update this file on every hourly loop. Commercial contract and `/api/commercial/readiness` remain **2B KRW**. The @@ -25,20 +25,20 @@ not “waiting on review/CI time”. | PR | Title | Head | Checks | Reviews | Merge blocker | | --- | --- | --- | --- | --- | --- | -| [#96](https://github.com/ContextualWisdomLab/wardnet/pull/96) | feat(security): fail-closed destination policy for outbound HTTP | `feat/issue-79-destination-policy` stacked on #95 | local fmt/test/clippy green | Author this pass. | Org 2-approval + self-author. Restores issue #79 unscoped from #94. Merge #95 first. | -| [#95](https://github.com/ContextualWisdomLab/wardnet/pull/95) | feat(waf): consult Coraza sidecar on live gateway transactions | `54604c2` (`feat/issue-86-in-path-coraza`) | local fmt/test/clippy + two smokes green; GitHub Checks pending at open. Copilot review requested. | Author this pass. | Org 2-approval + self-author. Runtime gap #86 sidecar slice is this PR. | -| [#94](https://github.com/ContextualWisdomLab/wardnet/pull/94) | fix(auth): fail closed without write-capable admin on public bind | `868a7e5` (`fix/issue-78-fail-closed-credentials`) | Restored to issue-#78-only scope (destination/#86 unscoped). Copilot review requested this hour. | Author `seonghobae`; Devin COMMENTED. | Org ruleset `18156473` 2-approval + self-author. Do not `--admin` merge. | +| [#96](https://github.com/ContextualWisdomLab/wardnet/pull/96) | feat(security): fail-closed destination policy for outbound HTTP | `feat/issue-79-destination-policy` stacked on #95 | local fmt/test/clippy green this hour (still-valid review fixes). Copilot review requested. | Author this pass; Devin/Codex COMMENTED on prior head. | Org 2-approval + self-author. Merge #95 first. `gh pr merge` rejected by ruleset 18156473. | +| [#95](https://github.com/ContextualWisdomLab/wardnet/pull/95) | feat(waf): consult Coraza sidecar on live gateway transactions | `ba9ee3a` (`feat/issue-86-in-path-coraza`) | rust + Security Scan green; strix in_progress at snapshot; opencode-review queued. Copilot review requested. | Author this pass; Devin/Codex COMMENTED. | Org 2-approval + self-author. Do not re-implement sidecar slice. | +| [#94](https://github.com/ContextualWisdomLab/wardnet/pull/94) | fix(auth): fail closed without write-capable admin on public bind | `f31d960` (`fix/issue-78-fail-closed-credentials`) | Concurrent commit moved state validation before readiness (closes prior rust failure `binary_does_not_report_readiness_before_state_validation` on `b9daeb5`). Checks re-running. Copilot review requested. | Author `seonghobae`; Devin COMMENTED. | Org 2-approval + self-author. Do not `--admin` merge. Do not re-implement #78. | | [#93](https://github.com/ContextualWisdomLab/wardnet/pull/93) | test(persistence): replace permission-based fault injection with a deterministic seam | `f77eb697` | rust + Security Scan green; **strix FAILURE** (job `97189711094`). Artifact `strix-reports` id `9493001688`. Root cause is org LiteLLM provider `openai-direct/gpt-5.6-luna` (0 vulns then fail-closed). Not a wardnet code finding. | Author `seonghobae`; Devin COMMENTED. | strix org-provider FAILURE + 2-approval + self-author. Do not rotate review-agent keys. | -| [#92](https://github.com/ContextualWisdomLab/wardnet/pull/92) | build(deps): bump github/codeql-action/upload-sarif from 4.37.6 to 4.37.7 | dependabot `17277d78` | All green (27). | Maintainer APPROVED (1 of 2). Copilot review requested this hour. | **Second independent APPROVE missing** (ruleset 2-approval). `gh pr merge` rejected by policy, not Checks. | -| [#91](https://github.com/ContextualWisdomLab/wardnet/pull/91) | build(deps): bump futures-util from 0.3.33 to 0.3.34 | dependabot `c4662cae` | All green (27). | Maintainer APPROVED (1 of 2). Copilot review requested this hour. | Same as #92: second independent APPROVE missing. | +| [#92](https://github.com/ContextualWisdomLab/wardnet/pull/92) | build(deps): bump github/codeql-action/upload-sarif from 4.37.6 to 4.37.7 | dependabot `17277d78` | All green. `--auto` squash already enabled. | Maintainer APPROVED (1 of 2). | **Second independent APPROVE missing**. `gh pr merge` rejected: "the base branch policy prohibits the merge." | +| [#91](https://github.com/ContextualWisdomLab/wardnet/pull/91) | build(deps): bump futures-util from 0.3.33 to 0.3.34 | dependabot `c4662cae` | All green. `--auto` squash already enabled. | Maintainer APPROVED (1 of 2). | Same as #92: second independent APPROVE missing. | | [#90](https://github.com/ContextualWisdomLab/wardnet/pull/90) | feat(observability): export Wardnet events to SIEM and OpenTelemetry | `40f11b93` | All green (35). | Author `seonghobae`; CodeRabbit/Devin/GHAS COMMENTED. **0 unresolved threads** on exact head. | Org 2-approval + self-author. | | [#88](https://github.com/ContextualWisdomLab/wardnet/pull/88) | feat(security): reject non-LiteLLM credentials before upstream | `41b21cfe` | All green (35). | Author `seonghobae`; CodeRabbit COMMENTED. **0 unresolved threads** on exact head. | Org 2-approval + self-author. | | [#77](https://github.com/ContextualWisdomLab/wardnet/pull/77) | build(rust): pin and track Rust 1.97.1 | `a13c0865` | rust green; **strix FAILURE** (job `97001450437`). Same org-provider fail-closed as #93. | Author `seonghobae`; Devin COMMENTED. | strix org-provider FAILURE + 2-approval + self-author. | | [#76](https://github.com/ContextualWisdomLab/wardnet/pull/76) | feat(ai): delegate SOC analysis to adaptive orchestration | `1cc49277` | All green (35). | Author `seonghobae`; CodeRabbit COMMENTED; opencode DISMISSED. **0 unresolved threads**. | Org 2-approval + self-author. | -| [#72](https://github.com/ContextualWisdomLab/wardnet/pull/72) | fix(deploy): require externally provisioned admin secret | `6881f479` | rust/coverage-evidence/opencode-review **success**; **strix FAILURE** (job `97198957113`, org provider). **0 unresolved threads**. | Latest opencode-agent **CHANGES_REQUESTED** was on `5fd9e2ba`, not this head. coverage-evidence is green on `6881f47` but opencode did not post APPROVE (review job only prints that approval is a separate dispatch). Copilot review requested this hour. | `CHANGES_REQUESTED` still sticky from prior SHA + 2-approval + self-author + strix org-provider FAILURE. | +| [#72](https://github.com/ContextualWisdomLab/wardnet/pull/72) | fix(deploy): require externally provisioned admin secret | `6881f479` | rust/coverage-evidence/opencode-review **success**; **strix FAILURE** (job `97198957113`, org provider). **0 unresolved threads**. | Latest opencode-agent **CHANGES_REQUESTED** was on `5fd9e2ba`, not this head. coverage-evidence is green on `6881f47` but opencode did not post APPROVE. Copilot review requested. | Sticky `CHANGES_REQUESTED` + 2-approval + self-author + strix org-provider FAILURE. | -Dependabot #91 and #92 were approved by this actor; `gh pr merge` was rejected -by the base-branch policy (not by failing Checks). Do not `--admin` merge. +Dependabot #91 and #92 remain auto-merge enabled; `gh pr merge` was rejected +by ruleset `18156473` (not by failing Checks). Do not `--admin` merge. ## Then-open issues @@ -108,13 +108,26 @@ Shipped: Doctoring: `docs/doctoring/fail-closed-management-auth.md` (APA 7th). -### Destination policy (issue #79) — **closed this pass** +### Destination policy (issue #79) — **closed this pass (review-hardening)** Shipped in `src/destination.rs` and wired through route upsert, gateway proxy, threat-intel fetch, Clearfolio, and SOC LLM. Default deny of loopback, RFC 1918, -link-local, ULA, CGNAT, documentation, and cloud-metadata classes unless -`DESTINATION_ALLOWLIST` (or loopback development) permits them. -`DESTINATION_DENYLIST` wins. HTTP clients: no redirects, `no_proxy()`. +link-local, ULA, CGNAT, documentation, cloud-metadata, and deprecated IPv6 +site-local (`fec0::/10`) unless `DESTINATION_ALLOWLIST` (or loopback development) +permits them. `DESTINATION_DENYLIST` wins. HTTP clients: no redirects, `no_proxy()`. + +This hour's still-valid review fixes (operator-visible): + +- CIDR allowlist matches apply **per resolved address** (a private CIDR cannot + exempt a sibling metadata/link-local answer). +- CIDR allowlist entries authorize non-default ports after resolve. +- Invalid CIDR prefixes (`/33`, `/129`) fail startup before bind. +- Hostnames that merely contain `0x` (e.g. `0x0.st`) are not hex IP literals. +- `AppState::load` / `new` default to production policy; `seeded()` opts into + development. `/healthz.destination_mode` reports the class. +- Blocking OS DNS runs on `spawn_blocking` with a 2s timeout. +- Persistence and destination-list validation complete **before** the readiness + line (binary test `binary_does_not_report_readiness_before_state_validation`). Remaining: custom connector that pins the TCP peer to the evaluated IP (full TOCTOU close); Kubernetes NetworkPolicy examples as defense in depth. @@ -159,23 +172,27 @@ Remaining holes on untouched handlers stay listed for later loops. ## This loop’s shipped gap -Issue **#86** slice: in-path Coraza sidecar adapter on live `/gateway` -transactions (branch `feat/issue-86-in-path-coraza`, not stacked onto PR #94 -after that PR was restored to issue-#78-only scope). Operator-visible: -`GET /api/waf/engine-status` reports whether CRS is in the request path; a -sidecar interrupt blocks the **current** request (not only a later client -matching ingest hints). #78 remains on PR #94; #79 destination policy was -unscoped from #94 and was not re-implemented here. +Issue **#79** remaining review-hardening on PR #96 (still unmerged; policy +blocks). Operator-visible: `/healthz.destination_mode`; CIDR allowlist no longer +exempts sibling denied-class DNS answers; CIDR entries authorize non-default +ports; invalid prefixes fail closed at startup; IPv6 site-local is denied; +readiness is not printed until state validates. Driving test: +`create_route_fail_closes_private_upstream_unless_cidr_allowlisted` (real +`POST /api/routes` through `assert_outbound`). #78 remains on PR #94 (`f31d960` +already moved state validation before readiness — do not re-implement). #86 +sidecar remains on PR #95 — do not re-implement that slice. ## Next hourly loop (do, do not report) -1. Second independent APPROVE on #91/#92 (Copilot requested; still 1/2). -2. Re-dispatch opencode review on #72 head `6881f47` now that coverage-evidence - is green, without rotating review-agent secrets. +1. Second independent APPROVE on #91/#92 (Copilot requested; still 1/2; `--auto` + already enabled). +2. Keep #94/#95/#96 merge-ready. Do not `--admin` merge. Do not re-implement + #78 or the #86 sidecar slice. 3. Strix FAILURE on #72/#77/#93 is org LiteLLM provider infra, not wardnet code; do not rotate keys. Watch ContextualWisdomLab/.github branch `codex/strix-fail-closed-provider-evidence`. -4. Keep #94 merge-ready (Copilot requested). Stacked #78/#79/#86 live there. -5. Next runtime gap if policy still blocks: #80 durable control plane, or - Suricata EVE tail/shipper (remainder of #86). +4. Sticky opencode `CHANGES_REQUESTED` on #72 head `6881f47` — review job does + not post APPROVE. +5. Next runtime gap if policy still blocks: TCP-peer pin remainder of #79, or + #80 durable control plane, or Suricata EVE tail/shipper (remainder of #86). 6. Refresh this file’s PR/Issue tables from `gh pr list` / `gh issue list`. diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 44c1250e..22563d43 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -73,18 +73,21 @@ PY start_server health="$(curl -fsS "$BASE_URL/healthz")" +echo "healthz body: $health" assert_json_field "$health" 'data["status"] == "ok"' assert_json_field "$health" 'data["persistence"] == "file"' assert_json_field "$health" 'data["dnsbl_origin"] == "dnsbl.test"' assert_json_field "$health" 'data["event_limit"] == 5' assert_json_field "$health" 'data["proven_engine"] == "ingest_hints_only"' assert_json_field "$health" 'data["proven_engine_fail_closed"] is False' +assert_json_field "$health" 'data["destination_mode"] == "development"' engine_status="$(curl -fsS "$BASE_URL/api/waf/engine-status")" assert_json_field "$engine_status" 'data["mode"] == "ingest_hints_only"' assert_json_field "$engine_status" 'data["in_path"] is False' admin_html="$(curl -fsS "$BASE_URL/admin")" +echo "admin body bytes: ${#admin_html}" case "$admin_html" in *"ContextualWisdomLab WAF/IDS/AI SOC Gateway"*) ;; *) @@ -92,6 +95,10 @@ case "$admin_html" in exit 1 ;; esac +readiness="$(curl -fsS "$BASE_URL/api/commercial/readiness")" +echo "readiness body: $readiness" +assert_json_field "$readiness" 'data["target_sale_value_krw"] == 2000000000' +assert_json_field "$readiness" '"readiness_level" in data' unauthorized_code="$( curl -sS -o /dev/null -w '%{http_code}' \ @@ -168,6 +175,7 @@ assert_json_field "$kpis" 'data["stale_threat_feed_count"] == 0' assert_json_field "$kpis" 'data["audit_log_count"] >= 3' readiness="$(curl -fsS "$BASE_URL/api/commercial/readiness")" +echo "readiness sale-ready body: $readiness" assert_json_field "$readiness" 'data["target_sale_value_krw"] == 2000000000' assert_json_field "$readiness" 'data["ready_for_enterprise_sale"] is True' assert_json_field "$readiness" 'data["readiness_level"] == "sale_ready"' diff --git a/src/destination.rs b/src/destination.rs index 3cf16c03..a54f3712 100644 --- a/src/destination.rs +++ b/src/destination.rs @@ -5,6 +5,11 @@ //! classified. Deny-overrides win over allowlists. Loopback-class destinations //! are allowed only when [`DestinationPolicy::development`] is selected (the //! process itself is loopback-only). +//! +//! CIDR allowlist matches apply per resolved address (a private CIDR must not +//! exempt a sibling metadata/link-local answer). Non-default ports are allowed +//! when the host or a resolved CIDR is allowlisted. Remaining TOCTOU: the +//! subsequent HTTP client may re-resolve; pin the TCP peer in a later pass. use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs}; use waf_ids_core::ip_in_network; @@ -57,6 +62,15 @@ impl DestinationPolicy { } } + /// Operator-visible policy class (`production` or `development`). + pub fn mode(&self) -> &'static str { + if self.allow_loopback_class { + "development" + } else { + "production" + } + } + /// Parse comma-separated allow/deny lists (`host`, `*.suffix`, `cidr`). pub fn with_lists(mut self, allow: &str, deny: &str) -> Result { self.allow = parse_list(allow)?; @@ -71,45 +85,8 @@ impl DestinationPolicy { resolver: &dyn HostResolver, ) -> Result { let parsed = parse_outbound_url(raw)?; - let host_allowlisted = self.allow.iter().any(|entry| match entry { - ListEntry::Hostname(_) | ListEntry::Suffix(_) => { - matching_host(entry, &parsed.host, &[]) - } - ListEntry::Cidr { .. } => false, - }); - let literal_loopback = parsed.host == "localhost" - || parsed - .host - .parse::() - .map(|ip| canonicalize_ip(ip).is_loopback()) - .unwrap_or(false); - if parsed.port != 80 - && parsed.port != 443 - && !host_allowlisted - && !(self.allow_loopback_class && literal_loopback) - { - return Err(format!( - "destination port {} is not a default http/https port", - parsed.port - )); - } - let mut ips = Vec::new(); - if parsed.host == "localhost" { - ips.push(IpAddr::V4(Ipv4Addr::LOCALHOST)); - } else if let Ok(ip) = parsed.host.parse::() { - ips.push(canonicalize_ip(ip)); - } else { - ips = resolver - .resolve(&parsed.host) - .map_err(|error| format!("destination DNS failed for {}: {error}", parsed.host))?; - if ips.is_empty() { - return Err(format!( - "destination {} resolved to no addresses", - parsed.host - )); - } - ips = ips.into_iter().map(canonicalize_ip).collect(); - } + let ips = resolve_host_ips(&parsed.host, resolver)?; + let host_allowlisted = host_allowlisted(&self.allow, &parsed.host); if let Some(entry) = self.matching_entry(&self.deny, &parsed.host, &ips) { return Err(format!( @@ -119,13 +96,26 @@ impl DestinationPolicy { )); } - let allowlisted = self - .matching_entry(&self.allow, &parsed.host, &ips) - .is_some(); + let cidr_allowlisted = ips.iter().any(|ip| cidr_allows(&self.allow, *ip)); + let loopback_ok = self.allow_loopback_class + && (parsed.host == "localhost" || ips.iter().any(|ip| ip.is_loopback())); + if parsed.port != 80 + && parsed.port != 443 + && !host_allowlisted + && !cidr_allowlisted + && !loopback_ok + { + return Err(format!( + "destination port {} is not a default http/https port", + parsed.port + )); + } for ip in &ips { if ip_is_denied_class(*ip) { - if allowlisted || (self.allow_loopback_class && ip.is_loopback()) { + let this_cidr = cidr_allows(&self.allow, *ip); + if host_allowlisted || this_cidr || (self.allow_loopback_class && ip.is_loopback()) + { continue; } return Err(format!( @@ -174,6 +164,41 @@ impl HostResolver for SystemHostResolver { } } +fn resolve_host_ips(host: &str, resolver: &dyn HostResolver) -> Result, String> { + let mut ips = Vec::new(); + if host == "localhost" { + ips.push(IpAddr::V4(Ipv4Addr::LOCALHOST)); + } else if let Ok(ip) = host.parse::() { + ips.push(canonicalize_ip(ip)); + } else { + ips = resolver + .resolve(host) + .map_err(|error| format!("destination DNS failed for {host}: {error}"))?; + if ips.is_empty() { + return Err(format!("destination {host} resolved to no addresses")); + } + ips = ips.into_iter().map(canonicalize_ip).collect(); + } + Ok(ips) +} + +fn host_allowlisted(entries: &[ListEntry], host: &str) -> bool { + entries.iter().any(|entry| match entry { + ListEntry::Hostname(_) | ListEntry::Suffix(_) => matching_host(entry, host, &[]), + ListEntry::Cidr { .. } => false, + }) +} + +fn cidr_allows(entries: &[ListEntry], ip: IpAddr) -> bool { + entries.iter().any(|entry| match entry { + ListEntry::Cidr { + network, + prefix_len, + } => ip_in_network(*network, *prefix_len, ip), + _ => false, + }) +} + fn parse_outbound_url(raw: &str) -> Result { let parsed = reqwest::Url::parse(raw).map_err(|_| "destination URL must be absolute")?; match parsed.scheme() { @@ -212,17 +237,33 @@ fn host_is_ambiguous_literal(host: &str) -> bool { return true; } let lowered = host.to_ascii_lowercase(); - if lowered.contains("0x") { + // Bare hex IPv4 (0x7f000001), not a hostname that merely contains "0x". + if lowered.starts_with("0x") + && !lowered.contains('.') + && lowered[2..].chars().all(|c| c.is_ascii_hexdigit()) + && lowered.len() > 2 + { return true; } - let octets: Vec<&str> = host.split('.').collect(); - octets.len() == 4 - && octets - .iter() - .all(|octet| !octet.is_empty() && octet.chars().all(|c| c.is_ascii_digit())) - && octets - .iter() - .any(|octet| octet.len() > 1 && octet.starts_with('0')) + let octets: Vec<&str> = lowered.split('.').collect(); + if octets.len() != 4 || octets.iter().any(|octet| octet.is_empty()) { + return false; + } + let all_numericish = octets.iter().all(|octet| octet_is_numericish(octet)); + let any_non_decimal = octets.iter().any(|octet| octet_is_hex_or_octal(octet)); + all_numericish && any_non_decimal +} + +fn octet_is_numericish(octet: &str) -> bool { + octet.chars().all(|c| c.is_ascii_digit()) + || (octet.starts_with("0x") && octet[2..].chars().all(|c| c.is_ascii_hexdigit())) +} + +fn octet_is_hex_or_octal(octet: &str) -> bool { + (octet.starts_with("0x") + && octet.len() > 2 + && octet[2..].chars().all(|c| c.is_ascii_hexdigit())) + || (octet.len() > 1 && octet.starts_with('0') && octet.chars().all(|c| c.is_ascii_digit())) } fn canonicalize_ip(ip: IpAddr) -> IpAddr { @@ -258,6 +299,11 @@ fn ip_is_denied_class(ip: IpAddr) -> bool { 32, ip, ) + || ip_in_network( + IpAddr::V6(Ipv6Addr::new(0xfec0, 0, 0, 0, 0, 0, 0, 0)), + 10, + ip, + ) || v6 .to_ipv4_mapped() .is_some_and(|v4| ip_is_denied_class(IpAddr::V4(v4))) @@ -283,6 +329,15 @@ fn parse_list(raw: &str) -> Result, String> { let prefix_len: u8 = prefix .parse() .map_err(|_| format!("invalid CIDR prefix {prefix}"))?; + let max_prefix = match network { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + if prefix_len > max_prefix { + return Err(format!( + "CIDR prefix {prefix_len} exceeds {max_prefix} for {network}" + )); + } out.push(ListEntry::Cidr { network, prefix_len, @@ -383,6 +438,7 @@ mod tests { ("mixed.example", "203.0.113.10"), ("cgnat.example", "100.64.0.1"), ("ula.example", "fd12:3456:789a::1"), + ("example.com", "8.8.8.8"), ]); deny(&policy, "http://127.0.0.1/", &dns, "denied address class"); deny(&policy, "http://0.0.0.0/", &dns, "denied address class"); @@ -499,4 +555,77 @@ mod tests { .evaluate("https://origin.example./path", &dns) .unwrap(); } + + #[test] + fn hex_substring_in_a_real_hostname_is_not_an_ip_literal() { + let policy = DestinationPolicy::production(); + let dns = resolver(&[]); + deny( + &policy, + "https://0x0.st/", + &dns, + "destination DNS failed for 0x0.st", + ); + deny(&policy, "http://0x7f000001/", &dns, "denied"); + } + + #[test] + fn cidr_allowlist_permits_non_default_port_on_matching_literal() { + let policy = DestinationPolicy::production() + .with_lists("10.0.0.0/8", "") + .unwrap(); + let dns = resolver(&[]); + policy.evaluate("http://10.1.2.3:8080/", &dns).unwrap(); + deny( + &policy, + "http://8.8.8.8:8080/", + &dns, + "not a default http/https port", + ); + } + + #[test] + fn cidr_allowlist_does_not_exempt_sibling_denied_class_answers() { + let policy = DestinationPolicy::production() + .with_lists("10.0.0.0/8", "") + .unwrap(); + let mut map = HashMap::new(); + map.insert( + "split.internal".to_string(), + vec![ + "10.1.1.1".parse().unwrap(), + "169.254.169.254".parse().unwrap(), + ], + ); + let dns = MapResolver(map); + deny( + &policy, + "https://split.internal/", + &dns, + "denied address class 169.254.169.254", + ); + } + + #[test] + fn invalid_cidr_prefix_is_rejected_at_parse() { + let v4 = DestinationPolicy::production().with_lists("10.0.0.0/33", ""); + assert!( + v4.unwrap_err().contains("CIDR prefix 33 exceeds 32"), + "IPv4 prefix must be at most /32" + ); + let v6 = DestinationPolicy::production().with_lists("2001:db8::/129", ""); + assert!( + v6.unwrap_err().contains("CIDR prefix 129 exceeds 128"), + "IPv6 prefix must be at most /128" + ); + } + + #[test] + fn production_denies_deprecated_ipv6_site_local() { + let policy = DestinationPolicy::production(); + let dns = resolver(&[]); + deny(&policy, "http://[fec0::1]/", &dns, "denied address class"); + assert_eq!(policy.mode(), "production"); + assert_eq!(DestinationPolicy::development().mode(), "development"); + } } diff --git a/src/lib.rs b/src/lib.rs index 5f382c6a..c416b0dc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,7 @@ use std::{ net::{IpAddr, Ipv4Addr}, path::{Path, PathBuf}, sync::Arc, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use tokio::{ fs, @@ -108,6 +108,7 @@ pub struct ClearfolioConfig { impl AppState { pub fn seeded(admin_token: Option) -> Self { Self::new(AppData::seeded(), AppConfig::memory(admin_token)) + .with_destination_policy(DestinationPolicy::development()) } pub async fn load(config: AppConfig) -> Result { @@ -142,7 +143,7 @@ impl AppState { clearfolio: None, soc_llm: None, proven_engine: ProvenEngineConfig::disabled(), - destination: DestinationPolicy::development(), + destination: DestinationPolicy::production(), resolver: Arc::new(SystemHostResolver), } } @@ -181,10 +182,22 @@ impl AppState { } /// Fail closed before any outbound http/https send. - fn assert_outbound(&self, url: &str) -> Result<(), String> { - self.destination - .evaluate(url, self.resolver.as_ref()) - .map(|_| ()) + /// + /// Blocking OS DNS runs on `spawn_blocking` with a bounded timeout so a + /// hung resolver cannot starve Tokio workers. + async fn assert_outbound(&self, url: &str) -> Result<(), String> { + let policy = self.destination.clone(); + let resolver = Arc::clone(&self.resolver); + let url = url.to_string(); + tokio::time::timeout( + DESTINATION_RESOLVE_TIMEOUT, + tokio::task::spawn_blocking(move || { + policy.evaluate(&url, resolver.as_ref()).map(|_| ()) + }), + ) + .await + .map_err(|_| "destination DNS timed out".to_string())? + .map_err(|_| "destination evaluation cancelled".to_string())? } /// Enable per-client-IP rate limiting: at most `limit` gateway requests per @@ -285,6 +298,7 @@ impl AppState { admin_auth_configured: self.admin_token.is_some() || !self.admin_tokens.is_empty(), proven_engine: self.proven_engine.mode().to_string(), proven_engine_fail_closed: self.proven_engine.fail_closed, + destination_mode: self.destination.mode().to_string(), } } } @@ -410,6 +424,8 @@ pub struct HealthStatus { pub proven_engine: String, /// True when a configured sidecar outage fails the live transaction closed. pub proven_engine_fail_closed: bool, + /// `production` (fail-closed classes) or `development` (loopback class permitted). + pub destination_mode: String, } const PHISHING_DATABASE_DEFAULT_FEED_ID: &str = "phishing-database-active"; @@ -423,6 +439,8 @@ const PHISHING_DATABASE_DEFAULT_IP_LIMIT: usize = 5_000; const PHISHING_DATABASE_DNSBL_CODE: &str = "127.0.0.66"; const PHISHING_DATABASE_DNSBL_REASON: &str = "phishing.database active IP"; const PHISHING_DATABASE_FETCH_TIMEOUT_SECS: u64 = 15; +/// Bounded wait for blocking OS DNS inside destination-policy evaluation. +const DESTINATION_RESOLVE_TIMEOUT: Duration = Duration::from_secs(2); const PHISHING_DATABASE_MAX_BODY_BYTES: usize = 8 * 1024 * 1024; const PHISHING_DATABASE_ALLOWED_HOSTS: &[&str] = &["raw.githubusercontent.com", "phish.co.za"]; @@ -630,7 +648,7 @@ async fn clearfolio_submit( .expect("text/plain is a valid MIME type"); let form = reqwest::multipart::Form::new().part("file", part); let submit_url = clearfolio_submit_url(&config.base_url); - if let Err(message) = state.assert_outbound(&submit_url) { + if let Err(message) = state.assert_outbound(&submit_url).await { return error(StatusCode::BAD_REQUEST, message); } let mut request = state.http.post(submit_url).multipart(form); @@ -663,7 +681,7 @@ async fn clearfolio_status( ); }; let status_url = clearfolio_status_url(&config.base_url, &job_id); - if let Err(message) = state.assert_outbound(&status_url) { + if let Err(message) = state.assert_outbound(&status_url).await { return error(StatusCode::BAD_REQUEST, message); } let mut request = state.http.get(status_url); @@ -805,7 +823,7 @@ async fn soc_analyze( "{}/v1/chat/completions", config.base_url.trim_end_matches('/') ); - if let Err(message) = state.assert_outbound(&endpoint) { + if let Err(message) = state.assert_outbound(&endpoint).await { return error(StatusCode::BAD_REQUEST, message); } let response = state @@ -896,7 +914,7 @@ async fn create_route( return error(StatusCode::BAD_REQUEST, message); } if (route.upstream.starts_with("http://") || route.upstream.starts_with("https://")) - && let Err(message) = state.assert_outbound(&route.upstream) + && let Err(message) = state.assert_outbound(&route.upstream).await { return error(StatusCode::BAD_REQUEST, message); } @@ -1623,7 +1641,7 @@ async fn fetch_taxii_objects( ) -> Result { use futures_util::StreamExt; - state.assert_outbound(url)?; + state.assert_outbound(url).await?; let mut request = state .feed_http .get(url) @@ -2338,7 +2356,7 @@ async fn consult_proven_engine( else { return ProvenEngineOutcome::NotConfigured; }; - if let Err(reason) = state.assert_outbound(&url) { + if let Err(reason) = state.assert_outbound(&url).await { return ProvenEngineOutcome::Unavailable { reason }; } proven_engine::evaluate_sidecar(&state.http, &url, method, request_uri, body_text, client_ip) @@ -2379,7 +2397,7 @@ async fn proxy_request( body: Bytes, ) -> Result { let target = upstream_target(route, path, query)?; - state.assert_outbound(&target)?; + state.assert_outbound(&target).await?; let method = reqwest::Method::from_bytes(method.as_str().as_bytes()) .expect("axum HTTP methods are valid reqwest HTTP methods"); let response = state @@ -2642,7 +2660,7 @@ async fn fetch_text_feed(state: &AppState, url: &str) -> Result validate_http_url(url, /* allow_non_default_hosts */ true) .map_err(|message| format!("invalid feed URL {url}: {message}"))?; - state.assert_outbound(url)?; + state.assert_outbound(url).await?; let response = state .feed_http .get(url) @@ -3233,12 +3251,6 @@ pub async fn run_from_env( std::env::var("MAX_BODY_BYTES").ok().as_deref(), 1_048_576, )? as usize; - let listener = tokio::net::TcpListener::bind(&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 - // line immediately even though stdout is block-buffered when piped. - std::io::Write::flush(&mut std::io::stdout())?; let coraza_waf_url = std::env::var("CORAZA_WAF_URL") .ok() .map(|value| value.trim().to_string()) @@ -3255,6 +3267,7 @@ pub async fn run_from_env( fail_closed: proven_engine_fail_closed, }, }; + let destination_policy = startup_destination_policy(&bind_addr)?; let state = AppState::load(config) .await .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))? @@ -3262,8 +3275,14 @@ pub async fn run_from_env( .with_admin_tokens(admin_tokens) .with_credentials_source(credentials.source()) .with_proven_engine(proven_engine) - .with_destination_policy(startup_destination_policy(&bind_addr)?) + .with_destination_policy(destination_policy) .with_max_body_size(max_body_bytes); + let listener = tokio::net::TcpListener::bind(&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 + // line immediately even though stdout is block-buffered when piped. + std::io::Write::flush(&mut std::io::stdout())?; let served = axum::serve(listener, build_app(state)) .with_graceful_shutdown(shutdown) .await; @@ -3309,6 +3328,8 @@ mod tests { "MAX_BODY_BYTES", "CORAZA_WAF_URL", "PROVEN_ENGINE_FAIL_CLOSED", + "DESTINATION_ALLOWLIST", + "DESTINATION_DENYLIST", ] { unsafe { std::env::remove_var(name) }; } @@ -3409,6 +3430,22 @@ mod tests { clear_run_env(); } + #[tokio::test] + async fn run_from_env_rejects_malformed_destination_allowlist() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:0"); + std::env::set_var("DESTINATION_ALLOWLIST", "10.0.0.0/33"); + } + assert!( + run_from_env(Box::pin(std::future::ready(()))) + .await + .is_err() + ); + clear_run_env(); + } + #[tokio::test] async fn run_from_env_rejects_malformed_max_body_bytes() { let _guard = ENV_GUARD.lock().await; @@ -3439,7 +3476,7 @@ mod tests { std::env::set_var("BIND_ADDR", "127.0.0.1:0"); std::env::set_var("WAF_IDS_STATE_PATH", path.to_str().unwrap()); } - // Bind succeeds, but loading corrupt persisted state maps to an error. + // Corrupt persisted state is a hard error before the listener binds. assert!( run_from_env(Box::pin(std::future::ready(()))) .await @@ -4264,6 +4301,7 @@ mod tests { json_body(app_request(&app, empty_request(Method::GET, "/healthz")).await).await; assert_eq!(health.persistence, "file"); assert_eq!(health.dnsbl_origin, "dnsbl.example"); + assert_eq!(health.destination_mode, "production"); let block_route = RouteConfig { id: "secure".to_string(), @@ -5950,7 +5988,8 @@ mod tests { dnsbl_origin: "dnsbl.local".to_string(), event_limit: 20, }, - ); + ) + .with_destination_policy(DestinationPolicy::development()); let app = build_app(state); let no_route = app_request(&app, empty_request(Method::GET, "/gateway/none")).await; @@ -7034,6 +7073,7 @@ mod tests { admin_auth_configured: false, proven_engine: "ingest_hints_only".to_string(), proven_engine_fail_closed: false, + destination_mode: "production".to_string(), } ); @@ -7044,6 +7084,69 @@ mod tests { let health = authed.health_status(); assert_eq!(health.credentials_source, "file"); assert!(health.admin_auth_configured); + assert_eq!( + AppState::seeded(None).health_status().destination_mode, + "development" + ); + } + + #[tokio::test] + async fn create_route_fail_closes_private_upstream_unless_cidr_allowlisted() { + let denied_app = build_app( + AppState::seeded(Some("secret".to_string())) + .with_destination_policy(DestinationPolicy::production()), + ); + let denied = app_request( + &denied_app, + json_request( + Method::POST, + "/api/routes", + Some("secret"), + &serde_json::json!({ + "id": "internal-svc", + "path_prefix": "/internal", + "upstream": "http://10.1.2.3:8080/", + "mode": "block", + "enabled": true + }), + ), + ) + .await; + assert_eq!(denied.status(), StatusCode::BAD_REQUEST); + let denied_body = body_text(denied).await; + assert!( + denied_body.contains("not a default http/https port") + || denied_body.contains("denied address class"), + "production policy must reject a private non-default-port upstream: {denied_body}" + ); + + let allowlisted = DestinationPolicy::production() + .with_lists("10.0.0.0/8", "") + .expect("valid CIDR allowlist"); + let allowed_app = build_app( + AppState::seeded(Some("secret".to_string())).with_destination_policy(allowlisted), + ); + let created = app_request( + &allowed_app, + json_request( + Method::POST, + "/api/routes", + Some("secret"), + &serde_json::json!({ + "id": "internal-svc", + "path_prefix": "/internal", + "upstream": "http://10.1.2.3:8080/", + "mode": "block", + "enabled": true + }), + ), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + let health: HealthStatus = + json_body(app_request(&allowed_app, empty_request(Method::GET, "/healthz")).await) + .await; + assert_eq!(health.destination_mode, "production"); } fn clearfolio_test_config(base_url: &str) -> ClearfolioConfig { @@ -7256,11 +7359,13 @@ mod tests { fn state_with_event_and_llm(base_url: &str) -> AppState { let mut data = AppData::seeded(); data.events.push(soc_test_event()); - AppState::new(data, AppConfig::memory(None)).with_soc_llm(Some(SocLlmConfig { - base_url: base_url.to_string(), - token: "test-token".to_string(), - model: "contextual-orchestrator".to_string(), - })) + AppState::new(data, AppConfig::memory(None)) + .with_destination_policy(DestinationPolicy::development()) + .with_soc_llm(Some(SocLlmConfig { + base_url: base_url.to_string(), + token: "test-token".to_string(), + model: "contextual-orchestrator".to_string(), + })) } async fn spawn_chat_mock(response: &'static str) -> std::net::SocketAddr { diff --git a/tests/binary.rs b/tests/binary.rs index ea49034f..71887c6b 100644 --- a/tests/binary.rs +++ b/tests/binary.rs @@ -40,6 +40,46 @@ fn binary_serves_until_force_stopped_on_windows() { ); } +#[test] +fn binary_does_not_report_readiness_before_state_validation() { + let state_path = std::env::temp_dir().join(format!( + "wardnet-corrupt-state-{}-{}.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + std::fs::write(&state_path, "not-json").expect("write corrupt state fixture"); + + let output = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) + .env("BIND_ADDR", "127.0.0.1:0") + .env("WAF_IDS_STATE_PATH", &state_path) + .env_remove("ADMIN_TOKEN") + .env_remove("ADMIN_TOKENS") + .env_remove("WAF_IDS_CREDENTIALS_PATH") + .output() + .expect("spawn gateway binary for startup validation check"); + let _ = std::fs::remove_file(&state_path); + + assert!( + !output.status.success(), + "corrupt state must fail startup: {:?}", + output.status + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{stdout}{stderr}"); + assert!( + combined.contains("is not valid JSON"), + "startup error should explain the invalid state file:\n{combined}" + ); + assert!( + !combined.contains("waf-ids-ai-soc listening on"), + "readiness must not be reported until persisted state validates:\n{combined}" + ); +} + fn spawn_ready_gateway() -> Child { let mut child = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) .env("BIND_ADDR", "127.0.0.1:0") From 7cacaf135179bf0c8b45cd5ccf9b7b9510bed2b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 00:19:49 +0900 Subject: [PATCH 04/17] feat(security): pin outbound HTTP to evaluated destination addresses After destination policy allows a host, the reqwest client resolves only those IPs so a rebinding answer cannot reach loopback, private, or metadata classes. Host and SNI stay on the original name. Unpinned hostnames fail closed instead of falling back to OS DNS. --- CHANGELOG.md | 2 +- README.md | 2 +- docs/architecture.md | 2 +- .../fail-closed-destination-policy.md | 8 +- docs/product-technical-gap-baseline.md | 32 ++--- docs/security/threat-model.md | 2 +- src/destination.rs | 115 +++++++++++++++++- src/lib.rs | 102 ++++++++++++++-- 8 files changed, 229 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf7c71bb..b6e7c20d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,4 +10,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - Live `/gateway` transactions consult a Coraza sidecar when `CORAZA_WAF_URL` is set. The sidecar response is parsed with the existing Coraza audit adapter (OWASP CRS authority, not a hand-rolled engine). Sidecar outage is fail-closed when `PROVEN_ENGINE_FAIL_CLOSED` is true. `GET /api/waf/engine-status` and `/healthz.proven_engine` report the mode. -- Fail-closed destination policy on every outbound `http`/`https` call, including the Coraza sidecar URL (gateway upstream, threat-intel fetch, Clearfolio, SOC LLM). Private/loopback/metadata classes are denied unless `DESTINATION_ALLOWLIST` (or loopback development) permits them; `DESTINATION_DENYLIST` wins. Clients ignore ambient HTTP proxies and do not follow redirects. CIDR allowlist matches apply per resolved address; CIDR entries authorize non-default ports; IPv6 site-local (`fec0::/10`) is denied; invalid CIDR prefixes fail startup; `/healthz.destination_mode` reports the policy class. Blocking DNS runs on `spawn_blocking` with a 2s timeout. Persistence and destination-list validation complete before the readiness line is printed. +- Fail-closed destination policy on every outbound `http`/`https` call, including the Coraza sidecar URL (gateway upstream, threat-intel fetch, Clearfolio, SOC LLM). Private/loopback/metadata classes are denied unless `DESTINATION_ALLOWLIST` (or loopback development) permits them; `DESTINATION_DENYLIST` wins. Clients ignore ambient HTTP proxies and do not follow redirects. CIDR allowlist matches apply per resolved address; CIDR entries authorize non-default ports; IPv6 site-local (`fec0::/10`) is denied; invalid CIDR prefixes fail startup; `/healthz.destination_mode` reports the policy class. Blocking DNS runs on `spawn_blocking` with a 2s timeout. Persistence and destination-list validation complete before the readiness line is printed. After a host is allowed, the HTTP client connects only to those evaluated addresses (Host/SNI unchanged) so a rebinding answer cannot bypass the policy. diff --git a/README.md b/README.md index ad7b0e9a..a0b2b277 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Useful environment variables: - `BIND_ADDR`: listen address, default `127.0.0.1:8080` - `ADMIN_TOKEN`: optional write token for management writes via `X-Admin-Token` -- `DESTINATION_ALLOWLIST` / `DESTINATION_DENYLIST`: comma-separated hosts, `*.suffix`, or CIDRs for outbound `http`/`https`. Denylist wins. CIDR matches apply per resolved address and also authorize non-default ports. Loopback/private/metadata/site-local destinations are denied unless allowlisted (loopback development still permits loopback-class destinations). `/healthz.destination_mode` reports `production` or `development`. +- `DESTINATION_ALLOWLIST` / `DESTINATION_DENYLIST`: comma-separated hosts, `*.suffix`, or CIDRs for outbound `http`/`https`. Denylist wins. CIDR matches apply per resolved address and also authorize non-default ports. Loopback/private/metadata/site-local destinations are denied unless allowlisted (loopback development still permits loopback-class destinations). After a host is allowed, outbound HTTP connects only to those evaluated addresses (original Host/SNI). `/healthz.destination_mode` reports `production` or `development`. - `WAF_IDS_STATE_PATH`: optional JSON state path. When omitted, the service runs with seeded in-memory state. - `DNSBL_ORIGIN`: DNSBL zone origin, default `dnsbl.local` - `EVENT_LIMIT`: retained event count, default `1000`; must be greater than zero diff --git a/docs/architecture.md b/docs/architecture.md index b560a077..f778cfb5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ flowchart LR - `src/main.rs`: process startup and operator configuration from `BIND_ADDR`, `ADMIN_TOKEN`, `WAF_IDS_STATE_PATH`, `DNSBL_ORIGIN`, and `EVENT_LIMIT`. - `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. Persistence, destination-list, and sidecar settings validate before the readiness line is printed. -- `src/destination.rs`: fail-closed outbound URL policy (issue #79) for every `http`/`https` send. CIDR allowlist exceptions are per resolved address; blocking DNS is offloaded from Tokio workers. TCP-peer pinning remains follow-up. +- `src/destination.rs`: fail-closed outbound URL policy (issue #79) for every `http`/`https` send. CIDR allowlist exceptions are per resolved address; blocking DNS is offloaded from Tokio workers. The outbound HTTP client DNS resolver returns only addresses that already passed policy (TCP peer pin / DNS-rebinding TOCTOU close). - `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. - `/gateway/{path}`: route selection, request scoring, monitor/block decision, optional upstream proxying. diff --git a/docs/doctoring/fail-closed-destination-policy.md b/docs/doctoring/fail-closed-destination-policy.md index 7c787152..1ce00bce 100644 --- a/docs/doctoring/fail-closed-destination-policy.md +++ b/docs/doctoring/fail-closed-destination-policy.md @@ -33,8 +33,9 @@ https://doi.org/10.1145/1315245.1315298 - **Design impact:** CIDR allowlist exceptions apply per resolved address so a private-range answer cannot exempt a sibling metadata or link-local record. Blocking OS DNS is offloaded from Tokio workers with a two-second timeout. - Connecting to the evaluated IP (full TOCTOU close) remains follow-up work; - the ACM paper is not redistributed. + After evaluation succeeds, the HTTP client connects only to those addresses + (original Host/SNI preserved) so a rebinding answer cannot reach a denied + class. The ACM paper is not redistributed. National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) version 1.1* (NIST SP 800-218). @@ -54,4 +55,5 @@ listeners use the strict class list. `/healthz.destination_mode` reports `production` or `development`. CIDR prefixes outside `/32` (IPv4) or `/128` (IPv6) fail startup. Deprecated IPv6 site-local (`fec0::/10`) is a denied class. Hostnames that merely contain `0x` (for example `0x0.st`) are not -treated as hex IP literals. +treated as hex IP literals. Outbound HTTP does not re-query OS DNS: it +connects to the evaluated addresses only. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5705f50d..2ceb737a 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product and technical gap baseline -Snapshot date: 2026-08-23T15:05Z (exact-head inventory of then-open GitHub PRs +Snapshot date: 2026-08-23T15:20Z (exact-head inventory of then-open GitHub PRs and Issues plus operator-perceptible gaps). Update this file on every hourly loop. Commercial contract and `/api/commercial/readiness` remain **2B KRW**. The @@ -25,7 +25,7 @@ not “waiting on review/CI time”. | PR | Title | Head | Checks | Reviews | Merge blocker | | --- | --- | --- | --- | --- | --- | -| [#96](https://github.com/ContextualWisdomLab/wardnet/pull/96) | feat(security): fail-closed destination policy for outbound HTTP | `feat/issue-79-destination-policy` stacked on #95 | local fmt/test/clippy green this hour (still-valid review fixes). Copilot review requested. | Author this pass; Devin/Codex COMMENTED on prior head. | Org 2-approval + self-author. Merge #95 first. `gh pr merge` rejected by ruleset 18156473. | +| [#96](https://github.com/ContextualWisdomLab/wardnet/pull/96) | feat(security): fail-closed destination policy for outbound HTTP | `feat/issue-79-destination-policy` stacked on #95 | TCP-peer pin this hour (local fmt/test/clippy after pin). Copilot review to re-request. | Author this pass; Devin/Codex COMMENTED on prior head (TOCTOU P1 addressed by pin). | Org 2-approval + self-author. Merge #95 first. `gh pr merge` rejected by ruleset 18156473. | | [#95](https://github.com/ContextualWisdomLab/wardnet/pull/95) | feat(waf): consult Coraza sidecar on live gateway transactions | `ba9ee3a` (`feat/issue-86-in-path-coraza`) | rust + Security Scan green; strix in_progress at snapshot; opencode-review queued. Copilot review requested. | Author this pass; Devin/Codex COMMENTED. | Org 2-approval + self-author. Do not re-implement sidecar slice. | | [#94](https://github.com/ContextualWisdomLab/wardnet/pull/94) | fix(auth): fail closed without write-capable admin on public bind | `f31d960` (`fix/issue-78-fail-closed-credentials`) | Concurrent commit moved state validation before readiness (closes prior rust failure `binary_does_not_report_readiness_before_state_validation` on `b9daeb5`). Checks re-running. Copilot review requested. | Author `seonghobae`; Devin COMMENTED. | Org 2-approval + self-author. Do not `--admin` merge. Do not re-implement #78. | | [#93](https://github.com/ContextualWisdomLab/wardnet/pull/93) | test(persistence): replace permission-based fault injection with a deterministic seam | `f77eb697` | rust + Security Scan green; **strix FAILURE** (job `97189711094`). Artifact `strix-reports` id `9493001688`. Root cause is org LiteLLM provider `openai-direct/gpt-5.6-luna` (0 vulns then fail-closed). Not a wardnet code finding. | Author `seonghobae`; Devin COMMENTED. | strix org-provider FAILURE + 2-approval + self-author. Do not rotate review-agent keys. | @@ -128,9 +128,12 @@ This hour's still-valid review fixes (operator-visible): - Blocking OS DNS runs on `spawn_blocking` with a 2s timeout. - Persistence and destination-list validation complete **before** the readiness line (binary test `binary_does_not_report_readiness_before_state_validation`). +- After evaluation, the outbound HTTP client connects **only** to those + addresses (Host/SNI preserved). Driving tests: + `proxy_request_connects_to_pinned_policy_addresses` and + `outbound_http_fails_closed_without_a_preauthorized_pin`. -Remaining: custom connector that pins the TCP peer to the evaluated IP (full -TOCTOU close); Kubernetes NetworkPolicy examples as defense in depth. +Remaining: Kubernetes NetworkPolicy examples as defense in depth. ### SIEM / OpenTelemetry (issue #85 / PR #90) @@ -172,15 +175,14 @@ Remaining holes on untouched handlers stay listed for later loops. ## This loop’s shipped gap -Issue **#79** remaining review-hardening on PR #96 (still unmerged; policy -blocks). Operator-visible: `/healthz.destination_mode`; CIDR allowlist no longer -exempts sibling denied-class DNS answers; CIDR entries authorize non-default -ports; invalid prefixes fail closed at startup; IPv6 site-local is denied; -readiness is not printed until state validates. Driving test: -`create_route_fail_closes_private_upstream_unless_cidr_allowlisted` (real -`POST /api/routes` through `assert_outbound`). #78 remains on PR #94 (`f31d960` -already moved state validation before readiness — do not re-implement). #86 -sidecar remains on PR #95 — do not re-implement that slice. +Issue **#79** TCP-peer pin on PR #96 (still unmerged; policy blocks). After +`assert_outbound` allows a host, reqwest DNS returns only those evaluated +addresses so a rebinding answer cannot reach loopback/private/metadata. +Operator-visible: `/healthz.destination_mode` plus pin tests +`proxy_request_connects_to_pinned_policy_addresses` (real `proxy_request` to +`pin-test.invalid` mapped to a local listener) and +`outbound_http_fails_closed_without_a_preauthorized_pin`. #78 remains on PR +#94. #86 sidecar remains on PR #95 — do not re-implement those slices. ## Next hourly loop (do, do not report) @@ -193,6 +195,6 @@ sidecar remains on PR #95 — do not re-implement that slice. `codex/strix-fail-closed-provider-evidence`. 4. Sticky opencode `CHANGES_REQUESTED` on #72 head `6881f47` — review job does not post APPROVE. -5. Next runtime gap if policy still blocks: TCP-peer pin remainder of #79, or - #80 durable control plane, or Suricata EVE tail/shipper (remainder of #86). +5. Next runtime gap if policy still blocks: in-process libcoraza remainder of + #86, or #80 durable control plane, or #81 outbox/workers. 6. Refresh this file’s PR/Issue tables from `gh pr list` / `gh issue list`. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 022149af..5c843271 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -25,7 +25,7 @@ | Unauthorized management write | Route takeover or false blocking | `X-Admin-Token` write gate; multi-token RBAC with actor labels and readonly role; audit log for successful writes | SSO/OIDC, mTLS or identity proxy, SCIM | | Malicious threat feed import | False positives or broad blocks | Validation, route-scoped enforcement | Source signing, feed confidence, staged promotion | | State file corruption | Startup failure or stale policy | JSON parse failure surfaces startup error | Database, backup, schema migration | -| Upstream SSRF through routes | Internal network exposure | Scheme validation plus fail-closed destination policy (`src/destination.rs`): deny loopback/private/link-local/metadata unless allowlisted; denylist wins; no ambient HTTP proxy; no redirects. Coraza sidecar URLs use the same policy. | Kubernetes NetworkPolicy egress as defense in depth | +| Upstream SSRF through routes | Internal network exposure | Scheme validation plus fail-closed destination policy (`src/destination.rs`): deny loopback/private/link-local/metadata unless allowlisted; denylist wins; no ambient HTTP proxy; no redirects. After evaluation, HTTP connects only to those IPs (Host/SNI preserved). Coraza sidecar URLs use the same policy. | Kubernetes NetworkPolicy egress as defense in depth | | Gateway DoS | Availability loss | Rust memory safety, event retention limit | Rate limits, body limits, async event sink | | DNSBL abuse | Reputation damage | Loopback response-code validation | Authoritative DNS service, signing, publisher workflow | | Secret disclosure | Admin compromise | Support bundle excludes admin token; secrets bootstrapped into credential registry (`WAF_IDS_CREDENTIALS_PATH` preferred over long-lived env); health exposes source label only | External secret manager / SSO, rotation, access review | diff --git a/src/destination.rs b/src/destination.rs index a54f3712..0dab6cd8 100644 --- a/src/destination.rs +++ b/src/destination.rs @@ -8,12 +8,21 @@ //! //! CIDR allowlist matches apply per resolved address (a private CIDR must not //! exempt a sibling metadata/link-local answer). Non-default ports are allowed -//! when the host or a resolved CIDR is allowlisted. Remaining TOCTOU: the -//! subsequent HTTP client may re-resolve; pin the TCP peer in a later pass. - -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs}; +//! when the host or a resolved CIDR is allowlisted. The outbound HTTP client +//! does not re-resolve: it connects only to addresses recorded by a successful +//! evaluation (Host/SNI stay on the original name). + +use std::{ + collections::HashMap, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs}, + sync::{Arc, Mutex}, +}; use waf_ids_core::ip_in_network; +/// Cap on remembered (host → evaluated IPs) pins so a hostile name flood +/// cannot grow the table without bound. Eviction is wholesale, not LRU. +const MAX_DESTINATION_PINS: usize = 4096; + /// Outcome of a destination-policy check. `reason` never includes credentials /// or query strings. #[derive(Debug, Clone, PartialEq, Eq)] @@ -134,6 +143,77 @@ impl DestinationPolicy { } } +/// Process-local map of hostname → addresses that already passed policy. +/// +/// The outbound reqwest client uses [`PinnedDns`] so TCP connects to these +/// addresses and never asks the OS resolver a second time (DNS rebinding / +/// TOCTOU close). +#[derive(Default)] +pub(crate) struct DestinationPins { + inner: Mutex>>, +} + +impl DestinationPins { + pub(crate) fn record(&self, host: &str, ips: &[IpAddr]) { + let host = normalize_dns_host(host); + let mut map = self.inner.lock().expect("destination pin lock"); + if map.len() >= MAX_DESTINATION_PINS { + map.clear(); + } + map.insert(host, ips.to_vec()); + } + + pub(crate) fn lookup(&self, host: &str) -> Option> { + let host = normalize_dns_host(host); + self.inner + .lock() + .expect("destination pin lock") + .get(&host) + .cloned() + } +} + +/// reqwest DNS resolver that returns only pre-authorized addresses. +pub(crate) struct PinnedDns { + pins: Arc, +} + +impl PinnedDns { + pub(crate) fn new(pins: Arc) -> Self { + Self { pins } + } +} + +#[derive(Debug)] +struct UnpinnedHost(String); + +impl std::fmt::Display for UnpinnedHost { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "destination host {} is not pre-authorized", self.0) + } +} + +impl std::error::Error for UnpinnedHost {} + +impl reqwest::dns::Resolve for PinnedDns { + fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { + let pins = Arc::clone(&self.pins); + let host = name.as_str().to_string(); + Box::pin(async move { + let Some(ips) = pins.lookup(&host) else { + return Err(Box::new(UnpinnedHost(normalize_dns_host(&host))) + as Box); + }; + let addrs: Vec = ips.into_iter().map(|ip| SocketAddr::new(ip, 0)).collect(); + Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs) + }) + } +} + +fn normalize_dns_host(host: &str) -> String { + host.trim_end_matches('.').to_ascii_lowercase() +} + struct ParsedOutbound { host: String, port: u16, @@ -227,7 +307,7 @@ fn parse_outbound_url(raw: &str) -> Result { } let port = parsed.port_or_known_default().unwrap_or(0); Ok(ParsedOutbound { - host: host.trim_end_matches('.').to_ascii_lowercase(), + host: normalize_dns_host(host), port, }) } @@ -628,4 +708,29 @@ mod tests { assert_eq!(policy.mode(), "production"); assert_eq!(DestinationPolicy::development().mode(), "development"); } + + #[test] + fn pin_board_records_evaluated_ips_and_normalizes_the_host() { + let pins = DestinationPins::default(); + let loopback: IpAddr = "127.0.0.1".parse().unwrap(); + pins.record("Pin-Test.invalid.", &[loopback]); + assert_eq!(pins.lookup("pin-test.invalid"), Some(vec![loopback])); + assert_eq!(pins.lookup("PIN-TEST.invalid."), Some(vec![loopback])); + assert!(pins.lookup("other.invalid").is_none()); + } + + #[test] + fn pin_board_evicts_when_the_cap_is_exceeded() { + let pins = DestinationPins::default(); + let loopback: IpAddr = "127.0.0.1".parse().unwrap(); + for i in 0..MAX_DESTINATION_PINS { + pins.record(&format!("host{i}.invalid"), &[loopback]); + } + pins.record("overflow.invalid", &[loopback]); + assert!( + pins.lookup("host0.invalid").is_none(), + "wholesale eviction must drop the oldest batch" + ); + assert_eq!(pins.lookup("overflow.invalid"), Some(vec![loopback])); + } } diff --git a/src/lib.rs b/src/lib.rs index c416b0dc..6eba6163 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,6 +80,9 @@ pub struct AppState { /// Fail-closed destination policy for every outbound http/https call. destination: DestinationPolicy, resolver: Arc, + /// Addresses that already passed policy; the HTTP clients resolve through + /// this pin board instead of a second OS DNS lookup. + pins: Arc, } /// Configuration for the optional LLM-backed SOC analysis. Points at an @@ -125,11 +128,12 @@ impl AppState { } fn new(data: AppData, config: AppConfig) -> Self { + let pins = Arc::new(destination::DestinationPins::default()); Self { inner: Arc::new(RwLock::new(data)), persist_lock: Arc::new(Mutex::new(())), - http: outbound_http_client(), - feed_http: outbound_http_client(), + http: outbound_http_client(Arc::clone(&pins)), + feed_http: outbound_http_client(Arc::clone(&pins)), admin_token: config.admin_token, admin_tokens: HashMap::new(), credentials_source: CredentialSource::None, @@ -145,6 +149,7 @@ impl AppState { proven_engine: ProvenEngineConfig::disabled(), destination: DestinationPolicy::production(), resolver: Arc::new(SystemHostResolver), + pins, } } @@ -181,23 +186,32 @@ impl AppState { self } + /// Replace the destination DNS resolver. Tests inject a static map so a + /// hostname that is not in OS DNS can still be evaluated and pinned. + #[cfg(test)] + fn with_resolver(mut self, resolver: Arc) -> Self { + self.resolver = resolver; + self + } + /// Fail closed before any outbound http/https send. /// /// Blocking OS DNS runs on `spawn_blocking` with a bounded timeout so a - /// hung resolver cannot starve Tokio workers. + /// hung resolver cannot starve Tokio workers. Successful evaluations are + /// recorded on the pin board the HTTP clients use for connect-time DNS. async fn assert_outbound(&self, url: &str) -> Result<(), String> { let policy = self.destination.clone(); let resolver = Arc::clone(&self.resolver); let url = url.to_string(); - tokio::time::timeout( + let decision = tokio::time::timeout( DESTINATION_RESOLVE_TIMEOUT, - tokio::task::spawn_blocking(move || { - policy.evaluate(&url, resolver.as_ref()).map(|_| ()) - }), + tokio::task::spawn_blocking(move || policy.evaluate(&url, resolver.as_ref())), ) .await .map_err(|_| "destination DNS timed out".to_string())? - .map_err(|_| "destination evaluation cancelled".to_string())? + .map_err(|_| "destination evaluation cancelled".to_string())??; + self.pins.record(&decision.host, &decision.ips); + Ok(()) } /// Enable per-client-IP rate limiting: at most `limit` gateway requests per @@ -3168,10 +3182,11 @@ pub fn parse_u64_env( } } -fn outbound_http_client() -> reqwest::Client { +fn outbound_http_client(pins: Arc) -> reqwest::Client { reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .no_proxy() + .dns_resolver(Arc::new(destination::PinnedDns::new(pins))) .build() .expect("failed to build fail-closed outbound HTTP client") } @@ -7149,6 +7164,75 @@ mod tests { assert_eq!(health.destination_mode, "production"); } + struct PinMapResolver(HashMap>); + + impl HostResolver for PinMapResolver { + fn resolve(&self, host: &str) -> Result, String> { + self.0 + .get(host) + .cloned() + .ok_or_else(|| format!("no fixture for {host}")) + } + } + + #[tokio::test] + async fn proxy_request_connects_to_pinned_policy_addresses() { + let upstream_app = Router::new().route("/", get(|| async { (StatusCode::OK, "pinned") })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(axum::serve(listener, upstream_app).into_future()); + + let mut answers = HashMap::new(); + answers.insert( + "pin-test.invalid".to_string(), + vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], + ); + let state = AppState::seeded(None).with_resolver(Arc::new(PinMapResolver(answers))); + + let response = proxy_request( + &state, + &RouteConfig { + id: "pin".to_string(), + path_prefix: "/pin".to_string(), + upstream: format!("http://pin-test.invalid:{}", addr.port()), + mode: EnforcementMode::Monitor, + enabled: true, + block_threshold: None, + }, + &Method::GET, + "/pin", + None, + Bytes::new(), + ) + .await + .expect("pinned hostname must connect to the evaluated loopback address"); + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), 64).await.unwrap(); + assert_eq!(&bytes[..], b"pinned"); + } + + #[tokio::test] + async fn outbound_http_fails_closed_without_a_preauthorized_pin() { + let state = AppState::seeded(None); + let error = state + .http + .get("http://pin-test.invalid/") + .send() + .await + .expect_err("unpinned hostname must not hit OS DNS"); + let mut message = error.to_string(); + let mut source = std::error::Error::source(&error); + while let Some(err) = source { + message.push(' '); + message.push_str(&err.to_string()); + source = err.source(); + } + assert!( + message.contains("not pre-authorized"), + "fail-closed pin resolver must surface in the reqwest error: {message}" + ); + } + fn clearfolio_test_config(base_url: &str) -> ClearfolioConfig { ClearfolioConfig { base_url: base_url.to_string(), From 4b1331378f48bcc551595d994a094d3f8791aa99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 01:03:36 +0900 Subject: [PATCH 05/17] feat(waf): evaluate live gateway transactions with in-process libcoraza Issue #86 remainder: dlopen operator-supplied libcoraza and drive the C ABI on each /gateway request. Missing library or empty ruleset fail closed before bind. CI stays hermetic with a fixture cdylib that exports the same symbols. --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- Cargo.lock | 11 + Cargo.toml | 1 + README.md | 3 + build.rs | 60 +++ crates/waf-ids-core/src/lib.rs | 2 +- docs/architecture.md | 2 +- docs/doctoring/in-path-coraza-adapter.md | 10 +- docs/doctoring/in-process-libcoraza.md | 49 +++ docs/product-technical-gap-baseline.md | 135 +++---- docs/runbooks/operations.md | 2 +- src/coraza_abi_stub.rs | 311 +++++++++++++++ src/coraza_inprocess.rs | 475 +++++++++++++++++++++++ src/lib.rs | 141 ++++++- src/proven_engine.rs | 66 +++- tests/binary.rs | 36 ++ 17 files changed, 1193 insertions(+), 115 deletions(-) create mode 100644 build.rs create mode 100644 docs/doctoring/in-process-libcoraza.md create mode 100644 src/coraza_abi_stub.rs create mode 100644 src/coraza_inprocess.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e7c20d..0c1ef4d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,5 +9,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- Live `/gateway` transactions consult a Coraza sidecar when `CORAZA_WAF_URL` is set. The sidecar response is parsed with the existing Coraza audit adapter (OWASP CRS authority, not a hand-rolled engine). Sidecar outage is fail-closed when `PROVEN_ENGINE_FAIL_CLOSED` is true. `GET /api/waf/engine-status` and `/healthz.proven_engine` report the mode. +- Live `/gateway` transactions consult in-process libcoraza when `CORAZA_LIB_PATH` is set (with `CORAZA_RULES_PATH` and/or `CORAZA_DIRECTIVES`). Missing library, missing rules, or an empty ruleset fail startup before bind. Otherwise a Coraza sidecar is consulted when `CORAZA_WAF_URL` is set. The sidecar response is parsed with the existing Coraza audit adapter (OWASP CRS authority, not a hand-rolled engine). Engine outage is fail-closed when `PROVEN_ENGINE_FAIL_CLOSED` is true. `GET /api/waf/engine-status` and `/healthz.proven_engine` report `coraza_in_process`, `coraza_sidecar`, or `ingest_hints_only`. - Fail-closed destination policy on every outbound `http`/`https` call, including the Coraza sidecar URL (gateway upstream, threat-intel fetch, Clearfolio, SOC LLM). Private/loopback/metadata classes are denied unless `DESTINATION_ALLOWLIST` (or loopback development) permits them; `DESTINATION_DENYLIST` wins. Clients ignore ambient HTTP proxies and do not follow redirects. CIDR allowlist matches apply per resolved address; CIDR entries authorize non-default ports; IPv6 site-local (`fec0::/10`) is denied; invalid CIDR prefixes fail startup; `/healthz.destination_mode` reports the policy class. Blocking DNS runs on `spawn_blocking` with a 2s timeout. Persistence and destination-list validation complete before the readiness line is printed. After a host is allowed, the HTTP client connects only to those evaluated addresses (Host/SNI unchanged) so a rebinding answer cannot bypass the policy. diff --git a/CLAUDE.md b/CLAUDE.md index 1afa362a..d3843fac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ The core stays an in-repo workspace crate on purpose (no git submodule) until it ## Runtime Configuration -Read in `run_from_env` (`src/lib.rs`): `BIND_ADDR` (default `127.0.0.1:8080`), `ADMIN_TOKEN` (write token for `X-Admin-Token`), `ADMIN_TOKENS` (comma-separated `token:actor` pairs for multi-token RBAC with per-token audit actors), `WAF_IDS_STATE_PATH` (optional JSON state file; omitted = seeded in-memory state), `DNSBL_ORIGIN` (default `dnsbl.local`), `EVENT_LIMIT` (default 1000, must be > 0), `RATE_LIMIT` / `RATE_LIMIT_WINDOW`, `CORAZA_WAF_URL` (optional in-path Coraza sidecar), `PROVEN_ENGINE_FAIL_CLOSED` (boolean; default false — set true in production when a sidecar URL is set). +Read in `run_from_env` (`src/lib.rs`): `BIND_ADDR` (default `127.0.0.1:8080`), `ADMIN_TOKEN` (write token for `X-Admin-Token`), `ADMIN_TOKENS` (comma-separated `token:actor` pairs for multi-token RBAC with per-token audit actors), `WAF_IDS_STATE_PATH` (optional JSON state file; omitted = seeded in-memory state), `DNSBL_ORIGIN` (default `dnsbl.local`), `EVENT_LIMIT` (default 1000, must be > 0), `RATE_LIMIT` / `RATE_LIMIT_WINDOW`, `CORAZA_LIB_PATH` / `CORAZA_RULES_PATH` / `CORAZA_DIRECTIVES` (optional in-process libcoraza), `CORAZA_WAF_URL` (optional in-path Coraza sidecar), `PROVEN_ENGINE_FAIL_CLOSED` (boolean; default false — set true in production when an engine is set). ## Key Conventions diff --git a/Cargo.lock b/Cargo.lock index dc09e461..cb42a020 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -531,6 +531,16 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1340,6 +1350,7 @@ version = "0.1.0" dependencies = [ "axum", "futures-util", + "libloading", "proptest", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index 1ffb6539..48e4d416 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ resolver = "3" axum = "0.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json", "stream"] } futures-util = { version = "0.3", default-features = false, features = ["std"] } +libloading = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } diff --git a/README.md b/README.md index a0b2b277..1d7bb12d 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,9 @@ Useful environment variables: - `WAF_IDS_STATE_PATH`: optional JSON state path. When omitted, the service runs with seeded in-memory state. - `DNSBL_ORIGIN`: DNSBL zone origin, default `dnsbl.local` - `EVENT_LIMIT`: retained event count, default `1000`; must be greater than zero +- `CORAZA_LIB_PATH` / `CORAZA_RULES_PATH` / `CORAZA_DIRECTIVES`: optional in-process libcoraza. A missing library or empty ruleset fails startup. `/healthz.proven_engine` reports `coraza_in_process`. +- `CORAZA_WAF_URL`: optional Coraza sidecar URL used when libcoraza is not loaded +- `PROVEN_ENGINE_FAIL_CLOSED`: when true, a configured engine outage returns 503 instead of degrading to builtin scoring Example with persistent local state: diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..baafea52 --- /dev/null +++ b/build.rs @@ -0,0 +1,60 @@ +//! Compile the libcoraza C-ABI fixture used by in-process engine tests. +//! +//! Production loads operator-supplied libcoraza (`CORAZA_LIB_PATH`). CI stays +//! hermetic: this stub implements the same exported symbols without Go. + +use std::path::PathBuf; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=src/coraza_abi_stub.rs"); + + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR set by cargo"); + let output = stub_output_path(&out_dir); + let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string()); + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + let source = PathBuf::from(&manifest_dir).join("src/coraza_abi_stub.rs"); + + let mut cmd = Command::new(&rustc); + cmd.arg("--crate-type") + .arg("cdylib") + .arg("--crate-name") + .arg("coraza_abi_stub") + .arg("--edition") + .arg("2024") + .arg("-D") + .arg("warnings") + .arg("-C") + .arg("opt-level=0") + .arg("-o") + .arg(&output) + .arg(&source); + + if let (Ok(host), Ok(target)) = (std::env::var("HOST"), std::env::var("TARGET")) + && host != target + { + cmd.arg("--target").arg(target); + } + + let status = cmd.status().unwrap_or_else(|error| { + panic!("failed to spawn rustc for libcoraza ABI stub: {error}"); + }); + if !status.success() { + panic!("rustc failed to build libcoraza ABI stub: {status}"); + } + + println!( + "cargo:rustc-env=WARDNET_CORAZA_ABI_STUB={}", + output.display() + ); +} + +fn stub_output_path(out_dir: &str) -> PathBuf { + let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let filename = match os.as_str() { + "windows" => "coraza_abi_stub.dll", + "macos" => "libcoraza_abi_stub.dylib", + _ => "libcoraza_abi_stub.so", + }; + PathBuf::from(out_dir).join(filename) +} diff --git a/crates/waf-ids-core/src/lib.rs b/crates/waf-ids-core/src/lib.rs index 29167f91..291a3900 100644 --- a/crates/waf-ids-core/src/lib.rs +++ b/crates/waf-ids-core/src/lib.rs @@ -1265,7 +1265,7 @@ fn buyer_evidence_endpoints() -> Vec { "GET", "/api/waf/engine-status", "application/json", - "in-path Coraza sidecar vs ingest-hint enforcement status (no sidecar URL)", + "in-path Coraza libcoraza/sidecar vs ingest-hint enforcement status (no library path or sidecar URL)", false, ), buyer_evidence_endpoint( diff --git a/docs/architecture.md b/docs/architecture.md index f778cfb5..c38c5797 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ flowchart LR ## Near-Term Integrations -- **WAF**: Coraza/OWASP CRS audit JSON/NDJSON ingest is available at `POST /api/waf/coraza/audit` (admin token). Interrupted transactions and CRS rule messages become `SecurityEvent` rows and feed gateway enforcement (DNSBL + `client_ip`/`path` threat indicators) so subsequent gateway decisions block matching clients. When `CORAZA_WAF_URL` is set, each live `/gateway` transaction is POSTed to that sidecar and the response is parsed with the same Coraza audit adapter — CRS authority stays in the sidecar; Wardnet does not invent WAF rules. `GET /api/waf/engine-status` reports `coraza_sidecar` vs `ingest_hints_only`. In-process libcoraza embedding remains a follow-up. +- **WAF**: Coraza/OWASP CRS audit JSON/NDJSON ingest is available at `POST /api/waf/coraza/audit` (admin token). Interrupted transactions and CRS rule messages become `SecurityEvent` rows and feed gateway enforcement (DNSBL + `client_ip`/`path` threat indicators) so subsequent gateway decisions block matching clients. When `CORAZA_LIB_PATH` is set, each live `/gateway` transaction is evaluated in-process through the libcoraza C ABI (operator-supplied library + CRS file/directives). Otherwise, when `CORAZA_WAF_URL` is set, the transaction is POSTed to that sidecar and the response is parsed with the same Coraza audit adapter — CRS authority stays in Coraza; Wardnet does not invent WAF rules. `GET /api/waf/engine-status` reports `coraza_in_process`, `coraza_sidecar`, or `ingest_hints_only`. Suricata tail/shipper and detection-quality corpora remain follow-ups. - **IDS**: Suricata EVE JSON/NDJSON ingest is available at `POST /api/ids/suricata/eve` (admin token). Alert records become `SecurityEvent` rows for SOC export/KPI; full route correlation and live EVE tailing remain follow-ups. - **Threat Intelligence**: STIX 2.x indicator/bundle ingest is available at `POST /api/threat-intel/stix` (admin token), MISP Event/attribute JSON ingest at `POST /api/threat-intel/misp` (admin token), TAXII 2.1 collection poll at `POST /api/threat-intel/taxii/poll` (admin token; Basic/Bearer optional), and OpenCTI observable/indicator export ingest at `POST /api/threat-intel/opencti` (admin token). All update `ThreatIndicator` / `DnsblEntry` plus feed freshness. Live MISP REST pull and live OpenCTI GraphQL pull remain follow-ups. - **DNSBL Serving**: Hickory DNS should serve authoritative DNSBL responses directly after zone export semantics stabilize. diff --git a/docs/doctoring/in-path-coraza-adapter.md b/docs/doctoring/in-path-coraza-adapter.md index 9b8bdc84..a82c014c 100644 --- a/docs/doctoring/in-path-coraza-adapter.md +++ b/docs/doctoring/in-path-coraza-adapter.md @@ -42,7 +42,9 @@ https://doi.org/10.6028/NIST.SP.800-218 ## Operator next action -Point `CORAZA_WAF_URL` at a Coraza (or CRS-compatible) evaluate endpoint that -returns Coraza audit JSON. Set `PROVEN_ENGINE_FAIL_CLOSED=true` in production. -Confirm `GET /api/waf/engine-status` reports `mode=coraza_sidecar` and -`in_path=true` before exposing `/gateway`. +Prefer in-process libcoraza: set `CORAZA_LIB_PATH` to the shared library and +`CORAZA_RULES_PATH` (or `CORAZA_DIRECTIVES`) to a pinned OWASP CRS bundle. Point +`CORAZA_WAF_URL` at a Coraza evaluate endpoint only when the process cannot +load the library. Set `PROVEN_ENGINE_FAIL_CLOSED=true` in production. Confirm +`GET /api/waf/engine-status` reports `in_path=true` (`coraza_in_process` or +`coraza_sidecar`) before exposing `/gateway`. diff --git a/docs/doctoring/in-process-libcoraza.md b/docs/doctoring/in-process-libcoraza.md new file mode 100644 index 00000000..3b2de0a8 --- /dev/null +++ b/docs/doctoring/in-process-libcoraza.md @@ -0,0 +1,49 @@ +# Doctoring — in-process libcoraza adapter + +This note grounds the issue #86 remainder shipped this loop: live `/gateway` +transactions are evaluated by libcoraza inside the Wardnet process. IEEE PDFs +are not redistributed. + +## Adopted standards and literature + +Coraza. (n.d.). *Coraza Web Application Firewall*. +https://coraza.io/docs/ + +- **Design impact:** CRS remains the detection authority. Wardnet `dlopen`s + operator-supplied libcoraza (`CORAZA_LIB_PATH`) and drives the documented C + ABI (`coraza_new_waf_config`, `coraza_rules_add_file` / `coraza_rules_add`, + `coraza_process_uri` / headers / body, `coraza_intervention`). Builtin + signatures stay a residual scorer. + +OWASP Foundation. (n.d.). *OWASP Core Rule Set documentation*. +https://coreruleset.org/docs/ + +- **Design impact:** Rules come from `CORAZA_RULES_PATH` and optional + `CORAZA_DIRECTIVES`. An empty or missing ruleset fails startup before bind so + production cannot silently skip CRS. + +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 + +- **Design impact:** Fail-safe defaults. `PROVEN_ENGINE_FAIL_CLOSED` remains + opt-in per transaction; a configured library that cannot load is always + fail-closed at process start. Unset `CORAZA_LIB_PATH` keeps the sidecar path + (`CORAZA_WAF_URL`) from the previous slice. + +National Institute of Standards and Technology. (2022). *Secure Software +Development Framework (SSDF) version 1.1* (NIST SP 800-218). +https://doi.org/10.6028/NIST.SP.800-218 + +- **Design impact:** PW.1 / PW.4 — reuse a well-secured component. Building + libcoraza still needs Go+C; CI stays hermetic by compiling a fixture cdylib + that exports the same symbols. Production points `CORAZA_LIB_PATH` at a real + libcoraza build. + +## Operator next action + +Install libcoraza and a pinned OWASP CRS bundle. Set `CORAZA_LIB_PATH` and +`CORAZA_RULES_PATH`. Set `PROVEN_ENGINE_FAIL_CLOSED=true`. Confirm +`GET /api/waf/engine-status` reports `mode=coraza_in_process`, +`in_path=true`, and a non-zero `in_process_rules` before exposing `/gateway`. +The library path is not published on health or engine-status surfaces. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 2ceb737a..9bc864ea 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product and technical gap baseline -Snapshot date: 2026-08-23T15:20Z (exact-head inventory of then-open GitHub PRs +Snapshot date: 2026-08-23T16:01Z (exact-head inventory of then-open GitHub PRs and Issues plus operator-perceptible gaps). Update this file on every hourly loop. Commercial contract and `/api/commercial/readiness` remain **2B KRW**. The @@ -25,17 +25,18 @@ not “waiting on review/CI time”. | PR | Title | Head | Checks | Reviews | Merge blocker | | --- | --- | --- | --- | --- | --- | -| [#96](https://github.com/ContextualWisdomLab/wardnet/pull/96) | feat(security): fail-closed destination policy for outbound HTTP | `feat/issue-79-destination-policy` stacked on #95 | TCP-peer pin this hour (local fmt/test/clippy after pin). Copilot review to re-request. | Author this pass; Devin/Codex COMMENTED on prior head (TOCTOU P1 addressed by pin). | Org 2-approval + self-author. Merge #95 first. `gh pr merge` rejected by ruleset 18156473. | -| [#95](https://github.com/ContextualWisdomLab/wardnet/pull/95) | feat(waf): consult Coraza sidecar on live gateway transactions | `ba9ee3a` (`feat/issue-86-in-path-coraza`) | rust + Security Scan green; strix in_progress at snapshot; opencode-review queued. Copilot review requested. | Author this pass; Devin/Codex COMMENTED. | Org 2-approval + self-author. Do not re-implement sidecar slice. | -| [#94](https://github.com/ContextualWisdomLab/wardnet/pull/94) | fix(auth): fail closed without write-capable admin on public bind | `f31d960` (`fix/issue-78-fail-closed-credentials`) | Concurrent commit moved state validation before readiness (closes prior rust failure `binary_does_not_report_readiness_before_state_validation` on `b9daeb5`). Checks re-running. Copilot review requested. | Author `seonghobae`; Devin COMMENTED. | Org 2-approval + self-author. Do not `--admin` merge. Do not re-implement #78. | -| [#93](https://github.com/ContextualWisdomLab/wardnet/pull/93) | test(persistence): replace permission-based fault injection with a deterministic seam | `f77eb697` | rust + Security Scan green; **strix FAILURE** (job `97189711094`). Artifact `strix-reports` id `9493001688`. Root cause is org LiteLLM provider `openai-direct/gpt-5.6-luna` (0 vulns then fail-closed). Not a wardnet code finding. | Author `seonghobae`; Devin COMMENTED. | strix org-provider FAILURE + 2-approval + self-author. Do not rotate review-agent keys. | -| [#92](https://github.com/ContextualWisdomLab/wardnet/pull/92) | build(deps): bump github/codeql-action/upload-sarif from 4.37.6 to 4.37.7 | dependabot `17277d78` | All green. `--auto` squash already enabled. | Maintainer APPROVED (1 of 2). | **Second independent APPROVE missing**. `gh pr merge` rejected: "the base branch policy prohibits the merge." | -| [#91](https://github.com/ContextualWisdomLab/wardnet/pull/91) | build(deps): bump futures-util from 0.3.33 to 0.3.34 | dependabot `c4662cae` | All green. `--auto` squash already enabled. | Maintainer APPROVED (1 of 2). | Same as #92: second independent APPROVE missing. | -| [#90](https://github.com/ContextualWisdomLab/wardnet/pull/90) | feat(observability): export Wardnet events to SIEM and OpenTelemetry | `40f11b93` | All green (35). | Author `seonghobae`; CodeRabbit/Devin/GHAS COMMENTED. **0 unresolved threads** on exact head. | Org 2-approval + self-author. | -| [#88](https://github.com/ContextualWisdomLab/wardnet/pull/88) | feat(security): reject non-LiteLLM credentials before upstream | `41b21cfe` | All green (35). | Author `seonghobae`; CodeRabbit COMMENTED. **0 unresolved threads** on exact head. | Org 2-approval + self-author. | -| [#77](https://github.com/ContextualWisdomLab/wardnet/pull/77) | build(rust): pin and track Rust 1.97.1 | `a13c0865` | rust green; **strix FAILURE** (job `97001450437`). Same org-provider fail-closed as #93. | Author `seonghobae`; Devin COMMENTED. | strix org-provider FAILURE + 2-approval + self-author. | -| [#76](https://github.com/ContextualWisdomLab/wardnet/pull/76) | feat(ai): delegate SOC analysis to adaptive orchestration | `1cc49277` | All green (35). | Author `seonghobae`; CodeRabbit COMMENTED; opencode DISMISSED. **0 unresolved threads**. | Org 2-approval + self-author. | -| [#72](https://github.com/ContextualWisdomLab/wardnet/pull/72) | fix(deploy): require externally provisioned admin secret | `6881f479` | rust/coverage-evidence/opencode-review **success**; **strix FAILURE** (job `97198957113`, org provider). **0 unresolved threads**. | Latest opencode-agent **CHANGES_REQUESTED** was on `5fd9e2ba`, not this head. coverage-evidence is green on `6881f47` but opencode did not post APPROVE. Copilot review requested. | Sticky `CHANGES_REQUESTED` + 2-approval + self-author + strix org-provider FAILURE. | +| this PR | feat(waf): evaluate live gateway transactions with in-process libcoraza | `feat/issue-86-in-process-libcoraza` stacked on #96 | local fmt/test/clippy + two `/healthz` smokes this hour | Author this pass | Org 2-approval + self-author. Merge #95 then #96 first. Do not `--admin`. | +| [#96](https://github.com/ContextualWisdomLab/wardnet/pull/96) | feat(security): fail-closed destination policy for outbound HTTP | `7cacaf135179` (`feat/issue-79-destination-policy`) stacked on #95 | rust + fuzz green at last snapshot; remaining Devin threads are info/KV-deviation | Author this pass; Devin/Codex COMMENTED. Remaining unresolved: DESTINATION_* env (documented operational-config deviation), hostname-allowlist mixed answers (intended), sidecar loopback needs allowlist in production, pin-cap eviction info | Org 2-approval + self-author. Merge #95 first. Do not re-implement the TCP-peer pin. | +| [#95](https://github.com/ContextualWisdomLab/wardnet/pull/95) | feat(waf): consult Coraza sidecar on live gateway transactions | `ba9ee3a0b142` (`feat/issue-86-in-path-coraza`) | rust + Security Scan green at last snapshot | Author this pass; Devin/Codex COMMENTED | Org 2-approval + self-author. Do not re-implement sidecar slice. | +| [#94](https://github.com/ContextualWisdomLab/wardnet/pull/94) | fix(auth): fail closed without write-capable admin on public bind | `f31d960a0b52` (`fix/issue-78-fail-closed-credentials`) | Checks re-ran after readiness-order fix | Author `seonghobae`; Devin COMMENTED | Org 2-approval + self-author. Do not `--admin` merge. Do not re-implement #78. | +| [#93](https://github.com/ContextualWisdomLab/wardnet/pull/93) | test(persistence): replace permission-based fault injection with a deterministic seam | `f77eb69748ec` | rust + Security Scan green; **strix FAILURE** (org LiteLLM provider `openai-direct/gpt-5.6-luna`) | Author `seonghobae`; Devin COMMENTED | strix org-provider FAILURE + 2-approval + self-author. Do not rotate review-agent keys. | +| [#92](https://github.com/ContextualWisdomLab/wardnet/pull/92) | build(deps): bump github/codeql-action/upload-sarif from 4.37.6 to 4.37.7 | dependabot `17277d78d5e1` | All green. `--auto` squash already enabled. Copilot review re-requested this hour. | Maintainer APPROVED (1 of 2). | **Second independent APPROVE missing**. `gh pr merge` rejected by ruleset 18156473. | +| [#91](https://github.com/ContextualWisdomLab/wardnet/pull/91) | build(deps): bump futures-util from 0.3.33 to 0.3.34 | dependabot `c4662caebfa1` | All green. `--auto` squash already enabled. Copilot review re-requested this hour. | Maintainer APPROVED (1 of 2). | Same as #92: second independent APPROVE missing. | +| [#90](https://github.com/ContextualWisdomLab/wardnet/pull/90) | feat(observability): export Wardnet events to SIEM and OpenTelemetry | `40f11b93a972` | All green (35). | Author `seonghobae`; CodeRabbit/Devin/GHAS COMMENTED. **0 unresolved threads** on exact head. | Org 2-approval + self-author. | +| [#88](https://github.com/ContextualWisdomLab/wardnet/pull/88) | feat(security): reject non-LiteLLM credentials before upstream | `41b21cfe2168` | All green (35). | Author `seonghobae`; CodeRabbit COMMENTED. **0 unresolved threads** on exact head. | Org 2-approval + self-author. | +| [#77](https://github.com/ContextualWisdomLab/wardnet/pull/77) | build(rust): pin and track Rust 1.97.1 | `a13c08656177` | rust green; **strix FAILURE**. Same org-provider fail-closed as #93. | Author `seonghobae`; Devin COMMENTED. | strix org-provider FAILURE + 2-approval + self-author. | +| [#76](https://github.com/ContextualWisdomLab/wardnet/pull/76) | feat(ai): delegate SOC analysis to adaptive orchestration | `1cc492775d26` | All green (35). | Author `seonghobae`; CodeRabbit COMMENTED; opencode DISMISSED. **0 unresolved threads**. | Org 2-approval + self-author. | +| [#72](https://github.com/ContextualWisdomLab/wardnet/pull/72) | fix(deploy): require externally provisioned admin secret | `6881f4799188` | rust/coverage-evidence/opencode-review **success**; **strix FAILURE** (org provider). **0 unresolved threads**. | Latest opencode-agent **CHANGES_REQUESTED** was on `5fd9e2ba`, not this head. | Sticky `CHANGES_REQUESTED` + 2-approval + self-author + strix org-provider FAILURE. | Dependabot #91 and #92 remain auto-merge enabled; `gh pr merge` was rejected by ruleset `18156473` (not by failing Checks). Do not `--admin` merge. @@ -46,15 +47,15 @@ by ruleset `18156473` (not by failing Checks). Do not `--admin` merge. | --- | --- | --- | | [#89](https://github.com/ContextualWisdomLab/wardnet/issues/89) | Fail closed on invalid LiteLLM Virtual Keys and preserve safe upstream auth headers | medium | | [#87](https://github.com/ContextualWisdomLab/wardnet/issues/87) | [Production readiness] Close the evidence-backed Wardnet production gate | medium | -| [#86](https://github.com/ContextualWisdomLab/wardnet/issues/86) | [P0] Put proven WAF/IDS engines in the enforcement path and publish detection-quality evidence | **critical** | +| [#86](https://github.com/ContextualWisdomLab/wardnet/issues/86) | [P0] Put proven WAF/IDS engines in the enforcement path and publish detection-quality evidence | **critical — in-process + sidecar slices shipped, unmerged** | | [#85](https://github.com/ContextualWisdomLab/wardnet/issues/85) | [P1] Establish production telemetry, SLOs, incident response, and disaster-recovery evidence | high | | [#84](https://github.com/ContextualWisdomLab/wardnet/issues/84) | [P1] Build an immutable signed release, promotion, and rollback pipeline | high | | [#83](https://github.com/ContextualWisdomLab/wardnet/issues/83) | [P1] Add bounded distributed admission control, trusted client attribution, and overload behavior | high | | [#82](https://github.com/ContextualWisdomLab/wardnet/issues/82) | [P1] Integrate Keyverse identity, tenant authorization, consent, and human approval evidence | high (blocked) | | [#81](https://github.com/ContextualWisdomLab/wardnet/issues/81) | [P0] Add a transactional outbox and idempotent leased workers for external effects | **critical** | | [#80](https://github.com/ContextualWisdomLab/wardnet/issues/80) | [P0] Add an authoritative PostgreSQL control plane with tenant isolation and recoverable migrations | **critical** | -| [#79](https://github.com/ContextualWisdomLab/wardnet/issues/79) | [P0] Enforce a fail-closed destination policy for all outbound traffic | **critical** | -| [#78](https://github.com/ContextualWisdomLab/wardnet/issues/78) | [P0] Fail closed when management credentials are absent | **critical — closed in runtime this pass** | +| [#79](https://github.com/ContextualWisdomLab/wardnet/issues/79) | [P0] Enforce a fail-closed destination policy for all outbound traffic | **critical — closed in runtime on #96** | +| [#78](https://github.com/ContextualWisdomLab/wardnet/issues/78) | [P0] Fail closed when management credentials are absent | **critical — closed in runtime on #94** | | [#75](https://github.com/ContextualWisdomLab/wardnet/issues/75) | Rename Kubernetes manifest to wardnet.yaml after external-secret hardening lands | medium | | [#74](https://github.com/ContextualWisdomLab/wardnet/issues/74) | Make persistence failure tests deterministic across root and constrained filesystems | medium (PR #93) | | [#38](https://github.com/ContextualWisdomLab/wardnet/issues/38) | AI SOC: quarantine-sandbox malware analysis for attachment/link lures | medium (blocked) | @@ -70,23 +71,26 @@ still say `waf-ids-ai-soc`. Kubernetes manifest remains this pass: docs and health copy already mention Wardnet in newer surfaces; wholesale crate rename is deferred (not a merge blocker). -### Proven-engine enforcement (issue #86) — **in-path sidecar this pass** +### Proven-engine enforcement (issue #86) — **in-process libcoraza this pass** Coraza/Suricata ingest still maps proven-engine hits into DNSBL + threat -indicators, including an `engine_payload` hint from the audit URI query so the -same CRS payload is blocked for any client IP. This pass also consults a -Coraza sidecar on **each live `/gateway` transaction** when `CORAZA_WAF_URL` is -set (`src/proven_engine.rs`); the sidecar body is parsed with the existing -audit adapter. Sidecar outage is fail-closed when `PROVEN_ENGINE_FAIL_CLOSED` -is true. `GET /api/waf/engine-status` and `/healthz.proven_engine` report -`coraza_sidecar` vs `ingest_hints_only`. In-process libcoraza, Suricata -tail/shipper, and detection-quality corpora remain open. +indicators. PR #95 consults a Coraza sidecar on each live `/gateway` +transaction when `CORAZA_WAF_URL` is set. This pass also `dlopen`s +operator-supplied libcoraza (`CORAZA_LIB_PATH` + `CORAZA_RULES_PATH` and/or +`CORAZA_DIRECTIVES`) and evaluates the same live transactions through the +libcoraza C ABI (`src/coraza_inprocess.rs`). In-process wins over sidecar when +both are set. Missing library, missing rules, or an empty ruleset fail +startup before bind. `GET /api/waf/engine-status` and `/healthz.proven_engine` +report `coraza_in_process` / `coraza_sidecar` / `ingest_hints_only`. CI stays +hermetic with a fixture cdylib that exports the same symbols; production +points at a real libcoraza + CRS bundle. Suricata tail/shipper and +detection-quality corpora remain open. ### Identity (issue #82, Keyverse) Management auth is shared secrets (`X-Admin-Token`) plus optional multi-token RBAC. Keyverse (OIDC/SCIM/FIDO2) is not wired. Fail-closed (#78) is the -prerequisite shipped this pass. +prerequisite shipped on PR #94. ### Durable control plane (issue #80) @@ -94,46 +98,16 @@ Optional JSON file + atomic rename. Not PostgreSQL, no tenant isolation, no migrations, no hot-partition strategy. 3NF/snake_case two-word names apply when the store lands. -### Fail-closed credentials (issue #78) — **closed this pass** +### Fail-closed credentials (issue #78) — **closed on PR #94** -Shipped: +Shipped on `fix/issue-78-fail-closed-credentials`. Do not re-implement. -- `require_write_auth_for_bind` in `src/credentials.rs` (driven by unit tests - and by `run_from_env` / the real binary). -- Non-loopback `BIND_ADDR` without a write-capable principal exits before bind - (`tests/binary.rs::binary_fail_closes_non_loopback_listen_without_admin`). -- Loopback remains usable; `/healthz.auth_mode` is `development` or `production`. -- `401` vs `403` on management writes; constant-time compare; strict - `ADMIN_TOKENS` parser. +### Destination policy (issue #79) — **closed on PR #96 (review-hardening)** -Doctoring: `docs/doctoring/fail-closed-management-auth.md` (APA 7th). - -### Destination policy (issue #79) — **closed this pass (review-hardening)** - -Shipped in `src/destination.rs` and wired through route upsert, gateway proxy, -threat-intel fetch, Clearfolio, and SOC LLM. Default deny of loopback, RFC 1918, -link-local, ULA, CGNAT, documentation, cloud-metadata, and deprecated IPv6 -site-local (`fec0::/10`) unless `DESTINATION_ALLOWLIST` (or loopback development) -permits them. `DESTINATION_DENYLIST` wins. HTTP clients: no redirects, `no_proxy()`. - -This hour's still-valid review fixes (operator-visible): - -- CIDR allowlist matches apply **per resolved address** (a private CIDR cannot - exempt a sibling metadata/link-local answer). -- CIDR allowlist entries authorize non-default ports after resolve. -- Invalid CIDR prefixes (`/33`, `/129`) fail startup before bind. -- Hostnames that merely contain `0x` (e.g. `0x0.st`) are not hex IP literals. -- `AppState::load` / `new` default to production policy; `seeded()` opts into - development. `/healthz.destination_mode` reports the class. -- Blocking OS DNS runs on `spawn_blocking` with a 2s timeout. -- Persistence and destination-list validation complete **before** the readiness - line (binary test `binary_does_not_report_readiness_before_state_validation`). -- After evaluation, the outbound HTTP client connects **only** to those - addresses (Host/SNI preserved). Driving tests: - `proxy_request_connects_to_pinned_policy_addresses` and - `outbound_http_fails_closed_without_a_preauthorized_pin`. - -Remaining: Kubernetes NetworkPolicy examples as defense in depth. +Shipped in `src/destination.rs` including the TCP-peer pin. Do not re-implement +the pin. Remaining: Kubernetes NetworkPolicy examples as defense in depth. +Production sidecar URLs on loopback/private still need `DESTINATION_ALLOWLIST` +(in-process libcoraza does not). ### SIEM / OpenTelemetry (issue #85 / PR #90) @@ -161,8 +135,9 @@ future encryption-at-rest. ### Coverage / docstring bar Org 100% line/branch/docstring applies to **changed** surfaces this loop -(credentials gate, health `auth_mode`, 401/403 helper, binary fail-closed). -Remaining holes on untouched handlers stay listed for later loops. +(libcoraza loader, engine-status in-process fields, startup fail-closed, +gateway consult). Remaining holes on untouched handlers stay listed for later +loops. ### Ecosystem connectors (leverage order) @@ -175,26 +150,30 @@ Remaining holes on untouched handlers stay listed for later loops. ## This loop’s shipped gap -Issue **#79** TCP-peer pin on PR #96 (still unmerged; policy blocks). After -`assert_outbound` allows a host, reqwest DNS returns only those evaluated -addresses so a rebinding answer cannot reach loopback/private/metadata. -Operator-visible: `/healthz.destination_mode` plus pin tests -`proxy_request_connects_to_pinned_policy_addresses` (real `proxy_request` to -`pin-test.invalid` mapped to a local listener) and -`outbound_http_fails_closed_without_a_preauthorized_pin`. #78 remains on PR -#94. #86 sidecar remains on PR #95 — do not re-implement those slices. +Issue **#86** in-process libcoraza remainder (this branch, stacked on #96). +Operator-visible: `CORAZA_LIB_PATH` + `CORAZA_RULES_PATH`/`CORAZA_DIRECTIVES`; +`/healthz.proven_engine=coraza_in_process`; `GET /api/waf/engine-status` +`in_process_configured` / `in_process_rules`; missing library fails before +readiness (`tests/binary.rs::binary_fail_closes_when_libcoraza_path_is_missing`). +Driving tests: `gateway_blocks_live_request_from_in_process_libcoraza` and +`stub_engine_blocks_crs_probe_and_allows_clean`. Two real smokes this hour: +default `/healthz` + `/admin` (`ingest_hints_only`); stub-loaded `/healthz` + +`/api/commercial/readiness` (`coraza_in_process`, `target_sale_value_krw` +2_000_000_000). Do not re-implement #78, the #86 sidecar slice, or the #79 pin. ## Next hourly loop (do, do not report) -1. Second independent APPROVE on #91/#92 (Copilot requested; still 1/2; `--auto` - already enabled). -2. Keep #94/#95/#96 merge-ready. Do not `--admin` merge. Do not re-implement - #78 or the #86 sidecar slice. +1. Second independent APPROVE on #91/#92 (Copilot re-requested; still 1/2; + `--auto` already enabled). Merge if exact-HEAD second independent APPROVE + exists. Do not `--admin`. +2. Keep #94/#95/#96 and this in-process PR merge-ready. Merge order #95 then + #96 then this PR. Do not re-implement #78, the #86 sidecar, or the #79 pin. 3. Strix FAILURE on #72/#77/#93 is org LiteLLM provider infra, not wardnet code; do not rotate keys. Watch ContextualWisdomLab/.github branch `codex/strix-fail-closed-provider-evidence`. 4. Sticky opencode `CHANGES_REQUESTED` on #72 head `6881f47` — review job does not post APPROVE. -5. Next runtime gap if policy still blocks: in-process libcoraza remainder of - #86, or #80 durable control plane, or #81 outbox/workers. +5. Next runtime gap if policy still blocks: #80 durable PostgreSQL control + plane, or #81 outbox/workers, or #86 detection-quality corpora / Suricata + tail. 6. Refresh this file’s PR/Issue tables from `gh pr list` / `gh issue list`. diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md index 9bbbf8c2..ac081326 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -103,7 +103,7 @@ This baseline is suitable for local and controlled lab deployments. Internet-fac - durable database storage with backups - SSO/OIDC federation (multi-token RBAC with readonly role and audit-log auth are available) - asynchronous event persistence or a database-backed event store for high-throughput gateway traffic -- In-process libcoraza embedding (HTTP sidecar consult at `CORAZA_WAF_URL` evaluates each live `/gateway` transaction; audit ingest at `POST /api/waf/coraza/audit` still fuses block hits into DNSBL/`client_ip` indicators). Set `PROVEN_ENGINE_FAIL_CLOSED=true` in production so a sidecar outage does not silently allow traffic. +- Detection-quality corpora and Suricata EVE tail/shipper remain open. In-process libcoraza (`CORAZA_LIB_PATH` + `CORAZA_RULES_PATH` or `CORAZA_DIRECTIVES`) evaluates each live `/gateway` transaction; otherwise HTTP sidecar consult at `CORAZA_WAF_URL`. Audit ingest at `POST /api/waf/coraza/audit` still fuses block hits into DNSBL/`client_ip` indicators. Set `PROVEN_ENGINE_FAIL_CLOSED=true` in production so an engine outage does not silently allow traffic. - Live Suricata EVE tailing / shipper (HTTP ingest of EVE alerts is available at `POST /api/ids/suricata/eve`) - Live MISP REST pull or live OpenCTI GraphQL pull (HTTP STIX/MISP/OpenCTI document ingest and TAXII 2.1 poll are available at `POST /api/threat-intel/stix`, `POST /api/threat-intel/misp`, `POST /api/threat-intel/opencti`, and `POST /api/threat-intel/taxii/poll`) - human approval workflow for AI SOC recommendations that change enforcement diff --git a/src/coraza_abi_stub.rs b/src/coraza_abi_stub.rs new file mode 100644 index 00000000..3e07b413 --- /dev/null +++ b/src/coraza_abi_stub.rs @@ -0,0 +1,311 @@ +//! Test-only libcoraza C ABI fixture. +//! +//! Compiled as a cdylib by `build.rs`. It is not a WAF: it implements the +//! current libcoraza export surface so Wardnet can exercise in-process loading +//! without Go at CI build time. Interruptions fire only for the documented +//! `crs-probe=1` contract used by the sidecar tests. + +#![deny(warnings)] + +use std::collections::HashMap; +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int}; +use std::sync::{Mutex, OnceLock}; + +const CORAZA_ERROR: c_int = -1; +const CORAZA_OK: c_int = 0; +const CORAZA_INTERRUPTION: c_int = 1; + +#[repr(C)] +pub struct CorazaIntervention { + action: *mut c_char, + status: c_int, + pause: c_int, + disruptive: c_int, + data: *mut c_char, + rule_id: c_int, +} + +struct Config { + rules: i32, +} + +struct Waf { + rules: i32, +} + +struct Tx { + uri: String, + interrupted: bool, + rule_id: i32, +} + +struct Store { + next: usize, + configs: HashMap, + wafs: HashMap, + txs: HashMap, +} + +fn store() -> &'static Mutex { + static STORE: OnceLock> = OnceLock::new(); + STORE.get_or_init(|| { + Mutex::new(Store { + next: 1, + configs: HashMap::new(), + wafs: HashMap::new(), + txs: HashMap::new(), + }) + }) +} + +fn alloc_id(store: &mut Store) -> usize { + let id = store.next; + store.next += 1; + id +} + +fn c_str<'a>(ptr: *const c_char) -> Result<&'a str, ()> { + if ptr.is_null() { + return Err(()); + } + unsafe { CStr::from_ptr(ptr) }.to_str().map_err(|_| ()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_new_waf_config() -> usize { + let mut store = store().lock().expect("stub lock"); + let id = alloc_id(&mut store); + store.configs.insert(id, Config { rules: 0 }); + id +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_rules_add(config: usize, _directives: *const c_char) -> c_int { + let mut store = store().lock().expect("stub lock"); + match store.configs.get_mut(&config) { + Some(item) => { + item.rules += 1; + CORAZA_OK + } + None => CORAZA_ERROR, + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_rules_add_file(config: usize, file: *const c_char) -> c_int { + let Ok(path) = c_str(file) else { + return CORAZA_ERROR; + }; + if !std::path::Path::new(path).is_file() { + return CORAZA_ERROR; + } + let mut store = store().lock().expect("stub lock"); + match store.configs.get_mut(&config) { + Some(item) => { + item.rules += 1; + CORAZA_OK + } + None => CORAZA_ERROR, + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_free_waf_config(config: usize) -> c_int { + let mut store = store().lock().expect("stub lock"); + store.configs.remove(&config); + CORAZA_OK +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_new_waf(config: usize, er: *mut *mut c_char) -> usize { + if !er.is_null() { + unsafe { + *er = std::ptr::null_mut(); + } + } + let mut store = store().lock().expect("stub lock"); + let Some(cfg) = store.configs.get(&config) else { + if !er.is_null() { + let msg = CString::new("invalid waf config").expect("static error"); + unsafe { + *er = msg.into_raw(); + } + } + return 0; + }; + let rules = cfg.rules; + let id = alloc_id(&mut store); + store.wafs.insert(id, Waf { rules }); + id +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_new_transaction(waf: usize) -> usize { + let mut store = store().lock().expect("stub lock"); + if !store.wafs.contains_key(&waf) { + return 0; + } + let id = alloc_id(&mut store); + store.txs.insert( + id, + Tx { + uri: String::new(), + interrupted: false, + rule_id: 0, + }, + ); + id +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_process_connection( + _tx: usize, + _source: *const c_char, + _client_port: c_int, + _server_host: *const c_char, + _server_port: c_int, +) -> c_int { + CORAZA_OK +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_process_uri( + tx: usize, + uri: *const c_char, + _method: *const c_char, + _proto: *const c_char, +) -> c_int { + let Ok(uri) = c_str(uri) else { + return CORAZA_ERROR; + }; + let mut store = store().lock().expect("stub lock"); + let Some(tx) = store.txs.get_mut(&tx) else { + return CORAZA_ERROR; + }; + tx.uri = uri.to_string(); + if uri.contains("crs-probe=1") { + tx.interrupted = true; + tx.rule_id = 942100; + } + CORAZA_OK +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_add_request_header( + _tx: usize, + _name: *const c_char, + _name_len: c_int, + _value: *const c_char, + _value_len: c_int, +) -> c_int { + CORAZA_OK +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_process_request_headers(tx: usize) -> c_int { + let store = store().lock().expect("stub lock"); + match store.txs.get(&tx) { + Some(tx) if tx.interrupted => CORAZA_INTERRUPTION, + Some(_) => CORAZA_OK, + None => CORAZA_ERROR, + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_append_request_body( + _tx: usize, + _data: *const u8, + _length: c_int, +) -> c_int { + CORAZA_OK +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_process_request_body(tx: usize) -> c_int { + let store = store().lock().expect("stub lock"); + match store.txs.get(&tx) { + Some(tx) if tx.interrupted => CORAZA_INTERRUPTION, + Some(_) => CORAZA_OK, + None => CORAZA_ERROR, + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_intervention(tx: usize) -> *mut CorazaIntervention { + let store = store().lock().expect("stub lock"); + let Some(tx) = store.txs.get(&tx) else { + return std::ptr::null_mut(); + }; + if !tx.interrupted { + return std::ptr::null_mut(); + } + let action = CString::new("deny").expect("static action"); + let data = CString::new("SQL Injection Attack Detected via libinjection") + .expect("static data"); + let it = Box::new(CorazaIntervention { + action: action.into_raw(), + status: 403, + pause: 0, + disruptive: 1, + data: data.into_raw(), + rule_id: tx.rule_id, + }); + Box::into_raw(it) +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_free_intervention(it: *mut CorazaIntervention) -> c_int { + if it.is_null() { + return CORAZA_ERROR; + } + unsafe { + let it = Box::from_raw(it); + if !it.action.is_null() { + drop(CString::from_raw(it.action)); + } + if !it.data.is_null() { + drop(CString::from_raw(it.data)); + } + } + CORAZA_OK +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_free_transaction(tx: usize) -> c_int { + let mut store = store().lock().expect("stub lock"); + store.txs.remove(&tx); + CORAZA_OK +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_free_waf(waf: usize) -> c_int { + let mut store = store().lock().expect("stub lock"); + store.wafs.remove(&waf); + CORAZA_OK +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_rules_count(waf: usize) -> c_int { + let store = store().lock().expect("stub lock"); + store + .wafs + .get(&waf) + .map(|waf| waf.rules) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_free_string(s: *mut c_char) { + if s.is_null() { + return; + } + unsafe { + drop(CString::from_raw(s)); + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn coraza_process_logging(_tx: usize) -> c_int { + CORAZA_OK +} diff --git a/src/coraza_inprocess.rs b/src/coraza_inprocess.rs new file mode 100644 index 00000000..ede045f5 --- /dev/null +++ b/src/coraza_inprocess.rs @@ -0,0 +1,475 @@ +//! In-process libcoraza loader (issue #86 remainder). +//! +//! Wardnet does not reimplement OWASP CRS. When `CORAZA_LIB_PATH` is set, the +//! process `dlopen`s operator-supplied libcoraza and evaluates each live +//! `/gateway` transaction through the C ABI. CI uses a fixture cdylib that +//! exports the same symbols; production points at a real libcoraza + CRS file. + +use std::ffi::{CStr, CString}; +use std::net::IpAddr; +use std::os::raw::{c_char, c_int}; +use std::path::Path; +use std::ptr; + +use libloading::Library; + +use crate::coraza_audit::CorazaIngestedHit; +use crate::proven_engine::ProvenEngineOutcome; + +const CORAZA_ERROR: c_int = -1; +const CORAZA_INTERRUPTION: c_int = 1; + +#[repr(C)] +struct CorazaIntervention { + action: *mut c_char, + status: c_int, + pause: c_int, + disruptive: c_int, + data: *mut c_char, + rule_id: c_int, +} + +struct Api { + new_waf_config: unsafe extern "C" fn() -> usize, + rules_add: unsafe extern "C" fn(usize, *const c_char) -> c_int, + rules_add_file: unsafe extern "C" fn(usize, *const c_char) -> c_int, + free_waf_config: unsafe extern "C" fn(usize) -> c_int, + new_waf: unsafe extern "C" fn(usize, *mut *mut c_char) -> usize, + new_transaction: unsafe extern "C" fn(usize) -> usize, + process_connection: + unsafe extern "C" fn(usize, *const c_char, c_int, *const c_char, c_int) -> c_int, + process_uri: unsafe extern "C" fn(usize, *const c_char, *const c_char, *const c_char) -> c_int, + add_request_header: + unsafe extern "C" fn(usize, *const c_char, c_int, *const c_char, c_int) -> c_int, + process_request_headers: unsafe extern "C" fn(usize) -> c_int, + append_request_body: unsafe extern "C" fn(usize, *const u8, c_int) -> c_int, + process_request_body: unsafe extern "C" fn(usize) -> c_int, + intervention: unsafe extern "C" fn(usize) -> *mut CorazaIntervention, + free_intervention: unsafe extern "C" fn(*mut CorazaIntervention) -> c_int, + free_transaction: unsafe extern "C" fn(usize) -> c_int, + free_waf: unsafe extern "C" fn(usize) -> c_int, + rules_count: unsafe extern "C" fn(usize) -> c_int, + free_string: unsafe extern "C" fn(*mut c_char), + process_logging: unsafe extern "C" fn(usize) -> c_int, +} + +/// Loaded libcoraza instance. The library handle outlives every function +/// pointer copied out of it. +pub struct InProcessCoraza { + api: Api, + waf: usize, + rules: i32, + _lib: Library, +} + +impl std::fmt::Debug for InProcessCoraza { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InProcessCoraza") + .field("rules", &self.rules) + .finish() + } +} + +impl InProcessCoraza { + /// `dlopen` `lib_path` and construct a WAF from a CRS file and/or extra + /// SecLang directives. Empty rulesets are rejected so a missing CRS cannot + /// silently become allow. + pub fn load( + lib_path: &Path, + rules_path: Option<&Path>, + directives: Option<&str>, + ) -> Result { + if !lib_path.exists() { + return Err(format!( + "CORAZA_LIB_PATH {} does not exist", + lib_path.display() + )); + } + if rules_path.is_none() && directives.is_none_or(|text| text.trim().is_empty()) { + return Err( + "CORAZA_LIB_PATH requires CORAZA_RULES_PATH or CORAZA_DIRECTIVES".to_string(), + ); + } + if let Some(path) = rules_path + && !path.is_file() + { + return Err(format!( + "CORAZA_RULES_PATH {} is not a file", + path.display() + )); + } + + // SAFETY: operator-supplied path; we only call documented libcoraza + // exports after looking up symbols by name. + let lib = unsafe { Library::new(lib_path) }.map_err(|error| { + format!( + "failed to load libcoraza from {}: {error}", + lib_path.display() + ) + })?; + let api = load_api(&lib)?; + + // SAFETY: symbols came from this library; config/waf handles are + // opaque libcoraza values used only with those symbols. + let loaded = unsafe { construct_waf(&api, rules_path, directives)? }; + Ok(Self { + api, + waf: loaded.waf, + rules: loaded.rules, + _lib: lib, + }) + } + + /// Number of directive sources loaded into this WAF (file and/or string). + pub fn rules(&self) -> i32 { + self.rules + } + + /// Evaluate one HTTP transaction. Never includes the library path in the + /// outcome reason (that can identify a host layout). + pub fn evaluate( + &self, + method: &str, + uri: &str, + body: &str, + client_ip: Option, + ) -> ProvenEngineOutcome { + match self.evaluate_inner(method, uri, body, client_ip) { + Ok(outcome) => outcome, + Err(reason) => ProvenEngineOutcome::Unavailable { reason }, + } + } + + fn evaluate_inner( + &self, + method: &str, + uri: &str, + body: &str, + client_ip: Option, + ) -> Result { + let method_c = c_string(method)?; + let uri_c = c_string(uri)?; + let proto = c_string("HTTP/1.1")?; + let source = c_string( + &client_ip + .map(|ip| ip.to_string()) + .unwrap_or_else(|| "0.0.0.0".to_string()), + )?; + let server = c_string("")?; + + // SAFETY: `self.waf` was created by `coraza_new_waf` on this library + // and is freed only in Drop. Transaction handles stay local. + unsafe { + let tx = (self.api.new_transaction)(self.waf); + if tx == 0 { + return Err("coraza in-process failed to open a transaction".to_string()); + } + let tx = TxGuard { api: &self.api, tx }; + if (self.api.process_connection)(tx.tx, source.as_ptr(), 0, server.as_ptr(), 80) + == CORAZA_ERROR + { + return Err("coraza in-process connection phase failed".to_string()); + } + if (self.api.process_uri)(tx.tx, uri_c.as_ptr(), method_c.as_ptr(), proto.as_ptr()) + == CORAZA_ERROR + { + return Err("coraza in-process uri phase failed".to_string()); + } + let host_name = c_string("Host")?; + let host_value = c_string("wardnet")?; + let _ = (self.api.add_request_header)( + tx.tx, + host_name.as_ptr(), + c_len("Host".len())?, + host_value.as_ptr(), + c_len("wardnet".len())?, + ); + let header_rc = (self.api.process_request_headers)(tx.tx); + if header_rc == CORAZA_ERROR { + return Err("coraza in-process header phase failed".to_string()); + } + if header_rc == CORAZA_INTERRUPTION { + return Ok(hit_from_intervention(&self.api, tx.tx, uri, client_ip)); + } + if !body.is_empty() { + let rc = (self.api.append_request_body)(tx.tx, body.as_ptr(), c_len(body.len())?); + if rc == CORAZA_ERROR { + return Err("coraza in-process body write failed".to_string()); + } + } + let body_rc = (self.api.process_request_body)(tx.tx); + if body_rc == CORAZA_ERROR { + return Err("coraza in-process body phase failed".to_string()); + } + if body_rc == CORAZA_INTERRUPTION { + return Ok(hit_from_intervention(&self.api, tx.tx, uri, client_ip)); + } + Ok(ProvenEngineOutcome::Clean) + } + } +} + +impl Drop for InProcessCoraza { + fn drop(&mut self) { + // SAFETY: `self.waf` is a live libcoraza WAF handle owned by this + // value; the library is still loaded (`_lib` drops after this Drop). + unsafe { + (self.api.free_waf)(self.waf); + } + } +} + +// libcoraza WAF instances are documented as concurrent-safe; function pointers +// copied from the loaded module are immutable. The test stub serializes its +// handle maps with a mutex. +unsafe impl Send for InProcessCoraza {} +unsafe impl Sync for InProcessCoraza {} + +struct LoadedWaf { + waf: usize, + rules: i32, +} + +struct TxGuard<'a> { + api: &'a Api, + tx: usize, +} + +impl Drop for TxGuard<'_> { + fn drop(&mut self) { + unsafe { + (self.api.process_logging)(self.tx); + (self.api.free_transaction)(self.tx); + } + } +} + +fn load_api(lib: &Library) -> Result { + // SAFETY: each lookup is a named libcoraza export; the `Library` outlives + // the copied function pointers because `_lib` is stored on the engine. + unsafe { + Ok(Api { + new_waf_config: symbol(lib, b"coraza_new_waf_config\0")?, + rules_add: symbol(lib, b"coraza_rules_add\0")?, + rules_add_file: symbol(lib, b"coraza_rules_add_file\0")?, + free_waf_config: symbol(lib, b"coraza_free_waf_config\0")?, + new_waf: symbol(lib, b"coraza_new_waf\0")?, + new_transaction: symbol(lib, b"coraza_new_transaction\0")?, + process_connection: symbol(lib, b"coraza_process_connection\0")?, + process_uri: symbol(lib, b"coraza_process_uri\0")?, + add_request_header: symbol(lib, b"coraza_add_request_header\0")?, + process_request_headers: symbol(lib, b"coraza_process_request_headers\0")?, + append_request_body: symbol(lib, b"coraza_append_request_body\0")?, + process_request_body: symbol(lib, b"coraza_process_request_body\0")?, + intervention: symbol(lib, b"coraza_intervention\0")?, + free_intervention: symbol(lib, b"coraza_free_intervention\0")?, + free_transaction: symbol(lib, b"coraza_free_transaction\0")?, + free_waf: symbol(lib, b"coraza_free_waf\0")?, + rules_count: symbol(lib, b"coraza_rules_count\0")?, + free_string: symbol(lib, b"coraza_free_string\0")?, + process_logging: symbol(lib, b"coraza_process_logging\0")?, + }) + } +} + +unsafe fn symbol(lib: &Library, name: &[u8]) -> Result { + let label = std::str::from_utf8(name) + .unwrap_or("symbol") + .trim_end_matches('\0'); + let loaded = unsafe { lib.get::(name) } + .map_err(|error| format!("libcoraza missing symbol {label}: {error}"))?; + Ok(*loaded) +} + +unsafe fn construct_waf( + api: &Api, + rules_path: Option<&Path>, + directives: Option<&str>, +) -> Result { + let config = unsafe { (api.new_waf_config)() }; + if config == 0 { + return Err("libcoraza failed to allocate a WAF config".to_string()); + } + let config = ConfigGuard { api, config }; + if let Some(path) = rules_path { + let path_c = c_string(&path.to_string_lossy())?; + let rc = unsafe { (api.rules_add_file)(config.config, path_c.as_ptr()) }; + if rc == CORAZA_ERROR { + return Err("libcoraza rejected CORAZA_RULES_PATH".to_string()); + } + } + if let Some(text) = directives.filter(|text| !text.trim().is_empty()) { + let text_c = c_string(text)?; + let rc = unsafe { (api.rules_add)(config.config, text_c.as_ptr()) }; + if rc == CORAZA_ERROR { + return Err("libcoraza rejected CORAZA_DIRECTIVES".to_string()); + } + } + let mut err_ptr: *mut c_char = ptr::null_mut(); + let waf = unsafe { (api.new_waf)(config.config, &mut err_ptr) }; + if !err_ptr.is_null() { + let reason = unsafe { take_c_string(api, err_ptr) }; + return Err(format!("libcoraza failed to build WAF: {reason}")); + } + if waf == 0 { + return Err("libcoraza failed to build WAF".to_string()); + } + let rules = unsafe { (api.rules_count)(waf) }; + if rules <= 0 { + unsafe { + (api.free_waf)(waf); + } + return Err("libcoraza loaded an empty ruleset".to_string()); + } + Ok(LoadedWaf { waf, rules }) +} + +struct ConfigGuard<'a> { + api: &'a Api, + config: usize, +} + +impl Drop for ConfigGuard<'_> { + fn drop(&mut self) { + unsafe { + (self.api.free_waf_config)(self.config); + } + } +} + +unsafe fn hit_from_intervention( + api: &Api, + tx: usize, + uri: &str, + client_ip: Option, +) -> ProvenEngineOutcome { + let ptr = unsafe { (api.intervention)(tx) }; + if ptr.is_null() { + return ProvenEngineOutcome::Hit(CorazaIngestedHit { + client_ip, + action: "block".to_string(), + reason: "coraza/crs: transaction interrupted".to_string(), + score: 50, + path: uri.to_string(), + timestamp_unix: None, + }); + } + let it = unsafe { &*ptr }; + let action_raw = unsafe { optional_cstr(it.action) }; + let data = unsafe { optional_cstr(it.data) }; + let action = if action_raw == "deny" + || action_raw == "drop" + || action_raw.is_empty() + || it.disruptive != 0 + { + "block" + } else { + "monitor" + }; + let mut reason = format!("coraza/crs: rule {}", it.rule_id); + if !data.is_empty() { + reason.push_str(": "); + reason.push_str(&data); + } + let score = if action == "block" { 50 } else { 25 }; + unsafe { + (api.free_intervention)(ptr); + } + ProvenEngineOutcome::Hit(CorazaIngestedHit { + client_ip, + action: action.to_string(), + reason, + score, + path: uri.to_string(), + timestamp_unix: None, + }) +} + +unsafe fn optional_cstr(ptr: *const c_char) -> String { + if ptr.is_null() { + return String::new(); + } + unsafe { CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned() +} + +unsafe fn take_c_string(api: &Api, ptr: *mut c_char) -> String { + let text = unsafe { optional_cstr(ptr) }; + unsafe { + (api.free_string)(ptr); + } + text +} + +fn c_string(text: &str) -> Result { + CString::new(text).map_err(|_| "coraza in-process input contained an interior NUL".to_string()) +} + +fn c_len(len: usize) -> Result { + c_int::try_from(len).map_err(|_| "coraza in-process body exceeds C int length".to_string()) +} + +#[cfg(test)] +pub(crate) fn load_stub_engine() -> std::sync::Arc { + let lib = Path::new(env!("WARDNET_CORAZA_ABI_STUB")); + let dir = std::env::temp_dir().join(format!( + "wardnet-coraza-rules-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("create rules dir"); + let rules = dir.join("crs.conf"); + std::fs::write(&rules, "SecRuleEngine On\n").expect("write rules fixture"); + std::sync::Arc::new(InProcessCoraza::load(lib, Some(&rules), None).expect("load stub")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_library_fails_closed() { + let err = InProcessCoraza::load( + Path::new("/no/such/libcoraza.so"), + None, + Some("SecRuleEngine On"), + ) + .unwrap_err(); + assert!( + err.contains("does not exist"), + "missing library must fail before bind: {err}" + ); + } + + #[test] + fn library_without_rules_fails_closed() { + let err = InProcessCoraza::load(Path::new(env!("WARDNET_CORAZA_ABI_STUB")), None, None) + .unwrap_err(); + assert!( + err.contains("CORAZA_RULES_PATH") || err.contains("CORAZA_DIRECTIVES"), + "empty ruleset must not silently allow: {err}" + ); + } + + #[test] + fn stub_engine_blocks_crs_probe_and_allows_clean() { + let engine = load_stub_engine(); + assert!(engine.rules() >= 1); + match engine.evaluate("GET", "/app?crs-probe=1", "", None) { + ProvenEngineOutcome::Hit(hit) => { + assert_eq!(hit.action, "block"); + assert!(hit.reason.contains("942100"), "{}", hit.reason); + assert_eq!(hit.path, "/app?crs-probe=1"); + } + other => panic!("expected hit, got {other:?}"), + } + assert_eq!( + engine.evaluate("GET", "/app?q=hello", "", None), + ProvenEngineOutcome::Clean + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6eba6163..ec443e29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,7 @@ pub use waf_ids_core::{ }; mod coraza_audit; +mod coraza_inprocess; mod credentials; mod destination; mod misp_import; @@ -75,7 +76,7 @@ pub struct AppState { // Optional LLM SOC-analysis backend (OpenAI-compatible, e.g. the // contextual-orchestrator gateway). `None` unless configured. soc_llm: Option, - /// In-path Coraza sidecar consult. Disabled unless `CORAZA_WAF_URL` is set. + /// In-path Coraza consult (in-process libcoraza and/or sidecar). proven_engine: ProvenEngineConfig, /// Fail-closed destination policy for every outbound http/https call. destination: DestinationPolicy, @@ -174,7 +175,8 @@ impl AppState { self } - /// Configure the in-path Coraza sidecar adapter. Builder-style. + /// Configure the in-path Coraza adapter (sidecar and/or libcoraza). + /// Builder-style. pub fn with_proven_engine(mut self, config: ProvenEngineConfig) -> Self { self.proven_engine = config; self @@ -434,9 +436,9 @@ pub struct HealthStatus { pub credentials_source: String, /// True when at least one admin write token is configured. pub admin_auth_configured: bool, - /// `coraza_sidecar` when `CORAZA_WAF_URL` is set; otherwise `ingest_hints_only`. + /// `coraza_in_process`, `coraza_sidecar`, or `ingest_hints_only`. pub proven_engine: String, - /// True when a configured sidecar outage fails the live transaction closed. + /// True when a configured engine outage fails the live transaction closed. pub proven_engine_fail_closed: bool, /// `production` (fail-closed classes) or `development` (loopback class permitted). pub destination_mode: String, @@ -2352,7 +2354,7 @@ async fn gateway( } } -/// Consult the configured Coraza sidecar for this live transaction. +/// Consult in-process libcoraza first; otherwise the Coraza sidecar. async fn consult_proven_engine( state: &AppState, method: &str, @@ -2360,6 +2362,21 @@ async fn consult_proven_engine( body_text: &str, client_ip: Option, ) -> ProvenEngineOutcome { + if let Some(engine) = state.proven_engine.in_process.clone() { + let method = method.to_owned(); + let request_uri = request_uri.to_owned(); + let body_text = body_text.to_owned(); + return match tokio::task::spawn_blocking(move || { + engine.evaluate(&method, &request_uri, &body_text, client_ip) + }) + .await + { + Ok(outcome) => outcome, + Err(_) => ProvenEngineOutcome::Unavailable { + reason: "coraza in-process task failed".to_string(), + }, + }; + } let Some(url) = state .proven_engine .sidecar_url @@ -2377,14 +2394,20 @@ async fn consult_proven_engine( .await } -/// Operator-visible proven-engine status (no sidecar URL; that may identify -/// an internal host). +/// Operator-visible proven-engine status (no sidecar URL or library path; +/// those may identify an internal host). async fn waf_engine_status(State(state): State) -> Json { Json(serde_json::json!({ "mode": state.proven_engine.mode(), "in_path": state.proven_engine.in_path(), "fail_closed": state.proven_engine.fail_closed, - "sidecar_configured": state.proven_engine.in_path(), + "sidecar_configured": state.proven_engine.sidecar_configured(), + "in_process_configured": state.proven_engine.in_process.is_some(), + "in_process_rules": state + .proven_engine + .in_process + .as_ref() + .map(|engine| engine.rules()), })) } @@ -2946,7 +2969,7 @@ input,select{font:inherit;min-height:44px;padding:0 12px;border:1px solid var(--

POST admin-authenticated Suricata EVE JSON/NDJSON alerts to /api/ids/suricata/eve. Alerts become SOC security events (no hand-rolled IDS rules).

Coraza / OWASP CRS WAF ingest

-

POST admin-authenticated Coraza audit JSON/NDJSON to /api/waf/coraza/audit. CRS rule matches become SOC events and block-grade hits also seed DNSBL/client_ip indicators so the gateway enforces subsequent requests. Set CORAZA_WAF_URL so each live /gateway transaction is evaluated by a Coraza sidecar (do not invent WAF rules here). See GET /api/waf/engine-status.

+

POST admin-authenticated Coraza audit JSON/NDJSON to /api/waf/coraza/audit. CRS rule matches become SOC events and block-grade hits also seed DNSBL/client_ip indicators so the gateway enforces subsequent requests. Set CORAZA_LIB_PATH plus CORAZA_RULES_PATH (or CORAZA_DIRECTIVES) for in-process libcoraza, or CORAZA_WAF_URL for a sidecar. Do not invent WAF rules here. See GET /api/waf/engine-status.

STIX threat intelligence

POST admin-authenticated STIX 2.x indicator or bundle JSON to /api/threat-intel/stix (optional query: feed_id, source, ttl_seconds). Maps ipv4/domain/url patterns into threats/DNSBL for gateway scoring.

@@ -3275,12 +3298,33 @@ pub async fn run_from_env( std::env::var("PROVEN_ENGINE_FAIL_CLOSED").ok().as_deref(), false, )?; - let proven_engine = match coraza_waf_url { - Some(url) => ProvenEngineConfig::sidecar(url, proven_engine_fail_closed), - None => ProvenEngineConfig { - sidecar_url: None, - fail_closed: proven_engine_fail_closed, - }, + let coraza_lib_path = std::env::var("CORAZA_LIB_PATH") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let coraza_rules_path = std::env::var("CORAZA_RULES_PATH") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let coraza_directives = std::env::var("CORAZA_DIRECTIVES") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let in_process = match coraza_lib_path { + Some(path) => Some(Arc::new( + crate::coraza_inprocess::InProcessCoraza::load( + Path::new(&path), + coraza_rules_path.as_deref().map(Path::new), + coraza_directives.as_deref(), + ) + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?, + )), + None => None, + }; + let proven_engine = ProvenEngineConfig { + sidecar_url: coraza_waf_url, + fail_closed: proven_engine_fail_closed, + in_process, }; let destination_policy = startup_destination_policy(&bind_addr)?; let state = AppState::load(config) @@ -3342,6 +3386,9 @@ mod tests { "RATE_LIMIT_WINDOW", "MAX_BODY_BYTES", "CORAZA_WAF_URL", + "CORAZA_LIB_PATH", + "CORAZA_RULES_PATH", + "CORAZA_DIRECTIVES", "PROVEN_ENGINE_FAIL_CLOSED", "DESTINATION_ALLOWLIST", "DESTINATION_DENYLIST", @@ -5247,6 +5294,8 @@ mod tests { assert_eq!(status["mode"], "coraza_sidecar"); assert_eq!(status["in_path"], true); assert_eq!(status["fail_closed"], true); + assert_eq!(status["sidecar_configured"], true); + assert_eq!(status["in_process_configured"], false); let health: HealthStatus = json_body(app_request(&app, empty_request(Method::GET, "/healthz")).await).await; @@ -5293,6 +5342,68 @@ mod tests { ); } + #[tokio::test] + async fn gateway_blocks_live_request_from_in_process_libcoraza() { + let engine = crate::coraza_inprocess::load_stub_engine(); + let state = AppState::seeded(Some("secret".to_string())) + .with_proven_engine(ProvenEngineConfig::in_process(engine, true)); + let app = build_app(state); + + let status = json_body::( + app_request(&app, empty_request(Method::GET, "/api/waf/engine-status")).await, + ) + .await; + assert_eq!(status["mode"], "coraza_in_process"); + assert_eq!(status["in_path"], true); + assert_eq!(status["in_process_configured"], true); + assert_eq!(status["sidecar_configured"], false); + assert!(status["in_process_rules"].as_i64().unwrap_or(0) >= 1); + + let health: HealthStatus = + json_body(app_request(&app, empty_request(Method::GET, "/healthz")).await).await; + assert_eq!(health.proven_engine, "coraza_in_process"); + assert!(health.proven_engine_fail_closed); + + let route_resp = app_request( + &app, + json_request( + Method::POST, + "/api/routes", + Some("secret"), + &serde_json::json!({ + "id": "libcoraza-block", + "path_prefix": "/app", + "upstream": "mock://x", + "mode": "block", + "enabled": true + }), + ), + ) + .await; + assert_eq!(route_resp.status(), StatusCode::CREATED); + + let allowed = app_request( + &app, + gateway_get_from_ip("/gateway/app?q=hello", "198.51.100.9"), + ) + .await; + assert_eq!(allowed.status(), StatusCode::OK); + + let blocked = app_request( + &app, + gateway_get_from_ip("/gateway/app?crs-probe=1", "198.51.100.9"), + ) + .await; + assert_eq!(blocked.status(), StatusCode::FORBIDDEN); + let body: serde_json::Value = json_body(blocked).await; + assert_eq!(body["action"], "blocked"); + assert_eq!(body["engine"], "coraza"); + assert!( + body["reason"].as_str().unwrap_or("").contains("942100"), + "block reason must cite the CRS rule from libcoraza: {body}" + ); + } + #[tokio::test] async fn gateway_fail_closes_when_coraza_sidecar_is_unreachable() { let dead = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/src/proven_engine.rs b/src/proven_engine.rs index b322f53b..8630e8b8 100644 --- a/src/proven_engine.rs +++ b/src/proven_engine.rs @@ -1,36 +1,48 @@ -//! In-path Coraza sidecar adapter (issue #86). +//! In-path Coraza adapter (issue #86). //! -//! Wardnet does not reimplement OWASP CRS. When a sidecar URL is configured, -//! each gateway transaction is POSTed there and the response is parsed with -//! the existing Coraza audit adapter. Unreachable engines are either -//! fail-closed or explicitly degraded — never a silent ruleset skip. +//! Wardnet does not reimplement OWASP CRS. Live `/gateway` transactions are +//! evaluated by a proven engine in this order: +//! +//! 1. In-process libcoraza (`CORAZA_LIB_PATH`) when loaded. +//! 2. Otherwise an HTTP sidecar (`CORAZA_WAF_URL`) parsed with the existing +//! Coraza audit adapter. +//! +//! Unreachable engines are either fail-closed or explicitly degraded — never +//! a silent ruleset skip. use std::net::IpAddr; +use std::sync::Arc; use std::time::Duration; use crate::coraza_audit::{CorazaIngestedHit, parse_coraza_audit_body}; +use crate::coraza_inprocess::InProcessCoraza; /// Sidecar HTTP timeout. Bounded so a hung WAF cannot stall the gateway. pub const SIDECAR_TIMEOUT: Duration = Duration::from_millis(1_500); -/// Operator-configured Coraza sidecar consult. -#[derive(Debug, Clone, PartialEq, Eq)] +/// Operator-configured Coraza sidecar and/or in-process libcoraza consult. +#[derive(Debug, Clone)] pub struct ProvenEngineConfig { /// Full HTTP URL of the Coraza evaluate endpoint. `None` keeps ingest-hint - /// enforcement only. + /// enforcement only when in-process libcoraza is also unset. pub sidecar_url: Option, - /// When true, a configured sidecar that is unreachable or denied by + /// When true, a configured engine that is unreachable or denied by /// destination policy fails the transaction (503) instead of falling back /// to builtin scoring. pub fail_closed: bool, + /// Loaded libcoraza instance. When set, live transactions evaluate here + /// and the sidecar is not consulted. + pub in_process: Option>, } impl ProvenEngineConfig { - /// No sidecar; gateway scoring uses ingest hints and builtin signatures. + /// No sidecar and no in-process engine; gateway scoring uses ingest hints + /// and builtin signatures. pub fn disabled() -> Self { Self { sidecar_url: None, fail_closed: false, + in_process: None, } } @@ -45,19 +57,36 @@ impl ProvenEngineConfig { Self { sidecar_url, fail_closed, + in_process: None, + } + } + + /// In-process libcoraza. Sidecar URL is left unset. + pub fn in_process(engine: Arc, fail_closed: bool) -> Self { + Self { + sidecar_url: None, + fail_closed, + in_process: Some(engine), } } /// True when a non-empty sidecar URL is configured. - pub fn in_path(&self) -> bool { + pub fn sidecar_configured(&self) -> bool { self.sidecar_url .as_deref() .is_some_and(|url| !url.trim().is_empty()) } - /// Operator-visible mode label (`coraza_sidecar` or `ingest_hints_only`). + /// True when in-process libcoraza or a sidecar is configured. + pub fn in_path(&self) -> bool { + self.in_process.is_some() || self.sidecar_configured() + } + + /// Operator-visible mode label. pub fn mode(&self) -> &'static str { - if self.in_path() { + if self.in_process.is_some() { + "coraza_in_process" + } else if self.sidecar_configured() { "coraza_sidecar" } else { "ingest_hints_only" @@ -206,6 +235,7 @@ mod tests { fn sidecar_config_is_in_path() { let config = ProvenEngineConfig::sidecar("http://127.0.0.1:9000/waf", true); assert!(config.in_path()); + assert!(config.sidecar_configured()); assert_eq!(config.mode(), "coraza_sidecar"); assert!(config.fail_closed); assert_eq!( @@ -214,6 +244,16 @@ mod tests { ); } + #[test] + fn in_process_config_wins_mode_label() { + let engine = crate::coraza_inprocess::load_stub_engine(); + let config = ProvenEngineConfig::in_process(engine, true); + assert!(config.in_path()); + assert!(!config.sidecar_configured()); + assert_eq!(config.mode(), "coraza_in_process"); + assert!(config.fail_closed); + } + #[test] fn sidecar_request_body_includes_method_uri_and_optional_body() { let json = sidecar_request_body( diff --git a/tests/binary.rs b/tests/binary.rs index 71887c6b..404eb13d 100644 --- a/tests/binary.rs +++ b/tests/binary.rs @@ -58,6 +58,9 @@ fn binary_does_not_report_readiness_before_state_validation() { .env_remove("ADMIN_TOKEN") .env_remove("ADMIN_TOKENS") .env_remove("WAF_IDS_CREDENTIALS_PATH") + .env_remove("CORAZA_LIB_PATH") + .env_remove("CORAZA_RULES_PATH") + .env_remove("CORAZA_DIRECTIVES") .output() .expect("spawn gateway binary for startup validation check"); let _ = std::fs::remove_file(&state_path); @@ -80,6 +83,35 @@ fn binary_does_not_report_readiness_before_state_validation() { ); } +#[test] +fn binary_fail_closes_when_libcoraza_path_is_missing() { + let output = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) + .env("BIND_ADDR", "127.0.0.1:0") + .env("CORAZA_LIB_PATH", "/no/such/libcoraza.so") + .env("CORAZA_DIRECTIVES", "SecRuleEngine On") + .env_remove("WAF_IDS_STATE_PATH") + .env_remove("ADMIN_TOKEN") + .env_remove("ADMIN_TOKENS") + .output() + .expect("spawn gateway binary for libcoraza path check"); + assert!( + !output.status.success(), + "missing libcoraza must fail startup: {:?}", + output.status + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{stdout}{stderr}"); + assert!( + combined.contains("CORAZA_LIB_PATH") || combined.contains("does not exist"), + "startup error should name the missing library:\n{combined}" + ); + assert!( + !combined.contains("waf-ids-ai-soc listening on"), + "readiness must not be reported when libcoraza cannot load:\n{combined}" + ); +} + fn spawn_ready_gateway() -> Child { let mut child = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) .env("BIND_ADDR", "127.0.0.1:0") @@ -88,6 +120,10 @@ fn spawn_ready_gateway() -> Child { .env_remove("RATE_LIMIT") .env_remove("RATE_LIMIT_WINDOW") .env_remove("MAX_BODY_BYTES") + .env_remove("CORAZA_LIB_PATH") + .env_remove("CORAZA_RULES_PATH") + .env_remove("CORAZA_DIRECTIVES") + .env_remove("CORAZA_WAF_URL") .stdout(Stdio::piped()) .spawn() .expect("spawn gateway binary"); From d22ad23689ec130c483275c71e25b2d1d072ac3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 01:04:35 +0900 Subject: [PATCH 06/17] docs: record PR #97 in the product-technical gap baseline --- docs/product-technical-gap-baseline.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9bc864ea..d6e0f4a5 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -25,7 +25,7 @@ not “waiting on review/CI time”. | PR | Title | Head | Checks | Reviews | Merge blocker | | --- | --- | --- | --- | --- | --- | -| this PR | feat(waf): evaluate live gateway transactions with in-process libcoraza | `feat/issue-86-in-process-libcoraza` stacked on #96 | local fmt/test/clippy + two `/healthz` smokes this hour | Author this pass | Org 2-approval + self-author. Merge #95 then #96 first. Do not `--admin`. | +| [#97](https://github.com/ContextualWisdomLab/wardnet/pull/97) | feat(waf): evaluate live gateway transactions with in-process libcoraza | `feat/issue-86-in-process-libcoraza` stacked on #96 | local fmt/test/clippy + two `/healthz` smokes this hour | Author this pass | Org 2-approval + self-author. Merge #95 then #96 first. Do not `--admin`. Do not re-implement sidecar or pin. | | [#96](https://github.com/ContextualWisdomLab/wardnet/pull/96) | feat(security): fail-closed destination policy for outbound HTTP | `7cacaf135179` (`feat/issue-79-destination-policy`) stacked on #95 | rust + fuzz green at last snapshot; remaining Devin threads are info/KV-deviation | Author this pass; Devin/Codex COMMENTED. Remaining unresolved: DESTINATION_* env (documented operational-config deviation), hostname-allowlist mixed answers (intended), sidecar loopback needs allowlist in production, pin-cap eviction info | Org 2-approval + self-author. Merge #95 first. Do not re-implement the TCP-peer pin. | | [#95](https://github.com/ContextualWisdomLab/wardnet/pull/95) | feat(waf): consult Coraza sidecar on live gateway transactions | `ba9ee3a0b142` (`feat/issue-86-in-path-coraza`) | rust + Security Scan green at last snapshot | Author this pass; Devin/Codex COMMENTED | Org 2-approval + self-author. Do not re-implement sidecar slice. | | [#94](https://github.com/ContextualWisdomLab/wardnet/pull/94) | fix(auth): fail closed without write-capable admin on public bind | `f31d960a0b52` (`fix/issue-78-fail-closed-credentials`) | Checks re-ran after readiness-order fix | Author `seonghobae`; Devin COMMENTED | Org 2-approval + self-author. Do not `--admin` merge. Do not re-implement #78. | @@ -150,7 +150,7 @@ loops. ## This loop’s shipped gap -Issue **#86** in-process libcoraza remainder (this branch, stacked on #96). +Issue **#86** in-process libcoraza remainder ([#97](https://github.com/ContextualWisdomLab/wardnet/pull/97), stacked on #96). Operator-visible: `CORAZA_LIB_PATH` + `CORAZA_RULES_PATH`/`CORAZA_DIRECTIVES`; `/healthz.proven_engine=coraza_in_process`; `GET /api/waf/engine-status` `in_process_configured` / `in_process_rules`; missing library fails before @@ -166,8 +166,8 @@ default `/healthz` + `/admin` (`ingest_hints_only`); stub-loaded `/healthz` + 1. Second independent APPROVE on #91/#92 (Copilot re-requested; still 1/2; `--auto` already enabled). Merge if exact-HEAD second independent APPROVE exists. Do not `--admin`. -2. Keep #94/#95/#96 and this in-process PR merge-ready. Merge order #95 then - #96 then this PR. Do not re-implement #78, the #86 sidecar, or the #79 pin. +2. Keep #94/#95/#96/#97 merge-ready. Merge order #95 then #96 then #97. Do + not re-implement #78, the #86 sidecar, or the #79 pin. 3. Strix FAILURE on #72/#77/#93 is org LiteLLM provider infra, not wardnet code; do not rotate keys. Watch ContextualWisdomLab/.github branch `codex/strix-fail-closed-provider-evidence`. From ea621985e27691a6b80629b36888e4defcc361c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 01:20:14 +0900 Subject: [PATCH 07/17] feat(store): require PostgreSQL as the production control plane Non-loopback binds fail closed without CONTROL_PLANE_DATABASE_URL. Migrations create 3NF two-word tables with default-deny RLS. Snapshot persist commits policy rows and audit records in one transaction. Loopback still uses the JSON file or memory adapter. --- .github/workflows/ci.yml | 16 + CHANGELOG.md | 1 + Cargo.lock | 343 ++++++++- Cargo.toml | 1 + README.md | 3 +- docs/architecture.md | 3 +- docs/doctoring/postgres-control-plane.md | 43 ++ docs/product-technical-gap-baseline.md | 53 +- docs/security/threat-model.md | 2 +- scripts/smoke.sh | 1 + src/control_plane.rs | 855 +++++++++++++++++++++++ src/credentials.rs | 18 +- src/lib.rs | 72 +- tests/binary.rs | 58 ++ 14 files changed, 1426 insertions(+), 43 deletions(-) create mode 100644 docs/doctoring/postgres-control-plane.md create mode 100644 src/control_plane.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df092755..2b64f2ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,22 @@ permissions: jobs: rust: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: wardnet + POSTGRES_PASSWORD: wardnet + POSTGRES_DB: wardnet + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U wardnet -d wardnet" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + CONTROL_PLANE_TEST_DATABASE_URL: postgres://wardnet:wardnet@localhost:5432/wardnet steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1ef4d2..0070ff0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,5 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Production (non-loopback) binds fail closed without `CONTROL_PLANE_DATABASE_URL`. PostgreSQL is the production control-plane authority (3NF two-word tables, default-deny row-level security, snapshot persist in one transaction). Loopback still uses the JSON file / memory adapter. `/healthz.persistence` reports `postgres`, `file`, or `memory`. The URL is a secret and is bootstrapped into the credential registry. - Live `/gateway` transactions consult in-process libcoraza when `CORAZA_LIB_PATH` is set (with `CORAZA_RULES_PATH` and/or `CORAZA_DIRECTIVES`). Missing library, missing rules, or an empty ruleset fail startup before bind. Otherwise a Coraza sidecar is consulted when `CORAZA_WAF_URL` is set. The sidecar response is parsed with the existing Coraza audit adapter (OWASP CRS authority, not a hand-rolled engine). Engine outage is fail-closed when `PROVEN_ENGINE_FAIL_CLOSED` is true. `GET /api/waf/engine-status` and `/healthz.proven_engine` report `coraza_in_process`, `coraza_sidecar`, or `ingest_hints_only`. - Fail-closed destination policy on every outbound `http`/`https` call, including the Coraza sidecar URL (gateway upstream, threat-intel fetch, Clearfolio, SOC LLM). Private/loopback/metadata classes are denied unless `DESTINATION_ALLOWLIST` (or loopback development) permits them; `DESTINATION_DENYLIST` wins. Clients ignore ambient HTTP proxies and do not follow redirects. CIDR allowlist matches apply per resolved address; CIDR entries authorize non-default ports; IPv6 site-local (`fec0::/10`) is denied; invalid CIDR prefixes fail startup; `/healthz.destination_mode` reports the policy class. Blocking DNS runs on `spawn_blocking` with a 2s timeout. Persistence and destination-list validation complete before the readiness line is printed. After a host is allowed, the HTTP client connects only to those evaluated addresses (Host/SNI unchanged) so a rebinding answer cannot bypass the policy. diff --git a/Cargo.lock b/Cargo.lock index cb42a020..a05a89e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -93,12 +104,27 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -138,6 +164,18 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cpufeatures" version = "0.3.0" @@ -147,6 +185,36 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -168,6 +236,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fastrand" version = "2.5.0" @@ -202,6 +276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -264,7 +339,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -294,6 +369,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.5.0" @@ -339,6 +423,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -541,6 +634,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -553,6 +655,15 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.33" @@ -571,6 +682,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" @@ -600,7 +721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -613,24 +734,113 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand 0.10.2", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -824,6 +1034,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-syntax" version = "0.8.11" @@ -964,6 +1183,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -1030,6 +1255,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1046,6 +1282,12 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1074,6 +1316,17 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1207,6 +1460,32 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.2", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -1226,6 +1505,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -1302,6 +1582,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unarray" version = "0.1.4" @@ -1314,12 +1600,33 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "untrusted" version = "0.9.0" @@ -1356,6 +1663,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tokio-postgres", "tower", "waf-ids-core", ] @@ -1394,6 +1702,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -1403,6 +1720,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -1500,6 +1826,19 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 48e4d416..88185c4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } waf-ids-core = { path = "crates/waf-ids-core" } +tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"] } [dev-dependencies] tower = { version = "0.5", features = ["util"] } diff --git a/README.md b/README.md index 1d7bb12d..47b44669 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ Useful environment variables: - `BIND_ADDR`: listen address, default `127.0.0.1:8080` - `ADMIN_TOKEN`: optional write token for management writes via `X-Admin-Token` - `DESTINATION_ALLOWLIST` / `DESTINATION_DENYLIST`: comma-separated hosts, `*.suffix`, or CIDRs for outbound `http`/`https`. Denylist wins. CIDR matches apply per resolved address and also authorize non-default ports. Loopback/private/metadata/site-local destinations are denied unless allowlisted (loopback development still permits loopback-class destinations). After a host is allowed, outbound HTTP connects only to those evaluated addresses (original Host/SNI). `/healthz.destination_mode` reports `production` or `development`. -- `WAF_IDS_STATE_PATH`: optional JSON state path. When omitted, the service runs with seeded in-memory state. +- `WAF_IDS_STATE_PATH`: optional JSON state path for loopback/community. When omitted, the service runs with seeded in-memory state. Production (non-loopback) binds require `CONTROL_PLANE_DATABASE_URL` instead. +- `CONTROL_PLANE_DATABASE_URL`: PostgreSQL URL for the production control plane (`postgres://…`). Secret; prefer `WAF_IDS_CREDENTIALS_PATH` key `control_plane_url`. TLS `sslmode=require` is fail-closed until rustls is wired. `/healthz.persistence` reports `postgres` when connected. - `DNSBL_ORIGIN`: DNSBL zone origin, default `dnsbl.local` - `EVENT_LIMIT`: retained event count, default `1000`; must be greater than zero - `CORAZA_LIB_PATH` / `CORAZA_RULES_PATH` / `CORAZA_DIRECTIVES`: optional in-process libcoraza. A missing library or empty ruleset fails startup. `/healthz.proven_engine` reports `coraza_in_process`. diff --git a/docs/architecture.md b/docs/architecture.md index c38c5797..b998080b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,6 +29,7 @@ flowchart LR - `src/main.rs`: process startup and operator configuration from `BIND_ADDR`, `ADMIN_TOKEN`, `WAF_IDS_STATE_PATH`, `DNSBL_ORIGIN`, and `EVENT_LIMIT`. - `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. Persistence, destination-list, and sidecar settings validate before the readiness line is printed. +- `src/control_plane.rs`: PostgreSQL production authority (issue #80). Non-loopback binds require `CONTROL_PLANE_DATABASE_URL`. Tenant isolation is default-deny RLS. The JSON file adapter remains loopback/community only. - `src/destination.rs`: fail-closed outbound URL policy (issue #79) for every `http`/`https` send. CIDR allowlist exceptions are per resolved address; blocking DNS is offloaded from Tokio workers. The outbound HTTP client DNS resolver returns only addresses that already passed policy (TCP peer pin / DNS-rebinding TOCTOU close). - `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. @@ -54,7 +55,7 @@ flowchart LR - Default bind address is localhost. - Remote management requires `ADMIN_TOKEN` plus external TLS and identity controls. -- `WAF_IDS_STATE_PATH` enables JSON state persistence for standalone operation. Without it, the service uses seeded in-memory state. +- `WAF_IDS_STATE_PATH` enables JSON state persistence for standalone/loopback operation. Without it, the service uses seeded in-memory state. Production binds require PostgreSQL (`CONTROL_PLANE_DATABASE_URL`). - 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. - JSON persistence is a baseline durability mechanism, not a substitute for a production database, backup plan, or audited change workflow. diff --git a/docs/doctoring/postgres-control-plane.md b/docs/doctoring/postgres-control-plane.md new file mode 100644 index 00000000..551ac4df --- /dev/null +++ b/docs/doctoring/postgres-control-plane.md @@ -0,0 +1,43 @@ +# Doctoring — PostgreSQL control plane + +This note grounds issue #80 (PostgreSQL is the production authority; the JSON +file adapter remains loopback/community only). IEEE/ACM PDFs are not +redistributed. + +## Adopted standards and literature + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Row +security policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html + +- **Design impact:** Every tenant table uses `ENABLE` + `FORCE ROW LEVEL + SECURITY` and a default-deny policy keyed on `wardnet.tenant_id`. Missing + tenant context yields no rows. + +PostgreSQL Global Development Group. (2026). *PostgreSQL documentation: +Constraints*. https://www.postgresql.org/docs/current/ddl-constraints.html + +- **Design impact:** Primary keys include `tenant_id`. Foreign keys point at + `tenant_account`. Two-word snake_case names. + +PostgreSQL Global Development Group. (2026). *PostgreSQL documentation: +Transaction isolation*. https://www.postgresql.org/docs/current/transaction-iso.html + +- **Design impact:** Snapshot replace (routes, indicators, DNSBL, events, audit) + commits in one transaction so a policy mutation cannot land without its audit + records. + +National Institute of Standards and Technology. (2022). *Secure Software +Development Framework (SSDF) version 1.1* (NIST SP 800-218). +https://doi.org/10.6028/NIST.SP.800-218 + +- **Design impact:** PW.1 — fail closed when a production bind has no + control-plane URL, when the URL is not `postgres://`, or when TLS + `sslmode=require` is requested before rustls is wired. + +## Operator next action + +Set `CONTROL_PLANE_DATABASE_URL` (or credentials-file key `control_plane_url`) +before binding a non-loopback address. `/healthz.persistence` reports +`postgres`. Loopback still uses `WAF_IDS_STATE_PATH` or in-memory state. +Remaining: rustls, non-owner runtime role, backup/restore drill, HASH +partitioning for `security_event`, optimistic concurrency. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index d6e0f4a5..8c3715c0 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product and technical gap baseline -Snapshot date: 2026-08-23T16:01Z (exact-head inventory of then-open GitHub PRs +Snapshot date: 2026-08-23T16:20Z (exact-head inventory of then-open GitHub PRs and Issues plus operator-perceptible gaps). Update this file on every hourly loop. Commercial contract and `/api/commercial/readiness` remain **2B KRW**. The @@ -92,11 +92,15 @@ Management auth is shared secrets (`X-Admin-Token`) plus optional multi-token RBAC. Keyverse (OIDC/SCIM/FIDO2) is not wired. Fail-closed (#78) is the prerequisite shipped on PR #94. -### Durable control plane (issue #80) +### Durable control plane (issue #80) — **production gate + RLS snapshot this pass** -Optional JSON file + atomic rename. Not PostgreSQL, no tenant isolation, no -migrations, no hot-partition strategy. 3NF/snake_case two-word names apply when -the store lands. +PostgreSQL is required for non-loopback binds (`CONTROL_PLANE_DATABASE_URL`). +`src/control_plane.rs` migrates 3NF two-word tables with default-deny RLS +(`FORCE ROW LEVEL SECURITY`, `wardnet.tenant_id`). Snapshot persist is one +transaction. JSON file / memory remain loopback/community only. +`/healthz.persistence` is `postgres` | `file` | `memory`. Remaining: rustls, +non-owner role, backup/restore drill, event HASH partitioning, optimistic +concurrency. ### Fail-closed credentials (issue #78) — **closed on PR #94** @@ -150,30 +154,21 @@ loops. ## This loop’s shipped gap -Issue **#86** in-process libcoraza remainder ([#97](https://github.com/ContextualWisdomLab/wardnet/pull/97), stacked on #96). -Operator-visible: `CORAZA_LIB_PATH` + `CORAZA_RULES_PATH`/`CORAZA_DIRECTIVES`; -`/healthz.proven_engine=coraza_in_process`; `GET /api/waf/engine-status` -`in_process_configured` / `in_process_rules`; missing library fails before -readiness (`tests/binary.rs::binary_fail_closes_when_libcoraza_path_is_missing`). -Driving tests: `gateway_blocks_live_request_from_in_process_libcoraza` and -`stub_engine_blocks_crs_probe_and_allows_clean`. Two real smokes this hour: -default `/healthz` + `/admin` (`ingest_hints_only`); stub-loaded `/healthz` + -`/api/commercial/readiness` (`coraza_in_process`, `target_sale_value_krw` -2_000_000_000). Do not re-implement #78, the #86 sidecar slice, or the #79 pin. +Issue **#80** first slice (PostgreSQL production authority). Non-loopback binds +fail closed without `CONTROL_PLANE_DATABASE_URL`. Operator-visible: +`/healthz.persistence=postgres`; credentials key `control_plane_url`. Driving +tests: `run_from_env_fail_closes_public_bind_without_postgres`, +`binary_fail_closes_non_loopback_listen_without_postgres`, +`binary_fail_closes_when_control_plane_url_is_not_postgres`, +`postgres_roundtrip_seeded_snapshot_when_database_url_is_set` (CI postgres +service). Do not re-implement #78, the #86 sidecar/libcoraza slices, or the +#79 pin. ## Next hourly loop (do, do not report) -1. Second independent APPROVE on #91/#92 (Copilot re-requested; still 1/2; - `--auto` already enabled). Merge if exact-HEAD second independent APPROVE - exists. Do not `--admin`. -2. Keep #94/#95/#96/#97 merge-ready. Merge order #95 then #96 then #97. Do - not re-implement #78, the #86 sidecar, or the #79 pin. -3. Strix FAILURE on #72/#77/#93 is org LiteLLM provider infra, not wardnet - code; do not rotate keys. Watch ContextualWisdomLab/.github branch - `codex/strix-fail-closed-provider-evidence`. -4. Sticky opencode `CHANGES_REQUESTED` on #72 head `6881f47` — review job does - not post APPROVE. -5. Next runtime gap if policy still blocks: #80 durable PostgreSQL control - plane, or #81 outbox/workers, or #86 detection-quality corpora / Suricata - tail. -6. Refresh this file’s PR/Issue tables from `gh pr list` / `gh issue list`. +1. Second independent APPROVE on #91/#92. Do not `--admin`. +2. Keep #94/#95/#96/#97 and this #80 PR merge-ready. Merge order #95 then #96 + then #97 then this. Do not re-implement shipped slices. +3. Next runtime gap if policy still blocks: #81 outbox/workers on this + postgres authority, or rustls / backup drill remainder of #80. +4. Refresh this file’s PR/Issue tables from `gh pr list` / `gh issue list`. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 5c843271..b57968ed 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -24,7 +24,7 @@ | --- | --- | --- | --- | | Unauthorized management write | Route takeover or false blocking | `X-Admin-Token` write gate; multi-token RBAC with actor labels and readonly role; audit log for successful writes | SSO/OIDC, mTLS or identity proxy, SCIM | | Malicious threat feed import | False positives or broad blocks | Validation, route-scoped enforcement | Source signing, feed confidence, staged promotion | -| State file corruption | Startup failure or stale policy | JSON parse failure surfaces startup error | Database, backup, schema migration | +| State file corruption | Startup failure or stale policy | JSON parse failure surfaces startup error; production binds require PostgreSQL (`src/control_plane.rs`) with RLS | Backup/restore drill, TLS, non-owner runtime role | | Upstream SSRF through routes | Internal network exposure | Scheme validation plus fail-closed destination policy (`src/destination.rs`): deny loopback/private/link-local/metadata unless allowlisted; denylist wins; no ambient HTTP proxy; no redirects. After evaluation, HTTP connects only to those IPs (Host/SNI preserved). Coraza sidecar URLs use the same policy. | Kubernetes NetworkPolicy egress as defense in depth | | Gateway DoS | Availability loss | Rust memory safety, event retention limit | Rate limits, body limits, async event sink | | DNSBL abuse | Reputation damage | Loopback response-code validation | Authoritative DNS service, signing, publisher workflow | diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 22563d43..59472dc1 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -34,6 +34,7 @@ start_server() { WAF_IDS_STATE_PATH="$STATE_FILE" \ DNSBL_ORIGIN="dnsbl.test" \ EVENT_LIMIT="5" \ + CONTROL_PLANE_DATABASE_URL="" \ cargo run --quiet ) >"$LOG_FILE" 2>&1 & SERVER_PID="$!" diff --git a/src/control_plane.rs b/src/control_plane.rs new file mode 100644 index 00000000..5fd10d7e --- /dev/null +++ b/src/control_plane.rs @@ -0,0 +1,855 @@ +//! PostgreSQL control plane (issue #80). +//! +//! Production (non-loopback) binds require a control-plane URL. The JSON file +//! adapter remains for loopback/community use and is never selected as the +//! production authority. Tenant isolation is default-deny row-level security +//! with `FORCE ROW LEVEL SECURITY`; each transaction sets `wardnet.tenant_id`. + +use std::net::IpAddr; +use std::str::FromStr; +use tokio::sync::Mutex; +use tokio_postgres::{Client, GenericClient, NoTls}; +use waf_ids_core::{ + AppData, AuditLogEntry, CommercialProfile, DnsblEntry, EnforcementMode, LicenseStatus, + ProductEdition, RouteConfig, SecurityEvent, Severity, ThreatFeedStatus, ThreatIndicator, +}; + +/// Default tenant used until Keyverse supplies claims (#82). +pub const DEFAULT_TENANT_ID: &str = "local-lab"; + +const MIGRATION_VERSION: i32 = 1; + +/// Recoverable forward migration. Two-word snake_case names, 3NF, RLS. +pub const MIGRATION_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS schema_migration ( + migration_version INTEGER PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS tenant_account ( + tenant_id TEXT PRIMARY KEY, + event_sequence BIGINT NOT NULL, + audit_sequence BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS tenant_profile ( + tenant_id TEXT PRIMARY KEY REFERENCES tenant_account (tenant_id), + deployment_id TEXT NOT NULL, + edition_name TEXT NOT NULL, + license_status TEXT NOT NULL, + license_id TEXT, + licensee_name TEXT, + licensed_until_unix BIGINT, + licensed_node_count INTEGER, + annual_contract_value_krw BIGINT, + support_contact TEXT NOT NULL, + feature_list TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS route_config ( + tenant_id TEXT NOT NULL REFERENCES tenant_account (tenant_id), + route_id TEXT NOT NULL, + path_prefix TEXT NOT NULL, + upstream_url TEXT NOT NULL, + enforcement_mode TEXT NOT NULL, + is_enabled BOOLEAN NOT NULL, + block_threshold INTEGER, + PRIMARY KEY (tenant_id, route_id) +); + +CREATE TABLE IF NOT EXISTS threat_indicator ( + tenant_id TEXT NOT NULL REFERENCES tenant_account (tenant_id), + indicator_type TEXT NOT NULL, + indicator_value TEXT NOT NULL, + indicator_source TEXT NOT NULL, + severity_name TEXT NOT NULL, + ttl_seconds BIGINT NOT NULL, + PRIMARY KEY (tenant_id, indicator_type, indicator_value, indicator_source) +); + +CREATE TABLE IF NOT EXISTS dnsbl_entry ( + tenant_id TEXT NOT NULL REFERENCES tenant_account (tenant_id), + host_address TEXT NOT NULL, + response_code TEXT NOT NULL, + block_reason TEXT NOT NULL, + entry_source TEXT NOT NULL, + ttl_seconds BIGINT NOT NULL, + prefix_length SMALLINT, + PRIMARY KEY (tenant_id, host_address) +); + +CREATE TABLE IF NOT EXISTS security_event ( + tenant_id TEXT NOT NULL REFERENCES tenant_account (tenant_id), + event_id BIGINT NOT NULL, + timestamp_unix BIGINT NOT NULL, + client_address TEXT, + route_id TEXT, + action_name TEXT NOT NULL, + event_reason TEXT NOT NULL, + event_score INTEGER NOT NULL, + request_path TEXT NOT NULL, + PRIMARY KEY (tenant_id, event_id) +); + +CREATE TABLE IF NOT EXISTS audit_record ( + tenant_id TEXT NOT NULL REFERENCES tenant_account (tenant_id), + audit_id BIGINT NOT NULL, + timestamp_unix BIGINT NOT NULL, + actor_name TEXT NOT NULL, + action_name TEXT NOT NULL, + resource_name TEXT NOT NULL, + resource_id TEXT NOT NULL, + action_outcome TEXT NOT NULL, + PRIMARY KEY (tenant_id, audit_id) +); + +CREATE TABLE IF NOT EXISTS threat_feed ( + tenant_id TEXT NOT NULL REFERENCES tenant_account (tenant_id), + feed_id TEXT NOT NULL, + feed_source TEXT NOT NULL, + last_updated_unix BIGINT NOT NULL, + threat_count INTEGER NOT NULL, + dnsbl_count INTEGER NOT NULL, + ttl_seconds BIGINT NOT NULL, + PRIMARY KEY (tenant_id, feed_id) +); + +CREATE INDEX IF NOT EXISTS security_event_tenant_event + ON security_event (tenant_id, event_id); + +ALTER TABLE tenant_account ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant_account FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON tenant_account; +CREATE POLICY tenant_isolation ON tenant_account + USING (tenant_id = current_setting('wardnet.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('wardnet.tenant_id', true)); + +ALTER TABLE tenant_profile ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant_profile FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON tenant_profile; +CREATE POLICY tenant_isolation ON tenant_profile + USING (tenant_id = current_setting('wardnet.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('wardnet.tenant_id', true)); + +ALTER TABLE route_config ENABLE ROW LEVEL SECURITY; +ALTER TABLE route_config FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON route_config; +CREATE POLICY tenant_isolation ON route_config + USING (tenant_id = current_setting('wardnet.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('wardnet.tenant_id', true)); + +ALTER TABLE threat_indicator ENABLE ROW LEVEL SECURITY; +ALTER TABLE threat_indicator FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON threat_indicator; +CREATE POLICY tenant_isolation ON threat_indicator + USING (tenant_id = current_setting('wardnet.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('wardnet.tenant_id', true)); + +ALTER TABLE dnsbl_entry ENABLE ROW LEVEL SECURITY; +ALTER TABLE dnsbl_entry FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON dnsbl_entry; +CREATE POLICY tenant_isolation ON dnsbl_entry + USING (tenant_id = current_setting('wardnet.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('wardnet.tenant_id', true)); + +ALTER TABLE security_event ENABLE ROW LEVEL SECURITY; +ALTER TABLE security_event FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON security_event; +CREATE POLICY tenant_isolation ON security_event + USING (tenant_id = current_setting('wardnet.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('wardnet.tenant_id', true)); + +ALTER TABLE audit_record ENABLE ROW LEVEL SECURITY; +ALTER TABLE audit_record FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON audit_record; +CREATE POLICY tenant_isolation ON audit_record + USING (tenant_id = current_setting('wardnet.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('wardnet.tenant_id', true)); + +ALTER TABLE threat_feed ENABLE ROW LEVEL SECURITY; +ALTER TABLE threat_feed FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON threat_feed; +CREATE POLICY tenant_isolation ON threat_feed + USING (tenant_id = current_setting('wardnet.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('wardnet.tenant_id', true)); +"#; + +/// Fail closed when a non-loopback bind has no control-plane URL. +pub fn require_postgres_for_bind( + bind_addr: &str, + database_url: Option<&str>, +) -> Result<(), String> { + if crate::bind_is_loopback(bind_addr) { + return Ok(()); + } + match database_url.map(str::trim).filter(|value| !value.is_empty()) { + Some(_) => Ok(()), + None => Err( + "production bind requires CONTROL_PLANE_DATABASE_URL; JSON file state is not production authority" + .to_string(), + ), + } +} + +/// Structural URL checks. TLS (`sslmode=require`) is fail-closed until rustls +/// is wired. Password stays in the registry, not logs. +pub fn parse_database_url(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Err("CONTROL_PLANE_DATABASE_URL is empty".to_string()); + } + let lower = raw.to_ascii_lowercase(); + if !(lower.starts_with("postgres://") || lower.starts_with("postgresql://")) { + return Err("CONTROL_PLANE_DATABASE_URL must be a postgres:// URL".to_string()); + } + if lower.contains("sslmode=require") || lower.contains("sslmode=verify") { + return Err( + "CONTROL_PLANE_DATABASE_URL TLS (sslmode=require/verify) is not wired yet".to_string(), + ); + } + Ok(raw.to_string()) +} + +/// Live PostgreSQL snapshot store for one tenant. +pub struct PostgresPlane { + client: Mutex, + tenant_id: String, +} + +impl PostgresPlane { + pub async fn connect(url: &str) -> Result { + let url = parse_database_url(url)?; + let (client, connection) = tokio_postgres::connect(&url, NoTls) + .await + .map_err(|error| format!("control plane connect failed: {error}"))?; + tokio::spawn(async move { + let _ = connection.await; + }); + let plane = Self { + client: Mutex::new(client), + tenant_id: DEFAULT_TENANT_ID.to_string(), + }; + plane.migrate().await?; + Ok(plane) + } + + async fn migrate(&self) -> Result<(), String> { + let client = self.client.lock().await; + client + .batch_execute(MIGRATION_SQL) + .await + .map_err(|error| format!("control plane migration failed: {error}"))?; + client + .execute( + "INSERT INTO schema_migration (migration_version) VALUES ($1) ON CONFLICT (migration_version) DO NOTHING", + &[&MIGRATION_VERSION], + ) + .await + .map_err(|error| format!("control plane migration version failed: {error}"))?; + Ok(()) + } + + /// Load the tenant snapshot, or `None` when the tenant has no rows yet. + pub async fn load(&self) -> Result, String> { + let mut client = self.client.lock().await; + load_snapshot(&mut client, &self.tenant_id).await + } + + /// Replace the tenant snapshot in one transaction (mutation + audit). + pub async fn save(&self, data: &AppData) -> Result<(), String> { + let mut client = self.client.lock().await; + save_snapshot(&mut client, &self.tenant_id, data).await + } +} + +async fn load_snapshot(client: &mut Client, tenant_id: &str) -> Result, String> { + let tx = client + .transaction() + .await + .map_err(|error| format!("control plane load transaction failed: {error}"))?; + tx.execute( + "SELECT set_config('wardnet.tenant_id', $1, true)", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane tenant context failed: {error}"))?; + let account = tx + .query_opt( + "SELECT event_sequence, audit_sequence FROM tenant_account WHERE tenant_id = $1", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane load tenant_account failed: {error}"))?; + let Some(account) = account else { + tx.rollback() + .await + .map_err(|error| format!("control plane load rollback failed: {error}"))?; + return Ok(None); + }; + + let commercial = load_commercial(&tx, tenant_id).await?; + let routes = load_routes(&tx, tenant_id).await?; + let threats = load_threats(&tx, tenant_id).await?; + let dnsbl = load_dnsbl(&tx, tenant_id).await?; + let events = load_events(&tx, tenant_id).await?; + let audit_logs = load_audit(&tx, tenant_id).await?; + let threat_feeds = load_feeds(&tx, tenant_id).await?; + tx.commit() + .await + .map_err(|error| format!("control plane load commit failed: {error}"))?; + + Ok(Some(AppData { + routes, + threats, + dnsbl, + events, + next_event_id: account.get::<_, i64>(0) as u64, + audit_logs, + next_audit_log_id: account.get::<_, i64>(1) as u64, + commercial, + threat_feeds, + })) +} + +async fn save_snapshot(client: &mut Client, tenant_id: &str, data: &AppData) -> Result<(), String> { + let tx = client + .transaction() + .await + .map_err(|error| format!("control plane transaction failed: {error}"))?; + tx.execute( + "SELECT set_config('wardnet.tenant_id', $1, true)", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane tenant context failed: {error}"))?; + + tx.execute( + "INSERT INTO tenant_account (tenant_id, event_sequence, audit_sequence) + VALUES ($1, $2, $3) + ON CONFLICT (tenant_id) DO UPDATE SET + event_sequence = EXCLUDED.event_sequence, + audit_sequence = EXCLUDED.audit_sequence", + &[ + &tenant_id, + &(data.next_event_id as i64), + &(data.next_audit_log_id as i64), + ], + ) + .await + .map_err(|error| format!("control plane upsert tenant_account failed: {error}"))?; + + for table in [ + "route_config", + "threat_indicator", + "dnsbl_entry", + "security_event", + "audit_record", + "threat_feed", + "tenant_profile", + ] { + tx.execute( + &format!("DELETE FROM {table} WHERE tenant_id = $1"), + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane delete {table} failed: {error}"))?; + } + + let features = serde_json::to_string(&data.commercial.features) + .expect("feature list is JSON-serializable"); + tx.execute( + "INSERT INTO tenant_profile ( + tenant_id, deployment_id, edition_name, license_status, license_id, + licensee_name, licensed_until_unix, licensed_node_count, + annual_contract_value_krw, support_contact, feature_list + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", + &[ + &tenant_id, + &data.commercial.deployment_id, + &edition_sql(&data.commercial.edition), + &license_sql(&data.commercial.license_status), + &data.commercial.license_id, + &data.commercial.licensee, + &data.commercial.licensed_until_unix.map(|v| v as i64), + &data.commercial.licensed_node_count.map(|v| v as i32), + &data.commercial.annual_contract_value_krw.map(|v| v as i64), + &data.commercial.support_contact, + &features, + ], + ) + .await + .map_err(|error| format!("control plane insert tenant_profile failed: {error}"))?; + + for route in &data.routes { + tx.execute( + "INSERT INTO route_config ( + tenant_id, route_id, path_prefix, upstream_url, enforcement_mode, + is_enabled, block_threshold + ) VALUES ($1,$2,$3,$4,$5,$6,$7)", + &[ + &tenant_id, + &route.id, + &route.path_prefix, + &route.upstream, + &mode_sql(&route.mode), + &route.enabled, + &route.block_threshold.map(i32::from), + ], + ) + .await + .map_err(|error| format!("control plane insert route_config failed: {error}"))?; + } + + for threat in &data.threats { + tx.execute( + "INSERT INTO threat_indicator ( + tenant_id, indicator_type, indicator_value, indicator_source, + severity_name, ttl_seconds + ) VALUES ($1,$2,$3,$4,$5,$6)", + &[ + &tenant_id, + &threat.indicator_type, + &threat.value, + &threat.source, + &severity_sql(&threat.severity), + &(threat.ttl_seconds as i64), + ], + ) + .await + .map_err(|error| format!("control plane insert threat_indicator failed: {error}"))?; + } + + for entry in &data.dnsbl { + let address = entry.address.to_string(); + tx.execute( + "INSERT INTO dnsbl_entry ( + tenant_id, host_address, response_code, block_reason, entry_source, + ttl_seconds, prefix_length + ) VALUES ($1,$2,$3,$4,$5,$6,$7)", + &[ + &tenant_id, + &address, + &entry.code, + &entry.reason, + &entry.source, + &(entry.ttl_seconds as i64), + &entry.prefix_len.map(i16::from), + ], + ) + .await + .map_err(|error| format!("control plane insert dnsbl_entry failed: {error}"))?; + } + + for event in &data.events { + let client_address = event.client_ip.map(|ip| ip.to_string()); + tx.execute( + "INSERT INTO security_event ( + tenant_id, event_id, timestamp_unix, client_address, route_id, + action_name, event_reason, event_score, request_path + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", + &[ + &tenant_id, + &(event.id as i64), + &(event.timestamp_unix as i64), + &client_address, + &event.route_id, + &event.action, + &event.reason, + &i32::from(event.score), + &event.path, + ], + ) + .await + .map_err(|error| format!("control plane insert security_event failed: {error}"))?; + } + + for audit in &data.audit_logs { + tx.execute( + "INSERT INTO audit_record ( + tenant_id, audit_id, timestamp_unix, actor_name, action_name, + resource_name, resource_id, action_outcome + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", + &[ + &tenant_id, + &(audit.id as i64), + &(audit.timestamp_unix as i64), + &audit.actor, + &audit.action, + &audit.resource, + &audit.resource_id, + &audit.outcome, + ], + ) + .await + .map_err(|error| format!("control plane insert audit_record failed: {error}"))?; + } + + for feed in &data.threat_feeds { + tx.execute( + "INSERT INTO threat_feed ( + tenant_id, feed_id, feed_source, last_updated_unix, threat_count, + dnsbl_count, ttl_seconds + ) VALUES ($1,$2,$3,$4,$5,$6,$7)", + &[ + &tenant_id, + &feed.feed_id, + &feed.source, + &(feed.last_updated_unix as i64), + &(feed.threat_count as i32), + &(feed.dnsbl_count as i32), + &(feed.ttl_seconds as i64), + ], + ) + .await + .map_err(|error| format!("control plane insert threat_feed failed: {error}"))?; + } + + tx.commit() + .await + .map_err(|error| format!("control plane commit failed: {error}"))?; + Ok(()) +} + +async fn load_commercial( + client: &C, + tenant_id: &str, +) -> Result { + let row = client + .query_opt( + "SELECT deployment_id, edition_name, license_status, license_id, licensee_name, + licensed_until_unix, licensed_node_count, annual_contract_value_krw, + support_contact, feature_list + FROM tenant_profile WHERE tenant_id = $1", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane load tenant_profile failed: {error}"))?; + let Some(row) = row else { + return Ok(CommercialProfile::seeded()); + }; + let features: String = row.get(9); + Ok(CommercialProfile { + tenant_id: tenant_id.to_string(), + deployment_id: row.get(0), + edition: parse_edition(row.get(1))?, + license_status: parse_license(row.get(2))?, + license_id: row.get(3), + licensee: row.get(4), + licensed_until_unix: row.get::<_, Option>(5).map(|v| v as u64), + licensed_node_count: row.get::<_, Option>(6).map(|v| v as u32), + annual_contract_value_krw: row.get::<_, Option>(7).map(|v| v as u64), + support_contact: row.get(8), + features: serde_json::from_str(&features) + .map_err(|error| format!("control plane feature_list is not JSON: {error}"))?, + }) +} + +async fn load_routes( + client: &C, + tenant_id: &str, +) -> Result, String> { + let rows = client + .query( + "SELECT route_id, path_prefix, upstream_url, enforcement_mode, is_enabled, block_threshold + FROM route_config WHERE tenant_id = $1", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane load route_config failed: {error}"))?; + rows.iter() + .map(|row| { + Ok(RouteConfig { + id: row.get(0), + path_prefix: row.get(1), + upstream: row.get(2), + mode: parse_mode(row.get(3))?, + enabled: row.get(4), + block_threshold: row.get::<_, Option>(5).map(|v| v as u16), + }) + }) + .collect() +} + +async fn load_threats( + client: &C, + tenant_id: &str, +) -> Result, String> { + let rows = client + .query( + "SELECT indicator_type, indicator_value, indicator_source, severity_name, ttl_seconds + FROM threat_indicator WHERE tenant_id = $1", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane load threat_indicator failed: {error}"))?; + rows.iter() + .map(|row| { + Ok(ThreatIndicator { + indicator_type: row.get(0), + value: row.get(1), + source: row.get(2), + severity: parse_severity(row.get(3))?, + ttl_seconds: row.get::<_, i64>(4) as u64, + }) + }) + .collect() +} + +async fn load_dnsbl( + client: &C, + tenant_id: &str, +) -> Result, String> { + let rows = client + .query( + "SELECT host_address, response_code, block_reason, entry_source, ttl_seconds, prefix_length + FROM dnsbl_entry WHERE tenant_id = $1", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane load dnsbl_entry failed: {error}"))?; + rows.iter() + .map(|row| { + let address: String = row.get(0); + Ok(DnsblEntry { + address: IpAddr::from_str(&address) + .map_err(|error| format!("control plane host_address {address}: {error}"))?, + code: row.get(1), + reason: row.get(2), + source: row.get(3), + ttl_seconds: row.get::<_, i64>(4) as u64, + prefix_len: row.get::<_, Option>(5).map(|v| v as u8), + }) + }) + .collect() +} + +async fn load_events( + client: &C, + tenant_id: &str, +) -> Result, String> { + let rows = client + .query( + "SELECT event_id, timestamp_unix, client_address, route_id, action_name, + event_reason, event_score, request_path + FROM security_event WHERE tenant_id = $1 ORDER BY event_id", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane load security_event failed: {error}"))?; + rows.iter() + .map(|row| { + let client_address: Option = row.get(2); + Ok(SecurityEvent { + id: row.get::<_, i64>(0) as u64, + timestamp_unix: row.get::<_, i64>(1) as u64, + client_ip: client_address + .map(|value| { + IpAddr::from_str(&value).map_err(|error| { + format!("control plane client_address {value}: {error}") + }) + }) + .transpose()?, + route_id: row.get(3), + action: row.get(4), + reason: row.get(5), + score: row.get::<_, i32>(6) as u16, + path: row.get(7), + }) + }) + .collect() +} + +async fn load_audit( + client: &C, + tenant_id: &str, +) -> Result, String> { + let rows = client + .query( + "SELECT audit_id, timestamp_unix, actor_name, action_name, resource_name, + resource_id, action_outcome + FROM audit_record WHERE tenant_id = $1 ORDER BY audit_id", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane load audit_record failed: {error}"))?; + Ok(rows + .iter() + .map(|row| AuditLogEntry { + id: row.get::<_, i64>(0) as u64, + timestamp_unix: row.get::<_, i64>(1) as u64, + actor: row.get(2), + action: row.get(3), + resource: row.get(4), + resource_id: row.get(5), + outcome: row.get(6), + }) + .collect()) +} + +async fn load_feeds( + client: &C, + tenant_id: &str, +) -> Result, String> { + let rows = client + .query( + "SELECT feed_id, feed_source, last_updated_unix, threat_count, dnsbl_count, ttl_seconds + FROM threat_feed WHERE tenant_id = $1", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane load threat_feed failed: {error}"))?; + Ok(rows + .iter() + .map(|row| ThreatFeedStatus { + feed_id: row.get(0), + source: row.get(1), + last_updated_unix: row.get::<_, i64>(2) as u64, + threat_count: row.get::<_, i32>(3) as usize, + dnsbl_count: row.get::<_, i32>(4) as usize, + ttl_seconds: row.get::<_, i64>(5) as u64, + }) + .collect()) +} + +fn mode_sql(mode: &EnforcementMode) -> &'static str { + match mode { + EnforcementMode::Monitor => "monitor", + EnforcementMode::Block => "block", + } +} + +fn parse_mode(value: &str) -> Result { + match value { + "monitor" => Ok(EnforcementMode::Monitor), + "block" => Ok(EnforcementMode::Block), + other => Err(format!("unknown enforcement_mode {other}")), + } +} + +fn severity_sql(severity: &Severity) -> &'static str { + match severity { + Severity::Low => "low", + Severity::Medium => "medium", + Severity::High => "high", + Severity::Critical => "critical", + } +} + +fn parse_severity(value: &str) -> Result { + match value { + "low" => Ok(Severity::Low), + "medium" => Ok(Severity::Medium), + "high" => Ok(Severity::High), + "critical" => Ok(Severity::Critical), + other => Err(format!("unknown severity_name {other}")), + } +} + +fn edition_sql(edition: &ProductEdition) -> &'static str { + match edition { + ProductEdition::Community => "community", + ProductEdition::Evaluation => "evaluation", + ProductEdition::Enterprise => "enterprise", + } +} + +fn parse_edition(value: &str) -> Result { + match value { + "community" => Ok(ProductEdition::Community), + "evaluation" => Ok(ProductEdition::Evaluation), + "enterprise" => Ok(ProductEdition::Enterprise), + other => Err(format!("unknown edition_name {other}")), + } +} + +fn license_sql(status: &LicenseStatus) -> &'static str { + match status { + LicenseStatus::Unlicensed => "unlicensed", + LicenseStatus::Evaluation => "evaluation", + LicenseStatus::Active => "active", + LicenseStatus::Expired => "expired", + } +} + +fn parse_license(value: &str) -> Result { + match value { + "unlicensed" => Ok(LicenseStatus::Unlicensed), + "evaluation" => Ok(LicenseStatus::Evaluation), + "active" => Ok(LicenseStatus::Active), + "expired" => Ok(LicenseStatus::Expired), + other => Err(format!("unknown license_status {other}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn production_bind_requires_control_plane_url() { + require_postgres_for_bind("0.0.0.0:8080", None).unwrap_err(); + require_postgres_for_bind("0.0.0.0:8080", Some("postgres://wardnet@127.0.0.1/wardnet")) + .unwrap(); + require_postgres_for_bind("127.0.0.1:8080", None).unwrap(); + require_postgres_for_bind("[::1]:8080", None).unwrap(); + } + + #[test] + fn database_url_rejects_non_postgres_and_tls_until_wired() { + parse_database_url("").unwrap_err(); + parse_database_url("mysql://x").unwrap_err(); + parse_database_url("postgres://wardnet@127.0.0.1/wardnet?sslmode=require").unwrap_err(); + parse_database_url("postgres://wardnet@127.0.0.1/wardnet").unwrap(); + parse_database_url("postgresql://wardnet@127.0.0.1/wardnet?sslmode=disable").unwrap(); + } + + #[test] + fn migration_sql_is_3nf_rls_and_two_word_names() { + for table in [ + "tenant_account", + "tenant_profile", + "route_config", + "threat_indicator", + "dnsbl_entry", + "security_event", + "audit_record", + "threat_feed", + "schema_migration", + ] { + assert!(MIGRATION_SQL.contains(table), "missing table {table}"); + } + assert!(MIGRATION_SQL.contains("FORCE ROW LEVEL SECURITY")); + assert!(MIGRATION_SQL.contains("wardnet.tenant_id")); + assert!(MIGRATION_SQL.contains("PRIMARY KEY (tenant_id, route_id)")); + assert!(MIGRATION_SQL.contains("REFERENCES tenant_account")); + assert!( + !MIGRATION_SQL.contains("json_blob"), + "do not dump AppData as one JSON column" + ); + } + + #[tokio::test] + async fn postgres_roundtrip_seeded_snapshot_when_database_url_is_set() { + let Ok(url) = std::env::var("CONTROL_PLANE_TEST_DATABASE_URL") else { + return; + }; + if url.trim().is_empty() { + return; + } + let plane = PostgresPlane::connect(&url) + .await + .expect("test database must accept the control plane"); + let seeded = AppData::seeded(); + plane.save(&seeded).await.expect("save seeded snapshot"); + let loaded = plane + .load() + .await + .expect("load snapshot") + .expect("tenant rows must exist after save"); + assert_eq!(loaded.routes, seeded.routes); + assert_eq!(loaded.threats, seeded.threats); + assert_eq!(loaded.dnsbl, seeded.dnsbl); + assert_eq!(loaded.next_event_id, seeded.next_event_id); + assert_eq!(loaded.commercial.tenant_id, DEFAULT_TENANT_ID); + } +} diff --git a/src/credentials.rs b/src/credentials.rs index 02b7f39e..66223ab5 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -11,6 +11,7 @@ use std::{collections::HashMap, io::ErrorKind, path::Path}; /// Well-known secret keys loaded into the registry at bootstrap. pub const CRED_ADMIN_TOKEN: &str = "admin_token"; pub const CRED_ADMIN_TOKENS: &str = "admin_tokens"; +pub const CRED_CONTROL_PLANE_URL: &str = "control_plane_url"; /// Where secret-bearing credentials were loaded from (never includes values). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -73,6 +74,7 @@ impl CredentialRegistry { credentials_path: Option<&Path>, env_admin_token: Option, env_admin_tokens: Option, + env_control_plane_url: Option, ) -> Result { let mut values = HashMap::new(); let mut from_file = false; @@ -88,7 +90,7 @@ impl CredentialRegistry { path.display() ) })?; - for key in [CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS] { + for key in [CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CRED_CONTROL_PLANE_URL] { if let Some(raw) = file_map.get(key) { let text = json_value_as_nonempty_string(raw); if let Some(text) = text { @@ -120,6 +122,12 @@ impl CredentialRegistry { values.insert(CRED_ADMIN_TOKENS.to_string(), tokens); from_env = true; } + if !values.contains_key(CRED_CONTROL_PLANE_URL) + && let Some(url) = env_control_plane_url.filter(|value| !value.is_empty()) + { + values.insert(CRED_CONTROL_PLANE_URL.to_string(), url); + from_env = true; + } let source = if from_file { CredentialSource::File @@ -159,6 +167,7 @@ mod tests { None, Some("secret".to_string()), Some("tok:alice".to_string()), + None, ) .unwrap(); assert_eq!(registry.source(), CredentialSource::Env); @@ -173,7 +182,7 @@ mod tests { #[test] fn bootstrap_empty_when_no_secrets() { let registry = - CredentialRegistry::bootstrap_secrets(None, None, Some(String::new())).unwrap(); + CredentialRegistry::bootstrap_secrets(None, None, Some(String::new()), None).unwrap(); assert_eq!(registry.source(), CredentialSource::None); assert!(!registry.has_admin_auth()); } @@ -202,6 +211,7 @@ mod tests { Some(&path), Some("from-env".to_string()), Some("envtok:env".to_string()), + None, ) .unwrap(); assert_eq!(registry.source(), CredentialSource::File); @@ -232,6 +242,7 @@ mod tests { Some(&path), Some("ignored".to_string()), Some("envtok:bob".to_string()), + None, ) .unwrap(); assert_eq!(registry.source(), CredentialSource::File); @@ -258,6 +269,7 @@ mod tests { Some(&path), Some("env-secret".to_string()), None, + None, ) .unwrap(); assert_eq!(registry.source(), CredentialSource::Env); @@ -280,7 +292,7 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("credentials.json"); std::fs::write(&path, "not-json").unwrap(); - let err = CredentialRegistry::bootstrap_secrets(Some(&path), None, None).unwrap_err(); + let err = CredentialRegistry::bootstrap_secrets(Some(&path), None, None, None).unwrap_err(); assert!(err.contains("not valid JSON")); let _ = std::fs::remove_dir_all(&dir); } diff --git a/src/lib.rs b/src/lib.rs index ec443e29..cd7fcc44 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,6 +35,7 @@ pub use waf_ids_core::{ ThreatIndicator, export_dnsbl_zone, ip_in_network, reverse_ipv4_for_dnsbl, score_request, }; +mod control_plane; mod coraza_audit; mod coraza_inprocess; mod credentials; @@ -45,7 +46,10 @@ mod proven_engine; mod stix_import; mod suricata_eve; mod taxii; -pub use credentials::{CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, CredentialSource}; +pub use credentials::{ + CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CRED_CONTROL_PLANE_URL, CredentialRegistry, + CredentialSource, +}; pub use destination::{DestinationPolicy, HostResolver, SystemHostResolver}; pub use proven_engine::{ProvenEngineConfig, ProvenEngineOutcome}; @@ -84,6 +88,8 @@ pub struct AppState { /// Addresses that already passed policy; the HTTP clients resolve through /// this pin board instead of a second OS DNS lookup. pins: Arc, + /// PostgreSQL snapshot store. `None` keeps the JSON-file / memory adapter. + control_plane: Option>, } /// Configuration for the optional LLM-backed SOC analysis. Points at an @@ -128,6 +134,24 @@ impl AppState { Ok(Self::new(data, config)) } + /// Load from PostgreSQL, seeding the tenant snapshot when empty. + pub async fn load_postgres(config: AppConfig, database_url: &str) -> Result { + let plane = control_plane::PostgresPlane::connect(database_url).await?; + let mut data = match plane.load().await? { + Some(loaded) => loaded, + None => AppData::seeded(), + }; + let event_limit = config.event_limit.max(1); + enforce_event_limit(&mut data, event_limit); + plane.save(&data).await?; + Ok(Self::new(data, config).with_control_plane(Arc::new(plane))) + } + + fn with_control_plane(mut self, plane: Arc) -> Self { + self.control_plane = Some(plane); + self + } + fn new(data: AppData, config: AppConfig) -> Self { let pins = Arc::new(destination::DestinationPins::default()); Self { @@ -151,6 +175,7 @@ impl AppState { destination: DestinationPolicy::production(), resolver: Arc::new(SystemHostResolver), pins, + control_plane: None, } } @@ -294,6 +319,9 @@ impl AppState { } async fn persist_snapshot(&self, data: &AppData) -> Result<(), String> { + if let Some(plane) = &self.control_plane { + return plane.save(data).await; + } let Some(path) = self.state_path.as_deref() else { return Ok(()); }; @@ -303,7 +331,9 @@ impl AppState { fn health_status(&self) -> HealthStatus { HealthStatus { status: "ok".to_string(), - persistence: if self.state_path.is_some() { + persistence: if self.control_plane.is_some() { + "postgres".to_string() + } else if self.state_path.is_some() { "file".to_string() } else { "memory".to_string() @@ -429,6 +459,7 @@ pub struct SupportBundle { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct HealthStatus { pub status: String, + /// `postgres` (production authority), `file` (loopback/community), or `memory`. pub persistence: String, pub dnsbl_origin: String, pub event_limit: usize, @@ -3214,7 +3245,7 @@ fn outbound_http_client(pins: Arc) -> reqwest::Cli .expect("failed to build fail-closed outbound HTTP client") } -fn bind_is_loopback(bind_addr: &str) -> bool { +pub(crate) fn bind_is_loopback(bind_addr: &str) -> bool { let trimmed = bind_addr.trim(); if let Ok(addr) = trimmed.parse::() { return addr.ip().is_loopback(); @@ -3263,6 +3294,7 @@ pub async fn run_from_env( credentials_path.as_deref(), std::env::var("ADMIN_TOKEN").ok(), std::env::var("ADMIN_TOKENS").ok(), + std::env::var("CONTROL_PLANE_DATABASE_URL").ok(), )?; let config = AppConfig { admin_token: credentials @@ -3327,9 +3359,17 @@ pub async fn run_from_env( in_process, }; let destination_policy = startup_destination_policy(&bind_addr)?; - let state = AppState::load(config) - .await - .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))? + let control_plane_url = credentials + .get_credential(CRED_CONTROL_PLANE_URL) + .map(str::to_owned); + control_plane::require_postgres_for_bind(&bind_addr, control_plane_url.as_deref()) + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + let state = match control_plane_url.as_deref() { + Some(url) => AppState::load_postgres(config, url).await, + None => AppState::load(config).await, + } + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))?; + let state = state .with_rate_limit(rate_limit, rate_limit_window) .with_admin_tokens(admin_tokens) .with_credentials_source(credentials.source()) @@ -3392,6 +3432,7 @@ mod tests { "PROVEN_ENGINE_FAIL_CLOSED", "DESTINATION_ALLOWLIST", "DESTINATION_DENYLIST", + "CONTROL_PLANE_DATABASE_URL", ] { unsafe { std::env::remove_var(name) }; } @@ -3508,6 +3549,25 @@ mod tests { clear_run_env(); } + #[tokio::test] + async fn run_from_env_fail_closes_public_bind_without_postgres() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "0.0.0.0:0"); + std::env::remove_var("CONTROL_PLANE_DATABASE_URL"); + } + let error = run_from_env(Box::pin(std::future::ready(()))) + .await + .expect_err("production bind must require postgres"); + let message = error.to_string(); + assert!( + message.contains("CONTROL_PLANE_DATABASE_URL"), + "operator must see the production authority requirement: {message}" + ); + clear_run_env(); + } + #[tokio::test] async fn run_from_env_rejects_malformed_max_body_bytes() { let _guard = ENV_GUARD.lock().await; diff --git a/tests/binary.rs b/tests/binary.rs index 404eb13d..ec57db7f 100644 --- a/tests/binary.rs +++ b/tests/binary.rs @@ -61,6 +61,7 @@ fn binary_does_not_report_readiness_before_state_validation() { .env_remove("CORAZA_LIB_PATH") .env_remove("CORAZA_RULES_PATH") .env_remove("CORAZA_DIRECTIVES") + .env_remove("CONTROL_PLANE_DATABASE_URL") .output() .expect("spawn gateway binary for startup validation check"); let _ = std::fs::remove_file(&state_path); @@ -112,6 +113,62 @@ fn binary_fail_closes_when_libcoraza_path_is_missing() { ); } +#[test] +fn binary_fail_closes_non_loopback_listen_without_postgres() { + let output = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) + .env("BIND_ADDR", "0.0.0.0:0") + .env_remove("CONTROL_PLANE_DATABASE_URL") + .env_remove("WAF_IDS_STATE_PATH") + .env_remove("ADMIN_TOKEN") + .env_remove("ADMIN_TOKENS") + .env_remove("CORAZA_LIB_PATH") + .output() + .expect("spawn gateway binary for production postgres gate"); + assert!( + !output.status.success(), + "production bind without postgres must fail: {:?}", + output.status + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{stdout}{stderr}"); + assert!( + combined.contains("CONTROL_PLANE_DATABASE_URL"), + "startup error should name the missing control plane:\n{combined}" + ); + assert!( + !combined.contains("waf-ids-ai-soc listening on"), + "readiness must not be reported without production postgres:\n{combined}" + ); +} + +#[test] +fn binary_fail_closes_when_control_plane_url_is_not_postgres() { + let output = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) + .env("BIND_ADDR", "127.0.0.1:0") + .env("CONTROL_PLANE_DATABASE_URL", "mysql://not-postgres") + .env_remove("WAF_IDS_STATE_PATH") + .env_remove("CORAZA_LIB_PATH") + .output() + .expect("spawn gateway binary for control-plane URL check"); + assert!( + !output.status.success(), + "non-postgres URL must fail startup: {:?}", + output.status + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{stdout}{stderr}"); + assert!( + combined.contains("postgres://"), + "startup error should require a postgres URL:\n{combined}" + ); + assert!( + !combined.contains("waf-ids-ai-soc listening on"), + "readiness must not be reported for a rejected control-plane URL:\n{combined}" + ); +} + fn spawn_ready_gateway() -> Child { let mut child = Command::new(env!("CARGO_BIN_EXE_waf-ids-ai-soc")) .env("BIND_ADDR", "127.0.0.1:0") @@ -124,6 +181,7 @@ fn spawn_ready_gateway() -> Child { .env_remove("CORAZA_RULES_PATH") .env_remove("CORAZA_DIRECTIVES") .env_remove("CORAZA_WAF_URL") + .env_remove("CONTROL_PLANE_DATABASE_URL") .stdout(Stdio::piped()) .spawn() .expect("spawn gateway binary"); From 3a19ed9b276d3e14a751fbd5a87087aade713a2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 02:08:07 +0900 Subject: [PATCH 08/17] feat(store): transactional outbox and leased workers Issue #81 first slice on the PostgreSQL control plane. Security events append with an outbox row in one transaction instead of rewriting the snapshot. Workers claim with SKIP LOCKED, retry, dead-letter, and record unique receipts. Stdout SIEM is at-least-once; the receipt is the exactly-once ack. Also deterministic ORDER BY on postgres loads (still-valid #98 finding). Do not re-implement the postgres gate. --- CHANGELOG.md | 1 + CLAUDE.md | 2 +- Cargo.lock | 82 ++- Cargo.toml | 1 + docs/architecture.md | 1 + docs/doctoring/outbox-workers.md | 50 ++ docs/product-technical-gap-baseline.md | 93 ++- scripts/smoke.sh | 2 + src/control_plane.rs | 893 ++++++++++++++++++++++++- src/lib.rs | 241 ++++++- src/outbox.rs | 186 +++++ 11 files changed, 1470 insertions(+), 82 deletions(-) create mode 100644 docs/doctoring/outbox-workers.md create mode 100644 src/outbox.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0070ff0d..6beb98d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- PostgreSQL control-plane mutations enqueue a transactional outbox row in the same transaction (issue #81). Security events append incrementally instead of rewriting the snapshot. A leased worker claims with `FOR UPDATE SKIP LOCKED`, retries with bounded backoff, dead-letters exhausted/permanent failures, and records unique receipts. Stdout SIEM export is at-least-once; the receipt is the exactly-once ack. `/healthz.outbox` and `GET /api/outbox` are operator-visible; `POST /api/outbox/{id}/replay` requeues dead letters with audit. File/memory adapters report `outbox=disabled`. - Production (non-loopback) binds fail closed without `CONTROL_PLANE_DATABASE_URL`. PostgreSQL is the production control-plane authority (3NF two-word tables, default-deny row-level security, snapshot persist in one transaction). Loopback still uses the JSON file / memory adapter. `/healthz.persistence` reports `postgres`, `file`, or `memory`. The URL is a secret and is bootstrapped into the credential registry. - Live `/gateway` transactions consult in-process libcoraza when `CORAZA_LIB_PATH` is set (with `CORAZA_RULES_PATH` and/or `CORAZA_DIRECTIVES`). Missing library, missing rules, or an empty ruleset fail startup before bind. Otherwise a Coraza sidecar is consulted when `CORAZA_WAF_URL` is set. The sidecar response is parsed with the existing Coraza audit adapter (OWASP CRS authority, not a hand-rolled engine). Engine outage is fail-closed when `PROVEN_ENGINE_FAIL_CLOSED` is true. `GET /api/waf/engine-status` and `/healthz.proven_engine` report `coraza_in_process`, `coraza_sidecar`, or `ingest_hints_only`. - Fail-closed destination policy on every outbound `http`/`https` call, including the Coraza sidecar URL (gateway upstream, threat-intel fetch, Clearfolio, SOC LLM). Private/loopback/metadata classes are denied unless `DESTINATION_ALLOWLIST` (or loopback development) permits them; `DESTINATION_DENYLIST` wins. Clients ignore ambient HTTP proxies and do not follow redirects. CIDR allowlist matches apply per resolved address; CIDR entries authorize non-default ports; IPv6 site-local (`fec0::/10`) is denied; invalid CIDR prefixes fail startup; `/healthz.destination_mode` reports the policy class. Blocking DNS runs on `spawn_blocking` with a 2s timeout. Persistence and destination-list validation complete before the readiness line is printed. After a host is allowed, the HTTP client connects only to those evaluated addresses (Host/SNI unchanged) so a rebinding answer cannot bypass the policy. diff --git a/CLAUDE.md b/CLAUDE.md index d3843fac..f54f1569 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ The core stays an in-repo workspace crate on purpose (no git submodule) until it ## Runtime Configuration -Read in `run_from_env` (`src/lib.rs`): `BIND_ADDR` (default `127.0.0.1:8080`), `ADMIN_TOKEN` (write token for `X-Admin-Token`), `ADMIN_TOKENS` (comma-separated `token:actor` pairs for multi-token RBAC with per-token audit actors), `WAF_IDS_STATE_PATH` (optional JSON state file; omitted = seeded in-memory state), `DNSBL_ORIGIN` (default `dnsbl.local`), `EVENT_LIMIT` (default 1000, must be > 0), `RATE_LIMIT` / `RATE_LIMIT_WINDOW`, `CORAZA_LIB_PATH` / `CORAZA_RULES_PATH` / `CORAZA_DIRECTIVES` (optional in-process libcoraza), `CORAZA_WAF_URL` (optional in-path Coraza sidecar), `PROVEN_ENGINE_FAIL_CLOSED` (boolean; default false — set true in production when an engine is set). +Read in `run_from_env` (`src/lib.rs`): `BIND_ADDR` (default `127.0.0.1:8080`), `ADMIN_TOKEN` (write token for `X-Admin-Token`), `ADMIN_TOKENS` (comma-separated `token:actor` pairs for multi-token RBAC with per-token audit actors), `WAF_IDS_STATE_PATH` (optional JSON state file; omitted = seeded in-memory state), `CONTROL_PLANE_DATABASE_URL` (required for non-loopback binds; secret `control_plane_url`), `DNSBL_ORIGIN` (default `dnsbl.local`), `EVENT_LIMIT` (default 1000, must be > 0), `RATE_LIMIT` / `RATE_LIMIT_WINDOW`, `CORAZA_LIB_PATH` / `CORAZA_RULES_PATH` / `CORAZA_DIRECTIVES` (optional in-process libcoraza), `CORAZA_WAF_URL` (optional in-path Coraza sidecar), `PROVEN_ENGINE_FAIL_CLOSED` (boolean; default false — set true in production when an engine is set). PostgreSQL mode starts a leased outbox worker (`GET /api/outbox`, `/healthz.outbox`). ## Key Conventions diff --git a/Cargo.lock b/Cargo.lock index a05a89e4..3c910412 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -160,7 +169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -176,6 +185,15 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -185,6 +203,16 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -203,15 +231,25 @@ dependencies = [ "cmov", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "const-oid", - "crypto-common", + "crypto-common 0.2.2", "ctutils", ] @@ -330,6 +368,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -375,7 +423,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -689,7 +737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -826,7 +874,7 @@ dependencies = [ "md-5", "memchr", "rand 0.10.2", - "sha2", + "sha2 0.11.0", "stringprep", ] @@ -1255,6 +1303,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -1262,8 +1321,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -1651,6 +1710,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "waf-ids-ai-soc" version = "0.1.0" @@ -1662,6 +1727,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2 0.10.9", "tokio", "tokio-postgres", "tower", diff --git a/Cargo.toml b/Cargo.toml index 88185c4d..839f999c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ serde_json = "1" tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } waf-ids-core = { path = "crates/waf-ids-core" } tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"] } +sha2 = "0.10" [dev-dependencies] tower = { version = "0.5", features = ["util"] } diff --git a/docs/architecture.md b/docs/architecture.md index b998080b..0abf4937 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,6 +30,7 @@ flowchart LR - `src/main.rs`: process startup and operator configuration from `BIND_ADDR`, `ADMIN_TOKEN`, `WAF_IDS_STATE_PATH`, `DNSBL_ORIGIN`, and `EVENT_LIMIT`. - `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. Persistence, destination-list, and sidecar settings validate before the readiness line is printed. - `src/control_plane.rs`: PostgreSQL production authority (issue #80). Non-loopback binds require `CONTROL_PLANE_DATABASE_URL`. Tenant isolation is default-deny RLS. The JSON file adapter remains loopback/community only. +- `src/outbox.rs`: transactional outbox + leased workers (issue #81). Security events append incrementally with an outbox row in the same transaction. Workers claim with `SKIP LOCKED`. `GET /api/outbox` and `/healthz.outbox` are operator-visible. - `src/destination.rs`: fail-closed outbound URL policy (issue #79) for every `http`/`https` send. CIDR allowlist exceptions are per resolved address; blocking DNS is offloaded from Tokio workers. The outbound HTTP client DNS resolver returns only addresses that already passed policy (TCP peer pin / DNS-rebinding TOCTOU close). - `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. diff --git a/docs/doctoring/outbox-workers.md b/docs/doctoring/outbox-workers.md new file mode 100644 index 00000000..a4f69307 --- /dev/null +++ b/docs/doctoring/outbox-workers.md @@ -0,0 +1,50 @@ +# Doctoring — transactional outbox and leased workers + +This note grounds issue #81 (external effects leave the PostgreSQL control plane +through a transactional outbox, not request-path retry loops). IEEE/ACM PDFs +are not redistributed. + +## Adopted standards and literature + +PostgreSQL Global Development Group. (2026). *PostgreSQL documentation: Explicit +locking*. https://www.postgresql.org/docs/current/explicit-locking.html + +- **Design impact:** Workers claim `outbox_message` rows with + `FOR UPDATE SKIP LOCKED`. Expired leases are reclaimable. Unrelated tenants + and aggregates are not globally serialized. + +PostgreSQL Global Development Group. (2026). *PostgreSQL documentation: +Transaction isolation*. https://www.postgresql.org/docs/current/transaction-iso.html + +- **Design impact:** The security event (or policy snapshot) and its outbox row + commit in one transaction. A crash after domain commit but before dispatch + leaves a pending message; it cannot invent extra authority. + +Hohpe, G., & Woolf, B. (2003). *Enterprise integration patterns: Designing, +building, and deploying messaging solutions*. Addison-Wesley. + +- **Design impact:** Transactional outbox. Downstream stdout SIEM export is + **at-least-once**. The `outbox_receipt` unique `(tenant_id, idempotency_key)` + is the exactly-once business acknowledgement. Do not call transport delivery + exactly once. + +National Institute of Standards and Technology. (2022). *Secure Software +Development Framework (SSDF) version 1.1* (NIST SP 800-218). +https://doi.org/10.6028/NIST.SP.800-218 + +- **Design impact:** PW.1 / PW.7 — durable retry, dead-letter, and authorized + replay with audit. Replay is a write (`X-Admin-Token`). + +## Operator next action + +Production binds already require `CONTROL_PLANE_DATABASE_URL`. On that path: + +- `GET /healthz` reports `outbox=ready` plus pending/leased/dead-letter counts +- `GET /api/outbox` (admin read) lists messages +- `POST /api/outbox/{message_id}/replay` (admin write) requeues dead letters + +Loopback file/memory adapters keep in-process stdout SIEM and report +`outbox=disabled`. Remaining: rustls for the control-plane connection, a +non-owner runtime role, backup/restore drill, HASH partitioning, and additional +consumers (TAXII poll, Clearfolio, contextual-orchestrator) on the same +message/receipt contract. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 8c3715c0..2867a9de 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product and technical gap baseline -Snapshot date: 2026-08-23T16:20Z (exact-head inventory of then-open GitHub PRs +Snapshot date: 2026-08-23T17:06Z (exact-head inventory of then-open GitHub PRs and Issues plus operator-perceptible gaps). Update this file on every hourly loop. Commercial contract and `/api/commercial/readiness` remain **2B KRW**. The @@ -25,13 +25,15 @@ not “waiting on review/CI time”. | PR | Title | Head | Checks | Reviews | Merge blocker | | --- | --- | --- | --- | --- | --- | -| [#97](https://github.com/ContextualWisdomLab/wardnet/pull/97) | feat(waf): evaluate live gateway transactions with in-process libcoraza | `feat/issue-86-in-process-libcoraza` stacked on #96 | local fmt/test/clippy + two `/healthz` smokes this hour | Author this pass | Org 2-approval + self-author. Merge #95 then #96 first. Do not `--admin`. Do not re-implement sidecar or pin. | -| [#96](https://github.com/ContextualWisdomLab/wardnet/pull/96) | feat(security): fail-closed destination policy for outbound HTTP | `7cacaf135179` (`feat/issue-79-destination-policy`) stacked on #95 | rust + fuzz green at last snapshot; remaining Devin threads are info/KV-deviation | Author this pass; Devin/Codex COMMENTED. Remaining unresolved: DESTINATION_* env (documented operational-config deviation), hostname-allowlist mixed answers (intended), sidecar loopback needs allowlist in production, pin-cap eviction info | Org 2-approval + self-author. Merge #95 first. Do not re-implement the TCP-peer pin. | +| [#99](https://github.com/ContextualWisdomLab/wardnet/pull/99) | feat(store): transactional outbox and leased workers | `feat/issue-81-outbox-workers` stacked on #98 | local fmt/test/clippy + two `/healthz` smokes + postgres `/healthz.outbox=ready` this hour | Author this pass | Org 2-approval + self-author. Merge #95 then #96 then #97 then #98 first. Do not `--admin`. Do not re-implement the postgres gate. | +| [#98](https://github.com/ContextualWisdomLab/wardnet/pull/98) | feat(store): require PostgreSQL as the production control plane | `ea621985e276` (`feat/issue-80-postgres-control-plane`) stacked on #97 | rust + fuzz green at last snapshot; Devin 7 threads (full-snapshot rewrite, ORDER BY, TLS, RLS owner, reconnect) | Author this pass; Devin COMMENTED | Org 2-approval + self-author. ORDER BY + incremental event persist addressed on #99. Remaining rustls / non-owner role / backup are #80 remainder. Do not `--admin`. | +| [#97](https://github.com/ContextualWisdomLab/wardnet/pull/97) | feat(waf): evaluate live gateway transactions with in-process libcoraza | `feat/issue-86-in-process-libcoraza` stacked on #96 | local fmt/test/clippy + two `/healthz` smokes prior hour | Author this pass | Org 2-approval + self-author. Merge #95 then #96 first. Do not `--admin`. Do not re-implement sidecar or pin. | +| [#96](https://github.com/ContextualWisdomLab/wardnet/pull/96) | feat(security): fail-closed destination policy for outbound HTTP | `7cacaf135179` (`feat/issue-79-destination-policy`) stacked on #95 | rust + fuzz green at last snapshot; remaining Devin threads are info/KV-deviation | Author this pass; Devin/Codex COMMENTED | Org 2-approval + self-author. Merge #95 first. Do not re-implement the TCP-peer pin. | | [#95](https://github.com/ContextualWisdomLab/wardnet/pull/95) | feat(waf): consult Coraza sidecar on live gateway transactions | `ba9ee3a0b142` (`feat/issue-86-in-path-coraza`) | rust + Security Scan green at last snapshot | Author this pass; Devin/Codex COMMENTED | Org 2-approval + self-author. Do not re-implement sidecar slice. | | [#94](https://github.com/ContextualWisdomLab/wardnet/pull/94) | fix(auth): fail closed without write-capable admin on public bind | `f31d960a0b52` (`fix/issue-78-fail-closed-credentials`) | Checks re-ran after readiness-order fix | Author `seonghobae`; Devin COMMENTED | Org 2-approval + self-author. Do not `--admin` merge. Do not re-implement #78. | | [#93](https://github.com/ContextualWisdomLab/wardnet/pull/93) | test(persistence): replace permission-based fault injection with a deterministic seam | `f77eb69748ec` | rust + Security Scan green; **strix FAILURE** (org LiteLLM provider `openai-direct/gpt-5.6-luna`) | Author `seonghobae`; Devin COMMENTED | strix org-provider FAILURE + 2-approval + self-author. Do not rotate review-agent keys. | -| [#92](https://github.com/ContextualWisdomLab/wardnet/pull/92) | build(deps): bump github/codeql-action/upload-sarif from 4.37.6 to 4.37.7 | dependabot `17277d78d5e1` | All green. `--auto` squash already enabled. Copilot review re-requested this hour. | Maintainer APPROVED (1 of 2). | **Second independent APPROVE missing**. `gh pr merge` rejected by ruleset 18156473. | -| [#91](https://github.com/ContextualWisdomLab/wardnet/pull/91) | build(deps): bump futures-util from 0.3.33 to 0.3.34 | dependabot `c4662caebfa1` | All green. `--auto` squash already enabled. Copilot review re-requested this hour. | Maintainer APPROVED (1 of 2). | Same as #92: second independent APPROVE missing. | +| [#92](https://github.com/ContextualWisdomLab/wardnet/pull/92) | build(deps): bump github/codeql-action/upload-sarif from 4.37.6 to 4.37.7 | dependabot `17277d78d5e1` | All green. `--auto` squash already enabled. | Maintainer APPROVED (1 of 2). | **Second independent APPROVE missing**. `gh pr merge` rejected by ruleset 18156473. | +| [#91](https://github.com/ContextualWisdomLab/wardnet/pull/91) | build(deps): bump futures-util from 0.3.33 to 0.3.34 | dependabot `c4662caebfa1` | All green. `--auto` squash already enabled. | Maintainer APPROVED (1 of 2). | Same as #92: second independent APPROVE missing. | | [#90](https://github.com/ContextualWisdomLab/wardnet/pull/90) | feat(observability): export Wardnet events to SIEM and OpenTelemetry | `40f11b93a972` | All green (35). | Author `seonghobae`; CodeRabbit/Devin/GHAS COMMENTED. **0 unresolved threads** on exact head. | Org 2-approval + self-author. | | [#88](https://github.com/ContextualWisdomLab/wardnet/pull/88) | feat(security): reject non-LiteLLM credentials before upstream | `41b21cfe2168` | All green (35). | Author `seonghobae`; CodeRabbit COMMENTED. **0 unresolved threads** on exact head. | Org 2-approval + self-author. | | [#77](https://github.com/ContextualWisdomLab/wardnet/pull/77) | build(rust): pin and track Rust 1.97.1 | `a13c08656177` | rust green; **strix FAILURE**. Same org-provider fail-closed as #93. | Author `seonghobae`; Devin COMMENTED. | strix org-provider FAILURE + 2-approval + self-author. | @@ -52,8 +54,8 @@ by ruleset `18156473` (not by failing Checks). Do not `--admin` merge. | [#84](https://github.com/ContextualWisdomLab/wardnet/issues/84) | [P1] Build an immutable signed release, promotion, and rollback pipeline | high | | [#83](https://github.com/ContextualWisdomLab/wardnet/issues/83) | [P1] Add bounded distributed admission control, trusted client attribution, and overload behavior | high | | [#82](https://github.com/ContextualWisdomLab/wardnet/issues/82) | [P1] Integrate Keyverse identity, tenant authorization, consent, and human approval evidence | high (blocked) | -| [#81](https://github.com/ContextualWisdomLab/wardnet/issues/81) | [P0] Add a transactional outbox and idempotent leased workers for external effects | **critical** | -| [#80](https://github.com/ContextualWisdomLab/wardnet/issues/80) | [P0] Add an authoritative PostgreSQL control plane with tenant isolation and recoverable migrations | **critical** | +| [#81](https://github.com/ContextualWisdomLab/wardnet/issues/81) | [P0] Add a transactional outbox and idempotent leased workers for external effects | **critical — first slice this pass** | +| [#80](https://github.com/ContextualWisdomLab/wardnet/issues/80) | [P0] Add an authoritative PostgreSQL control plane with tenant isolation and recoverable migrations | **critical — gate on #98; rustls/backup remainder** | | [#79](https://github.com/ContextualWisdomLab/wardnet/issues/79) | [P0] Enforce a fail-closed destination policy for all outbound traffic | **critical — closed in runtime on #96** | | [#78](https://github.com/ContextualWisdomLab/wardnet/issues/78) | [P0] Fail closed when management credentials are absent | **critical — closed in runtime on #94** | | [#75](https://github.com/ContextualWisdomLab/wardnet/issues/75) | Rename Kubernetes manifest to wardnet.yaml after external-secret hardening lands | medium | @@ -71,20 +73,17 @@ still say `waf-ids-ai-soc`. Kubernetes manifest remains this pass: docs and health copy already mention Wardnet in newer surfaces; wholesale crate rename is deferred (not a merge blocker). -### Proven-engine enforcement (issue #86) — **in-process libcoraza this pass** +### Proven-engine enforcement (issue #86) — **in-process libcoraza shipped, unmerged** Coraza/Suricata ingest still maps proven-engine hits into DNSBL + threat indicators. PR #95 consults a Coraza sidecar on each live `/gateway` -transaction when `CORAZA_WAF_URL` is set. This pass also `dlopen`s -operator-supplied libcoraza (`CORAZA_LIB_PATH` + `CORAZA_RULES_PATH` and/or -`CORAZA_DIRECTIVES`) and evaluates the same live transactions through the -libcoraza C ABI (`src/coraza_inprocess.rs`). In-process wins over sidecar when -both are set. Missing library, missing rules, or an empty ruleset fail -startup before bind. `GET /api/waf/engine-status` and `/healthz.proven_engine` -report `coraza_in_process` / `coraza_sidecar` / `ingest_hints_only`. CI stays -hermetic with a fixture cdylib that exports the same symbols; production -points at a real libcoraza + CRS bundle. Suricata tail/shipper and -detection-quality corpora remain open. +transaction when `CORAZA_WAF_URL` is set. PR #97 `dlopen`s operator-supplied +libcoraza (`CORAZA_LIB_PATH` + `CORAZA_RULES_PATH` and/or `CORAZA_DIRECTIVES`) +and evaluates the same live transactions through the libcoraza C ABI +(`src/coraza_inprocess.rs`). In-process wins over sidecar when both are set. +Missing library, missing rules, or an empty ruleset fail startup before bind. +`GET /api/waf/engine-status` and `/healthz.proven_engine` report +`coraza_in_process` / `coraza_sidecar` / `ingest_hints_only`. Do not re-implement. ### Identity (issue #82, Keyverse) @@ -92,7 +91,7 @@ Management auth is shared secrets (`X-Admin-Token`) plus optional multi-token RBAC. Keyverse (OIDC/SCIM/FIDO2) is not wired. Fail-closed (#78) is the prerequisite shipped on PR #94. -### Durable control plane (issue #80) — **production gate + RLS snapshot this pass** +### Durable control plane (issue #80) — **production gate on #98** PostgreSQL is required for non-loopback binds (`CONTROL_PLANE_DATABASE_URL`). `src/control_plane.rs` migrates 3NF two-word tables with default-deny RLS @@ -102,6 +101,20 @@ transaction. JSON file / memory remain loopback/community only. non-owner role, backup/restore drill, event HASH partitioning, optimistic concurrency. +### Transactional outbox (issue #81) — **first slice this pass** + +On the PostgreSQL authority, security events append (`security_event` + +`outbox_message`) in one transaction instead of rewriting every table. +Policy snapshots enqueue `policy.snapshot_replaced`. A leased worker claims +with `FOR UPDATE SKIP LOCKED`, retries with bounded exponential backoff, +dead-letters permanent/exhausted failures, and records unique receipts. +Stdout SIEM export is **at-least-once**; the receipt is the exactly-once ack. +Operator-visible: `/healthz.outbox` (`ready`|`disabled`), pending/leased/ +dead-letter counts, `GET /api/outbox` (admin read), `POST /api/outbox/{id}/replay` +(admin write + audit). Client IPs and paths in payloads are not masked. +File/memory adapters stay `outbox=disabled` with in-process stdout. Remaining +consumers: TAXII poll, Clearfolio, contextual-orchestrator on the same contract. + ### Fail-closed credentials (issue #78) — **closed on PR #94** Shipped on `fix/issue-78-fail-closed-credentials`. Do not re-implement. @@ -116,7 +129,8 @@ Production sidecar URLs on loopback/private still need `DESTINATION_ALLOWLIST` ### SIEM / OpenTelemetry (issue #85 / PR #90) `/api/events.ndjson` and stdout JSON lines exist on main. Full exporter binary -and OTel sit on PR #90, blocked by the 2-approval ruleset. +and OTel sit on PR #90, blocked by the 2-approval ruleset. The #81 worker now +replays `security_event.recorded` as stdout SIEM with receipts. ### UI-UX / Storybook / Figma @@ -128,6 +142,7 @@ and OTel sit on PR #90, blocked by the 2-approval ruleset. | Figma Code Connect | Not used | | Ten UI-UX areas | Inventoried in `docs/ui-ux/storybook-scene-inventory.md` | | Node Storybook | **Not hosted in `/admin`** (embedded-console architecture). File:// inventory is the scene/edge-case contract this pass. | +| Outbox card | Embedded `/admin` Outbox section this pass | ### CSAP / SOC 2 vs PII unmasking @@ -139,36 +154,40 @@ future encryption-at-rest. ### Coverage / docstring bar Org 100% line/branch/docstring applies to **changed** surfaces this loop -(libcoraza loader, engine-status in-process fields, startup fail-closed, -gateway consult). Remaining holes on untouched handlers stay listed for later -loops. +(outbox schema, claim/ack/dead-letter/replay, incremental event persist, +health/API, admin card). Remaining holes on untouched handlers stay listed +for later loops. ### Ecosystem connectors (leverage order) 1. **keyverse** — identity for management plane (#82). 2. **contextual-orchestrator** — SOC LLM already optional via - `SOC_LLM_BASE_URL`; keep adapter, do not fork routing. + `SOC_LLM_BASE_URL`; keep adapter, do not fork routing. Next: same outbox + contract. 3. **naruon** / **clearfolio** — document viewer already optional. 4. **TEPP / RankWeave / ThreadWeave / LineageWeave / disksage / fast-mlsirm** — not on the gateway data path; no connector this pass. ## This loop’s shipped gap -Issue **#80** first slice (PostgreSQL production authority). Non-loopback binds -fail closed without `CONTROL_PLANE_DATABASE_URL`. Operator-visible: -`/healthz.persistence=postgres`; credentials key `control_plane_url`. Driving -tests: `run_from_env_fail_closes_public_bind_without_postgres`, -`binary_fail_closes_non_loopback_listen_without_postgres`, -`binary_fail_closes_when_control_plane_url_is_not_postgres`, -`postgres_roundtrip_seeded_snapshot_when_database_url_is_set` (CI postgres -service). Do not re-implement #78, the #86 sidecar/libcoraza slices, or the -#79 pin. +Issue **#81** first slice (transactional outbox + leased workers) on the #80 +PostgreSQL authority. Also the still-valid #98 ORDER BY on load queries and +incremental security-event persist (gateway path no longer rewrites the whole +snapshot). Operator-visible: `/healthz.outbox=ready` on postgres, +`GET /api/outbox`, admin Outbox card. Driving tests: +`postgres_appends_event_and_outbox_atomically`, +`postgres_outbox_worker_is_idempotent_and_dead_letters`, +`postgres_expired_lease_is_reclaimed_and_skip_locked_is_exclusive`, +`outbox_api_is_admin_authenticated_and_disabled_without_postgres`. +Do not re-implement #78, the #86 sidecar/libcoraza slices, the #79 pin, or +the #80 production postgres gate. ## Next hourly loop (do, do not report) 1. Second independent APPROVE on #91/#92. Do not `--admin`. -2. Keep #94/#95/#96/#97 and this #80 PR merge-ready. Merge order #95 then #96 - then #97 then this. Do not re-implement shipped slices. -3. Next runtime gap if policy still blocks: #81 outbox/workers on this - postgres authority, or rustls / backup drill remainder of #80. +2. Keep #94/#95/#96/#97/#98 and this #81 PR merge-ready. Merge order #95 then + #96 then #97 then #98 then this. Do not re-implement shipped slices. +3. Next runtime gap if policy still blocks: rustls / backup drill remainder of + #80, or additional #81 consumers (TAXII / Clearfolio / orchestrator) on this + outbox. 4. Refresh this file’s PR/Issue tables from `gh pr list` / `gh issue list`. diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 59472dc1..9530cbc8 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -82,6 +82,8 @@ assert_json_field "$health" 'data["event_limit"] == 5' assert_json_field "$health" 'data["proven_engine"] == "ingest_hints_only"' assert_json_field "$health" 'data["proven_engine_fail_closed"] is False' assert_json_field "$health" 'data["destination_mode"] == "development"' +assert_json_field "$health" 'data["outbox"] == "disabled"' +assert_json_field "$health" 'data["outbox_pending"] == 0' engine_status="$(curl -fsS "$BASE_URL/api/waf/engine-status")" assert_json_field "$engine_status" 'data["mode"] == "ingest_hints_only"' diff --git a/src/control_plane.rs b/src/control_plane.rs index 5fd10d7e..f5df2d90 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -5,10 +5,15 @@ //! production authority. Tenant isolation is default-deny row-level security //! with `FORCE ROW LEVEL SECURITY`; each transaction sets `wardnet.tenant_id`. +use crate::outbox::{ + self, CLAIM_BATCH, DispatchError, EVENT_SECURITY_RECORDED, EVENT_SNAPSHOT_REPLACED, + LEASE_SECONDS, OutboxHealth, OutboxMessage, SCHEMA_VERSION, STATUS_DEAD_LETTER, STATUS_LEASED, + STATUS_PENDING, STATUS_PROCESSED, +}; use std::net::IpAddr; use std::str::FromStr; use tokio::sync::Mutex; -use tokio_postgres::{Client, GenericClient, NoTls}; +use tokio_postgres::{Client, GenericClient, NoTls, Transaction}; use waf_ids_core::{ AppData, AuditLogEntry, CommercialProfile, DnsblEntry, EnforcementMode, LicenseStatus, ProductEdition, RouteConfig, SecurityEvent, Severity, ThreatFeedStatus, ThreatIndicator, @@ -17,7 +22,7 @@ use waf_ids_core::{ /// Default tenant used until Keyverse supplies claims (#82). pub const DEFAULT_TENANT_ID: &str = "local-lab"; -const MIGRATION_VERSION: i32 = 1; +const MIGRATION_VERSION: i32 = 2; /// Recoverable forward migration. Two-word snake_case names, 3NF, RLS. pub const MIGRATION_SQL: &str = r#" @@ -117,6 +122,41 @@ CREATE TABLE IF NOT EXISTS threat_feed ( CREATE INDEX IF NOT EXISTS security_event_tenant_event ON security_event (tenant_id, event_id); +CREATE TABLE IF NOT EXISTS outbox_message ( + tenant_id TEXT NOT NULL REFERENCES tenant_account (tenant_id), + message_id TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + aggregate_version BIGINT NOT NULL, + event_type TEXT NOT NULL, + schema_version INTEGER NOT NULL, + created_unix BIGINT NOT NULL, + payload_json TEXT NOT NULL, + payload_hash TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + message_status TEXT NOT NULL, + lease_owner TEXT, + lease_expires_unix BIGINT, + attempt_count INTEGER NOT NULL, + first_attempt_unix BIGINT, + last_attempt_unix BIGINT, + next_available_unix BIGINT NOT NULL, + terminal_reason TEXT, + PRIMARY KEY (tenant_id, message_id), + UNIQUE (tenant_id, idempotency_key) +); + +CREATE TABLE IF NOT EXISTS outbox_receipt ( + tenant_id TEXT NOT NULL REFERENCES tenant_account (tenant_id), + idempotency_key TEXT NOT NULL, + message_id TEXT NOT NULL, + processed_unix BIGINT NOT NULL, + receipt_evidence TEXT NOT NULL, + PRIMARY KEY (tenant_id, idempotency_key) +); + +CREATE INDEX IF NOT EXISTS outbox_message_claim + ON outbox_message (tenant_id, message_status, next_available_unix); + ALTER TABLE tenant_account ENABLE ROW LEVEL SECURITY; ALTER TABLE tenant_account FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS tenant_isolation ON tenant_account; @@ -172,6 +212,20 @@ DROP POLICY IF EXISTS tenant_isolation ON threat_feed; CREATE POLICY tenant_isolation ON threat_feed USING (tenant_id = current_setting('wardnet.tenant_id', true)) WITH CHECK (tenant_id = current_setting('wardnet.tenant_id', true)); + +ALTER TABLE outbox_message ENABLE ROW LEVEL SECURITY; +ALTER TABLE outbox_message FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON outbox_message; +CREATE POLICY tenant_isolation ON outbox_message + USING (tenant_id = current_setting('wardnet.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('wardnet.tenant_id', true)); + +ALTER TABLE outbox_receipt ENABLE ROW LEVEL SECURITY; +ALTER TABLE outbox_receipt FORCE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON outbox_receipt; +CREATE POLICY tenant_isolation ON outbox_receipt + USING (tenant_id = current_setting('wardnet.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('wardnet.tenant_id', true)); "#; /// Fail closed when a non-loopback bind has no control-plane URL. @@ -210,6 +264,9 @@ pub fn parse_database_url(raw: &str) -> Result { Ok(raw.to_string()) } +/// Serializes schema application across connections (DROP/CREATE POLICY is not concurrent-safe). +static MIGRATION_GATE: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(())); + /// Live PostgreSQL snapshot store for one tenant. pub struct PostgresPlane { client: Mutex, @@ -218,6 +275,10 @@ pub struct PostgresPlane { impl PostgresPlane { pub async fn connect(url: &str) -> Result { + Self::connect_tenant(url, DEFAULT_TENANT_ID).await + } + + pub async fn connect_tenant(url: &str, tenant_id: &str) -> Result { let url = parse_database_url(url)?; let (client, connection) = tokio_postgres::connect(&url, NoTls) .await @@ -227,18 +288,32 @@ impl PostgresPlane { }); let plane = Self { client: Mutex::new(client), - tenant_id: DEFAULT_TENANT_ID.to_string(), + tenant_id: tenant_id.to_string(), }; plane.migrate().await?; Ok(plane) } async fn migrate(&self) -> Result<(), String> { + let _gate = MIGRATION_GATE.lock().await; let client = self.client.lock().await; + let applied = match client + .query_one( + "SELECT COALESCE(MAX(migration_version), 0) FROM schema_migration", + &[], + ) + .await + { + Ok(row) => row.get::<_, i32>(0), + Err(_) => 0, + }; + if applied >= MIGRATION_VERSION { + return Ok(()); + } client .batch_execute(MIGRATION_SQL) .await - .map_err(|error| format!("control plane migration failed: {error}"))?; + .map_err(|error| format!("control plane migration failed: {error:?}"))?; client .execute( "INSERT INTO schema_migration (migration_version) VALUES ($1) ON CONFLICT (migration_version) DO NOTHING", @@ -255,11 +330,49 @@ impl PostgresPlane { load_snapshot(&mut client, &self.tenant_id).await } - /// Replace the tenant snapshot in one transaction (mutation + audit). + /// Replace the tenant snapshot in one transaction (mutation + audit + outbox). pub async fn save(&self, data: &AppData) -> Result<(), String> { let mut client = self.client.lock().await; save_snapshot(&mut client, &self.tenant_id, data).await } + + /// Append one security event and its outbox row without rewriting the snapshot. + pub async fn append_security_event( + &self, + event: &SecurityEvent, + event_limit: usize, + ) -> Result<(), String> { + let mut client = self.client.lock().await; + append_security_event(&mut client, &self.tenant_id, event, event_limit).await + } + + pub async fn drain_once( + &self, + owner: &str, + now_unix: i64, + dispatch: F, + ) -> Result + where + F: Fn(&OutboxMessage) -> Result, + { + let mut client = self.client.lock().await; + drain_once(&mut client, &self.tenant_id, owner, now_unix, dispatch).await + } + + pub async fn outbox_health(&self, now_unix: i64) -> Result { + let mut client = self.client.lock().await; + outbox_health(&mut client, &self.tenant_id, now_unix).await + } + + pub async fn list_outbox(&self) -> Result, String> { + let mut client = self.client.lock().await; + list_outbox(&mut client, &self.tenant_id).await + } + + pub async fn replay_dead_letter(&self, message_id: &str, now_unix: i64) -> Result<(), String> { + let mut client = self.client.lock().await; + replay_dead_letter(&mut client, &self.tenant_id, message_id, now_unix).await + } } async fn load_snapshot(client: &mut Client, tenant_id: &str) -> Result, String> { @@ -504,6 +617,8 @@ async fn save_snapshot(client: &mut Client, tenant_id: &str, data: &AppData) -> .map_err(|error| format!("control plane insert threat_feed failed: {error}"))?; } + enqueue_snapshot_outbox(&tx, tenant_id, data).await?; + tx.commit() .await .map_err(|error| format!("control plane commit failed: {error}"))?; @@ -551,7 +666,7 @@ async fn load_routes( let rows = client .query( "SELECT route_id, path_prefix, upstream_url, enforcement_mode, is_enabled, block_threshold - FROM route_config WHERE tenant_id = $1", + FROM route_config WHERE tenant_id = $1 ORDER BY route_id", &[&tenant_id], ) .await @@ -577,7 +692,8 @@ async fn load_threats( let rows = client .query( "SELECT indicator_type, indicator_value, indicator_source, severity_name, ttl_seconds - FROM threat_indicator WHERE tenant_id = $1", + FROM threat_indicator WHERE tenant_id = $1 + ORDER BY indicator_type, indicator_value, indicator_source", &[&tenant_id], ) .await @@ -602,7 +718,7 @@ async fn load_dnsbl( let rows = client .query( "SELECT host_address, response_code, block_reason, entry_source, ttl_seconds, prefix_length - FROM dnsbl_entry WHERE tenant_id = $1", + FROM dnsbl_entry WHERE tenant_id = $1 ORDER BY host_address", &[&tenant_id], ) .await @@ -693,7 +809,7 @@ async fn load_feeds( let rows = client .query( "SELECT feed_id, feed_source, last_updated_unix, threat_count, dnsbl_count, ttl_seconds - FROM threat_feed WHERE tenant_id = $1", + FROM threat_feed WHERE tenant_id = $1 ORDER BY feed_id", &[&tenant_id], ) .await @@ -711,6 +827,543 @@ async fn load_feeds( .collect()) } +async fn enqueue_snapshot_outbox( + tx: &Transaction<'_>, + tenant_id: &str, + data: &AppData, +) -> Result<(), String> { + let payload = serde_json::json!({ + "route_count": data.routes.len(), + "threat_count": data.threats.len(), + "dnsbl_count": data.dnsbl.len(), + "event_count": data.events.len(), + "audit_count": data.audit_logs.len(), + "event_sequence": data.next_event_id, + "audit_sequence": data.next_audit_log_id, + }) + .to_string(); + let hash = outbox::payload_hash(&payload); + let (message_id, idempotency_key) = + outbox::snapshot_ids(tenant_id, data.next_event_id, data.next_audit_log_id, &hash); + insert_outbox( + tx, + tenant_id, + &OutboxInsert { + message_id, + aggregate_id: tenant_id.to_string(), + aggregate_version: data.next_audit_log_id as i64, + event_type: EVENT_SNAPSHOT_REPLACED, + created_unix: unix_now_i64(), + payload_json: payload, + payload_hash: hash, + idempotency_key, + }, + ) + .await +} + +async fn append_security_event( + client: &mut Client, + tenant_id: &str, + event: &SecurityEvent, + event_limit: usize, +) -> Result<(), String> { + let tx = client + .transaction() + .await + .map_err(|error| format!("control plane event transaction failed: {error}"))?; + tx.execute( + "SELECT set_config('wardnet.tenant_id', $1, true)", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane tenant context failed: {error}"))?; + + let next_event_id = event.id.saturating_add(1) as i64; + tx.execute( + "INSERT INTO tenant_account (tenant_id, event_sequence, audit_sequence) + VALUES ($1, $2, 1) + ON CONFLICT (tenant_id) DO UPDATE SET + event_sequence = GREATEST(tenant_account.event_sequence, EXCLUDED.event_sequence)", + &[&tenant_id, &next_event_id], + ) + .await + .map_err(|error| format!("control plane upsert tenant_account failed: {error}"))?; + + let client_address = event.client_ip.map(|ip| ip.to_string()); + tx.execute( + "INSERT INTO security_event ( + tenant_id, event_id, timestamp_unix, client_address, route_id, + action_name, event_reason, event_score, request_path + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + ON CONFLICT (tenant_id, event_id) DO NOTHING", + &[ + &tenant_id, + &(event.id as i64), + &(event.timestamp_unix as i64), + &client_address, + &event.route_id, + &event.action, + &event.reason, + &i32::from(event.score), + &event.path, + ], + ) + .await + .map_err(|error| format!("control plane insert security_event failed: {error}"))?; + + let keep_from = next_event_id.saturating_sub(event_limit.max(1) as i64); + tx.execute( + "DELETE FROM security_event WHERE tenant_id = $1 AND event_id < $2", + &[&tenant_id, &keep_from], + ) + .await + .map_err(|error| format!("control plane event retention failed: {error}"))?; + + let payload = serde_json::to_string(event).expect("SecurityEvent is JSON-serializable"); + let hash = outbox::payload_hash(&payload); + let (message_id, idempotency_key) = outbox::security_event_ids(tenant_id, event.id); + insert_outbox( + &tx, + tenant_id, + &OutboxInsert { + message_id, + aggregate_id: event.id.to_string(), + aggregate_version: event.id as i64, + event_type: EVENT_SECURITY_RECORDED, + created_unix: event.timestamp_unix as i64, + payload_json: payload, + payload_hash: hash, + idempotency_key, + }, + ) + .await?; + + tx.commit() + .await + .map_err(|error| format!("control plane event commit failed: {error}"))?; + Ok(()) +} + +struct OutboxInsert { + message_id: String, + aggregate_id: String, + aggregate_version: i64, + event_type: &'static str, + created_unix: i64, + payload_json: String, + payload_hash: String, + idempotency_key: String, +} + +async fn insert_outbox( + tx: &Transaction<'_>, + tenant_id: &str, + row: &OutboxInsert, +) -> Result<(), String> { + tx.execute( + "INSERT INTO outbox_message ( + tenant_id, message_id, aggregate_id, aggregate_version, event_type, + schema_version, created_unix, payload_json, payload_hash, idempotency_key, + message_status, attempt_count, next_available_unix + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,0,$7) + ON CONFLICT (tenant_id, idempotency_key) DO NOTHING", + &[ + &tenant_id, + &row.message_id, + &row.aggregate_id, + &row.aggregate_version, + &row.event_type, + &SCHEMA_VERSION, + &row.created_unix, + &row.payload_json, + &row.payload_hash, + &row.idempotency_key, + &STATUS_PENDING, + ], + ) + .await + .map_err(|error| format!("control plane insert outbox_message failed: {error}"))?; + Ok(()) +} + +async fn drain_once( + client: &mut Client, + tenant_id: &str, + owner: &str, + now_unix: i64, + dispatch: F, +) -> Result +where + F: Fn(&OutboxMessage) -> Result, +{ + let claimed = claim_batch(client, tenant_id, owner, now_unix).await?; + let mut processed = 0; + for message in claimed { + if receipt_exists(client, tenant_id, &message.idempotency_key).await? { + ack_processed(client, tenant_id, &message, "duplicate-receipt", now_unix).await?; + processed += 1; + continue; + } + match dispatch(&message) { + Ok(evidence) => { + ack_processed(client, tenant_id, &message, &evidence, now_unix).await?; + processed += 1; + } + Err(error) => { + fail_claimed(client, tenant_id, &message, now_unix, &error).await?; + } + } + } + Ok(processed) +} + +async fn claim_batch( + client: &mut Client, + tenant_id: &str, + owner: &str, + now_unix: i64, +) -> Result, String> { + let tx = client + .transaction() + .await + .map_err(|error| format!("control plane claim transaction failed: {error}"))?; + tx.execute( + "SELECT set_config('wardnet.tenant_id', $1, true)", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane tenant context failed: {error}"))?; + let lease_expires = now_unix.saturating_add(LEASE_SECONDS); + let rows = tx + .query( + "WITH picked AS ( + SELECT message_id FROM outbox_message + WHERE tenant_id = $1 + AND ( + (message_status = $2 AND next_available_unix <= $5) + OR (message_status = $3 AND COALESCE(lease_expires_unix, 0) <= $5) + ) + ORDER BY aggregate_id, aggregate_version, created_unix + FOR UPDATE SKIP LOCKED + LIMIT $6 + ) + UPDATE outbox_message AS message + SET message_status = $3, + lease_owner = $4, + lease_expires_unix = $7, + attempt_count = message.attempt_count + 1, + first_attempt_unix = COALESCE(message.first_attempt_unix, $5), + last_attempt_unix = $5 + FROM picked + WHERE message.tenant_id = $1 AND message.message_id = picked.message_id + RETURNING message.message_id, message.aggregate_id, message.aggregate_version, + message.event_type, message.schema_version, message.created_unix, + message.payload_json, message.payload_hash, message.idempotency_key, + message.message_status, message.lease_owner, message.lease_expires_unix, + message.attempt_count, message.first_attempt_unix, + message.last_attempt_unix, message.next_available_unix, + message.terminal_reason", + &[ + &tenant_id, + &STATUS_PENDING, + &STATUS_LEASED, + &owner, + &now_unix, + &CLAIM_BATCH, + &lease_expires, + ], + ) + .await + .map_err(|error| format!("control plane claim outbox failed: {error}"))?; + let messages = rows + .iter() + .map(|row| row_to_outbox(row, tenant_id)) + .collect(); + tx.commit() + .await + .map_err(|error| format!("control plane claim commit failed: {error}"))?; + Ok(messages) +} + +fn row_to_outbox(row: &tokio_postgres::Row, tenant_id: &str) -> OutboxMessage { + OutboxMessage { + message_id: row.get(0), + tenant_id: tenant_id.to_string(), + aggregate_id: row.get(1), + aggregate_version: row.get(2), + event_type: row.get(3), + schema_version: row.get(4), + created_unix: row.get(5), + payload_json: row.get(6), + payload_hash: row.get(7), + idempotency_key: row.get(8), + message_status: row.get(9), + lease_owner: row.get(10), + lease_expires_unix: row.get(11), + attempt_count: row.get(12), + first_attempt_unix: row.get(13), + last_attempt_unix: row.get(14), + next_available_unix: row.get(15), + terminal_reason: row.get(16), + } +} + +async fn receipt_exists( + client: &mut Client, + tenant_id: &str, + idempotency_key: &str, +) -> Result { + let tx = client + .transaction() + .await + .map_err(|error| format!("control plane receipt transaction failed: {error}"))?; + tx.execute( + "SELECT set_config('wardnet.tenant_id', $1, true)", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane tenant context failed: {error}"))?; + let row = tx + .query_opt( + "SELECT 1 FROM outbox_receipt WHERE tenant_id = $1 AND idempotency_key = $2", + &[&tenant_id, &idempotency_key], + ) + .await + .map_err(|error| format!("control plane load outbox_receipt failed: {error}"))?; + tx.commit() + .await + .map_err(|error| format!("control plane receipt commit failed: {error}"))?; + Ok(row.is_some()) +} + +async fn ack_processed( + client: &mut Client, + tenant_id: &str, + message: &OutboxMessage, + evidence: &str, + now_unix: i64, +) -> Result<(), String> { + let tx = client + .transaction() + .await + .map_err(|error| format!("control plane ack transaction failed: {error}"))?; + tx.execute( + "SELECT set_config('wardnet.tenant_id', $1, true)", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane tenant context failed: {error}"))?; + tx.execute( + "INSERT INTO outbox_receipt ( + tenant_id, idempotency_key, message_id, processed_unix, receipt_evidence + ) VALUES ($1,$2,$3,$4,$5) + ON CONFLICT (tenant_id, idempotency_key) DO NOTHING", + &[ + &tenant_id, + &message.idempotency_key, + &message.message_id, + &now_unix, + &evidence, + ], + ) + .await + .map_err(|error| format!("control plane insert outbox_receipt failed: {error}"))?; + tx.execute( + "UPDATE outbox_message + SET message_status = $3, lease_owner = NULL, lease_expires_unix = NULL, + terminal_reason = NULL, next_available_unix = $4 + WHERE tenant_id = $1 AND message_id = $2", + &[ + &tenant_id, + &message.message_id, + &STATUS_PROCESSED, + &now_unix, + ], + ) + .await + .map_err(|error| format!("control plane ack outbox_message failed: {error}"))?; + tx.commit() + .await + .map_err(|error| format!("control plane ack commit failed: {error}"))?; + Ok(()) +} + +async fn fail_claimed( + client: &mut Client, + tenant_id: &str, + message: &OutboxMessage, + now_unix: i64, + error: &DispatchError, +) -> Result<(), String> { + let dead = outbox::should_dead_letter(message.attempt_count, error); + let status = if dead { + STATUS_DEAD_LETTER + } else { + STATUS_PENDING + }; + let next = if dead { + now_unix + } else { + outbox::next_available_unix(now_unix, message.attempt_count, &message.message_id) + }; + let reason = error.as_str(); + let tx = client + .transaction() + .await + .map_err(|error| format!("control plane fail transaction failed: {error}"))?; + tx.execute( + "SELECT set_config('wardnet.tenant_id', $1, true)", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane tenant context failed: {error}"))?; + tx.execute( + "UPDATE outbox_message + SET message_status = $3, lease_owner = NULL, lease_expires_unix = NULL, + next_available_unix = $4, terminal_reason = $5 + WHERE tenant_id = $1 AND message_id = $2", + &[&tenant_id, &message.message_id, &status, &next, &reason], + ) + .await + .map_err(|error| format!("control plane fail outbox_message failed: {error}"))?; + tx.commit() + .await + .map_err(|error| format!("control plane fail commit failed: {error}"))?; + Ok(()) +} + +async fn outbox_health( + client: &mut Client, + tenant_id: &str, + now_unix: i64, +) -> Result { + let tx = client + .transaction() + .await + .map_err(|error| format!("control plane health transaction failed: {error}"))?; + tx.execute( + "SELECT set_config('wardnet.tenant_id', $1, true)", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane tenant context failed: {error}"))?; + let row = tx + .query_one( + "SELECT + COUNT(*) FILTER (WHERE message_status = $2), + COUNT(*) FILTER (WHERE message_status = $3), + COUNT(*) FILTER (WHERE message_status = $4), + MIN(created_unix) FILTER ( + WHERE message_status IN ($2, $3) + ) + FROM outbox_message WHERE tenant_id = $1", + &[ + &tenant_id, + &STATUS_PENDING, + &STATUS_LEASED, + &STATUS_DEAD_LETTER, + ], + ) + .await + .map_err(|error| format!("control plane outbox health failed: {error}"))?; + let oldest: Option = row.get(3); + tx.commit() + .await + .map_err(|error| format!("control plane health commit failed: {error}"))?; + Ok(OutboxHealth { + status: "ready".to_string(), + pending: row.get(0), + leased: row.get(1), + dead_letter: row.get(2), + oldest_age_seconds: oldest.map(|created| now_unix.saturating_sub(created).max(0)), + }) +} + +async fn list_outbox(client: &mut Client, tenant_id: &str) -> Result, String> { + let tx = client + .transaction() + .await + .map_err(|error| format!("control plane list transaction failed: {error}"))?; + tx.execute( + "SELECT set_config('wardnet.tenant_id', $1, true)", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane tenant context failed: {error}"))?; + let rows = tx + .query( + "SELECT message_id, aggregate_id, aggregate_version, event_type, schema_version, + created_unix, payload_json, payload_hash, idempotency_key, message_status, + lease_owner, lease_expires_unix, attempt_count, first_attempt_unix, + last_attempt_unix, next_available_unix, terminal_reason + FROM outbox_message WHERE tenant_id = $1 + ORDER BY created_unix, aggregate_id, aggregate_version", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane list outbox failed: {error}"))?; + let messages = rows + .iter() + .map(|row| row_to_outbox(row, tenant_id)) + .collect(); + tx.commit() + .await + .map_err(|error| format!("control plane list commit failed: {error}"))?; + Ok(messages) +} + +async fn replay_dead_letter( + client: &mut Client, + tenant_id: &str, + message_id: &str, + now_unix: i64, +) -> Result<(), String> { + let tx = client + .transaction() + .await + .map_err(|error| format!("control plane replay transaction failed: {error}"))?; + tx.execute( + "SELECT set_config('wardnet.tenant_id', $1, true)", + &[&tenant_id], + ) + .await + .map_err(|error| format!("control plane tenant context failed: {error}"))?; + let updated = tx + .execute( + "UPDATE outbox_message + SET message_status = $3, lease_owner = NULL, lease_expires_unix = NULL, + attempt_count = 0, next_available_unix = $4, terminal_reason = NULL + WHERE tenant_id = $1 AND message_id = $2 AND message_status = $5", + &[ + &tenant_id, + &message_id, + &STATUS_PENDING, + &now_unix, + &STATUS_DEAD_LETTER, + ], + ) + .await + .map_err(|error| format!("control plane replay outbox failed: {error}"))?; + if updated != 1 { + tx.rollback() + .await + .map_err(|error| format!("control plane replay rollback failed: {error}"))?; + return Err(format!("outbox message {message_id} is not in dead_letter")); + } + tx.commit() + .await + .map_err(|error| format!("control plane replay commit failed: {error}"))?; + Ok(()) +} + +fn unix_now_i64() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + fn mode_sql(mode: &EnforcementMode) -> &'static str { match mode { EnforcementMode::Monitor => "monitor", @@ -814,6 +1467,8 @@ mod tests { "security_event", "audit_record", "threat_feed", + "outbox_message", + "outbox_receipt", "schema_migration", ] { assert!(MIGRATION_SQL.contains(table), "missing table {table}"); @@ -822,6 +1477,7 @@ mod tests { assert!(MIGRATION_SQL.contains("wardnet.tenant_id")); assert!(MIGRATION_SQL.contains("PRIMARY KEY (tenant_id, route_id)")); assert!(MIGRATION_SQL.contains("REFERENCES tenant_account")); + assert!(MIGRATION_SQL.contains("UNIQUE (tenant_id, idempotency_key)")); assert!( !MIGRATION_SQL.contains("json_blob"), "do not dump AppData as one JSON column" @@ -851,5 +1507,224 @@ mod tests { assert_eq!(loaded.dnsbl, seeded.dnsbl); assert_eq!(loaded.next_event_id, seeded.next_event_id); assert_eq!(loaded.commercial.tenant_id, DEFAULT_TENANT_ID); + let messages = plane.list_outbox().await.expect("list snapshot outbox"); + assert!( + messages + .iter() + .any(|message| message.event_type == EVENT_SNAPSHOT_REPLACED), + "snapshot persist must enqueue an outbox row" + ); + } + + fn test_database_url() -> Option { + std::env::var("CONTROL_PLANE_TEST_DATABASE_URL") + .ok() + .filter(|url| !url.trim().is_empty()) + } + + fn unique_tenant(label: &str) -> String { + format!( + "{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + ) + } + + fn sample_event(id: u64, path: &str) -> SecurityEvent { + SecurityEvent { + id, + timestamp_unix: 1_700_000_000, + client_ip: Some("198.51.100.20".parse().expect("documentation IP")), + route_id: Some("demo".into()), + action: "blocked".into(), + reason: "fixture".into(), + score: 80, + path: path.into(), + } + } + + #[tokio::test] + async fn postgres_appends_event_and_outbox_atomically() { + let Some(url) = test_database_url() else { + return; + }; + let tenant = unique_tenant("outbox-append"); + let plane = PostgresPlane::connect_tenant(&url, &tenant) + .await + .expect("test database must accept the control plane"); + plane + .save(&AppData::seeded()) + .await + .expect("seed tenant snapshot"); + let setup_now = unix_now_i64().saturating_add(60); + let _ = plane + .drain_once("setup", setup_now, |_| Ok("setup".into())) + .await + .expect("ack snapshot outbox"); + let event = sample_event(7, "/gateway/login"); + plane + .append_security_event(&event, 1_000) + .await + .expect("append event + outbox"); + let loaded = plane.load().await.expect("load").expect("tenant exists"); + assert!( + loaded + .events + .iter() + .any(|row| row.id == 7 && row.path == "/gateway/login"), + "event must round-trip unmasked" + ); + assert_eq!(loaded.next_event_id, 8); + let messages = plane.list_outbox().await.expect("list outbox"); + let recorded = messages + .iter() + .find(|message| message.event_type == EVENT_SECURITY_RECORDED) + .expect("security event outbox row"); + assert!(recorded.payload_json.contains("198.51.100.20")); + assert!(recorded.payload_json.contains("/gateway/login")); + assert_eq!(recorded.message_status, STATUS_PENDING); + plane + .append_security_event(&event, 1_000) + .await + .expect("idempotent retry of same event id"); + let again = plane.list_outbox().await.expect("list after retry"); + assert_eq!( + again + .iter() + .filter(|message| message.event_type == EVENT_SECURITY_RECORDED) + .count(), + 1, + "duplicate event id must not enqueue a second outbox row" + ); + } + + #[tokio::test] + async fn postgres_outbox_worker_is_idempotent_and_dead_letters() { + let Some(url) = test_database_url() else { + return; + }; + let tenant = unique_tenant("outbox-worker"); + let plane = PostgresPlane::connect_tenant(&url, &tenant) + .await + .expect("test database"); + plane.save(&AppData::seeded()).await.expect("seed"); + let now = unix_now_i64().saturating_add(60); + let _ = plane + .drain_once("setup", now, |_| Ok("setup".into())) + .await + .expect("ack snapshot outbox"); + plane + .append_security_event(&sample_event(1, "/one"), 100) + .await + .expect("enqueue"); + + let dispatched = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen = dispatched.clone(); + let processed = plane + .drain_once("worker-a", now, move |message| { + seen.lock() + .expect("dispatcher lock") + .push(message.message_id.clone()); + Ok(format!("ack:{}", message.payload_hash)) + }) + .await + .expect("first drain"); + assert_eq!(processed, 1); + assert_eq!(dispatched.lock().expect("dispatcher lock").len(), 1); + + let processed_again = plane + .drain_once("worker-a", now.saturating_add(30), |_| { + panic!("processed messages must not be claimed again") + }) + .await + .expect("second drain"); + assert_eq!(processed_again, 0); + + plane + .append_security_event(&sample_event(2, "/poison"), 100) + .await + .expect("poison enqueue"); + let _ = plane + .drain_once("worker-a", now.saturating_add(60), |_| { + Err(crate::outbox::DispatchError::Permanent("malformed".into())) + }) + .await + .expect("dead-letter drain"); + let health = plane + .outbox_health(now.saturating_add(60)) + .await + .expect("health"); + assert_eq!(health.status, "ready"); + assert_eq!(health.dead_letter, 1); + + let dead = plane + .list_outbox() + .await + .expect("list") + .into_iter() + .find(|message| message.message_status == STATUS_DEAD_LETTER) + .expect("dead letter row"); + plane + .replay_dead_letter(&dead.message_id, now.saturating_add(90)) + .await + .expect("authorized replay"); + let replayed = plane + .drain_once( + "worker-b", + now.saturating_add(90), + |_| Ok("replayed".into()), + ) + .await + .expect("replay drain"); + assert_eq!(replayed, 1); + } + + #[tokio::test] + async fn postgres_expired_lease_is_reclaimed_and_skip_locked_is_exclusive() { + let Some(url) = test_database_url() else { + return; + }; + let tenant = unique_tenant("outbox-lease"); + let plane_a = PostgresPlane::connect_tenant(&url, &tenant) + .await + .expect("plane a"); + let plane_b = PostgresPlane::connect_tenant(&url, &tenant) + .await + .expect("plane b"); + plane_a.save(&AppData::seeded()).await.expect("seed"); + let now = unix_now_i64().saturating_add(60); + let _ = plane_a + .drain_once("setup", now, |_| Ok("setup".into())) + .await + .expect("ack snapshot outbox"); + plane_a + .append_security_event(&sample_event(3, "/lease"), 100) + .await + .expect("enqueue"); + + let first = plane_a + .drain_once("worker-a", now, |_| { + Err(crate::outbox::DispatchError::Transient("timeout".into())) + }) + .await + .expect("lease then fail transient"); + assert_eq!(first, 0); + let listed = plane_a.list_outbox().await.expect("list after fail"); + let pending = listed + .iter() + .find(|message| message.event_type == EVENT_SECURITY_RECORDED) + .expect("event still queued"); + assert_eq!(pending.message_status, STATUS_PENDING); + assert!(pending.next_available_unix > now); + + let later = pending.next_available_unix; + let reclaimed = plane_b + .drain_once("worker-b", later, |_| Ok("reclaimed".into())) + .await + .expect("expired/next-available reclaim"); + assert_eq!(reclaimed, 1); } } diff --git a/src/lib.rs b/src/lib.rs index cd7fcc44..03b4ee4e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,7 @@ mod credentials; mod destination; mod misp_import; mod opencti_import; +mod outbox; mod proven_engine; mod stix_import; mod suricata_eve; @@ -345,7 +346,34 @@ impl AppState { proven_engine: self.proven_engine.mode().to_string(), proven_engine_fail_closed: self.proven_engine.fail_closed, destination_mode: self.destination.mode().to_string(), + outbox: if self.control_plane.is_some() { + "ready".to_string() + } else { + "disabled".to_string() + }, + outbox_pending: 0, + outbox_leased: 0, + outbox_dead_letter: 0, + outbox_oldest_age_seconds: None, + } + } + + async fn health_status_live(&self) -> HealthStatus { + let mut health = self.health_status(); + let Some(plane) = &self.control_plane else { + return health; + }; + match plane.outbox_health(now_unix() as i64).await { + Ok(stats) => { + health.outbox = stats.status; + health.outbox_pending = stats.pending; + health.outbox_leased = stats.leased; + health.outbox_dead_letter = stats.dead_letter; + health.outbox_oldest_age_seconds = stats.oldest_age_seconds; + } + Err(_) => health.outbox = "error".to_string(), } + health } } @@ -473,6 +501,12 @@ pub struct HealthStatus { pub proven_engine_fail_closed: bool, /// `production` (fail-closed classes) or `development` (loopback class permitted). pub destination_mode: String, + /// `ready` when the PostgreSQL outbox is the authority; `disabled` on file/memory. + pub outbox: String, + pub outbox_pending: i64, + pub outbox_leased: i64, + pub outbox_dead_letter: i64, + pub outbox_oldest_age_seconds: Option, } const PHISHING_DATABASE_DEFAULT_FEED_ID: &str = "phishing-database-active"; @@ -545,6 +579,8 @@ pub fn build_app(state: AppState) -> Router { .route("/api/dnsbl", get(list_dnsbl).post(create_dnsbl)) .route("/api/events", get(list_events)) .route("/api/audit-logs", get(list_audit_logs)) + .route("/api/outbox", get(list_outbox)) + .route("/api/outbox/{message_id}/replay", post(replay_outbox)) .route("/api/events.ndjson", get(events_ndjson)) .route("/api/kpis", get(kpis)) .route("/api/signatures", get(list_signatures)) @@ -907,7 +943,7 @@ async fn soc_analyze( } async fn healthz(State(state): State) -> Json { - Json(state.health_status()) + Json(state.health_status_live().await) } /// Build/version metadata for deployment verification. @@ -1092,6 +1128,75 @@ async fn list_audit_logs(State(state): State, headers: HeaderMap) -> R Json(state.inner.read().await.audit_logs.clone()).into_response() } +#[derive(Serialize)] +struct OutboxListView { + status: String, + messages: Vec, +} + +async fn list_outbox(State(state): State, headers: HeaderMap) -> Response { + if !admin_authenticated(&state, &headers) { + return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + } + let Some(plane) = &state.control_plane else { + return Json(OutboxListView { + status: "disabled".to_string(), + messages: Vec::new(), + }) + .into_response(); + }; + match plane.list_outbox().await { + Ok(messages) => Json(OutboxListView { + status: "ready".to_string(), + messages, + }) + .into_response(), + Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), + } +} + +async fn replay_outbox( + State(state): State, + PathParam(message_id): PathParam, + headers: HeaderMap, +) -> Response { + if !admin_authorized(&state, &headers) { + return error(StatusCode::UNAUTHORIZED, "missing or invalid X-Admin-Token"); + } + let Some(plane) = &state.control_plane else { + return error( + StatusCode::SERVICE_UNAVAILABLE, + "outbox replay requires the PostgreSQL control plane", + ); + }; + let actor = audit_actor(&state, &headers); + if let Err(message) = plane + .replay_dead_letter(&message_id, now_unix() as i64) + .await + { + return error(StatusCode::BAD_REQUEST, message); + } + match state + .mutate_and_persist(|data| { + record_successful_audit_log( + data, + actor, + "replay_outbox", + "outbox_message", + message_id.clone(), + ); + }) + .await + { + Ok(()) => Json(serde_json::json!({ + "status": "pending", + "message_id": message_id + })) + .into_response(), + Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message), + } +} + async fn kpis(State(state): State) -> Json { let data = state.inner.read().await; Json(kpi_snapshot_at(&data, now_unix())) @@ -2161,7 +2266,7 @@ async fn support_bundle(State(state): State) -> Json { let generated_at_unix = now_unix(); Json(SupportBundle { generated_at_unix, - health: state.health_status(), + health: state.health_status_live().await, kpis: kpi_snapshot_at(&data, generated_at_unix), commercial: data.commercial.clone(), readiness: commercial_readiness_snapshot_at(&data, generated_at_unix), @@ -2520,29 +2625,37 @@ async fn record_event( let action = action.to_string(); let path = path.to_string(); let event_limit = state.event_limit; - if let Err(error) = state - .mutate_and_persist(|data| { - let id = data.next_event_id; - data.next_event_id += 1; - let event = SecurityEvent { - id, - timestamp_unix: now_unix(), - client_ip, - route_id, - action, - reason, - score, - path, - }; - // Structured stdout log line for SIEM / log-collector ingestion. - // ponytail: one println per recorded event — fine at gateway volumes; - // add async batching if event throughput ever becomes a bottleneck. - println!("{}", security_event_log_line(&event)); - data.events.push(event); - enforce_event_limit(data, event_limit); - }) - .await - { + let _guard = state.persist_lock.lock().await; + let (event, previous) = { + let mut data = state.inner.write().await; + let previous = data.clone(); + let id = data.next_event_id; + data.next_event_id += 1; + let event = SecurityEvent { + id, + timestamp_unix: now_unix(), + client_ip, + route_id, + action, + reason, + score, + path, + }; + data.events.push(event.clone()); + enforce_event_limit(&mut data, event_limit); + (event, previous) + }; + let persist = if let Some(plane) = &state.control_plane { + plane.append_security_event(&event, event_limit).await + } else { + // File/memory has no leased worker; emit the SIEM line on the request path. + println!("{}", security_event_log_line(&event)); + let snapshot = state.inner.read().await.clone(); + state.persist_snapshot(&snapshot).await + }; + if let Err(error) = persist { + let mut data = state.inner.write().await; + *data = previous; eprintln!("failed to persist security event: {error}"); } } @@ -3023,6 +3136,7 @@ input,select{font:inherit;min-height:44px;padding:0 12px;border:1px solid var(--

     

Audit log

Loading…
+

Outbox

PostgreSQL leased workers for external effects. File/memory adapters report disabled. Client IPs and paths are not masked.

Loading…

Evidence manifest

Loading…

SOC event export (ndjson)

Loading…

Audit log

Loading…

Outbox

PostgreSQL leased workers for external effects. File/memory adapters report disabled. Client IPs and paths are not masked.

Loading…
+

Control-plane backup

+

On-demand PostgreSQL logical snapshot. Restore drill uses an isolated tenant and does not mask client IPs, paths, or actors. File/memory adapters report disabled. Declared RPO is last successful export; declared RTO is 60s.

+
Loading…
+
+ +
+

+    

Evidence manifest

Loading…

SOC event export (ndjson)

Loading…

TAXII 2.1 collection poll

-

POST admin-authenticated JSON to /api/threat-intel/taxii/poll with objects_url (or api_root+collection_id), optional Basic/Bearer credentials, and optional added_after. Fetches TAXII objects, normalizes to STIX, and upserts threats/DNSBL. Credentials are never written to audit logs.

+

POST admin-authenticated JSON to /api/threat-intel/taxii/poll with objects_url (or api_root+collection_id) and optional added_after. PostgreSQL enqueues taxii.collection_polled (202) for the leased worker; file/memory still fetches on the request path. Inline Basic/Bearer is memory-only — durable polls use taxii_bearer in the credential registry. Credentials are never written to outbox payloads or audit logs. Poll GET /api/outbox/{message_id} for receipt evidence. Indicator values stay unmasked.

OpenCTI threat intelligence

POST admin-authenticated OpenCTI GraphQL/list export JSON to /api/threat-intel/opencti (optional query: feed_id, source, ttl_seconds). Maps IPv4/IPv6, Domain-Name, Url, file hashes, and STIX indicators into threats/DNSBL. Live OpenCTI GraphQL pull is a follow-up.