From 4b1331378f48bcc551595d994a094d3f8791aa99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 01:03:36 +0900 Subject: [PATCH 1/3] 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 b6e7c20..0c1ef4d 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 1afa362..d3843fa 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 dc09e46..cb42a02 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 1ffb653..48e4d41 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 a0b2b27..1d7bb12 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 0000000..baafea5 --- /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 29167f9..291a390 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 f778cfb..c38c579 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 9b8bdc8..a82c014 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 0000000..3b2de0a --- /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 2ceb737..9bc864e 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 9bbbf8c..ac08132 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 0000000..3e07b41 --- /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 0000000..ede045f --- /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 6eba616..ec443e2 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 b322f53..8630e8b 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 71887c6..404eb13 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 2/3] 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 9bc864e..d6e0f4a 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 2ca7db557a28f5134e7f4480bd26a8d74f2f9dc1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 15:19:00 +0900 Subject: [PATCH 3/3] feat(waf): forward bounded client headers into in-process libcoraza MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-process transactions now receive the same forwarded-header allowlist as the sidecar path (host, user-agent, accept, content-type, referer, origin, x-requested-with, x-forwarded-for, x-real-ip, cookie — never Authorization; 32 headers / 8 KiB caps enforced by proven_engine::engine_forwarded_headers). Each header crosses the C ABI via coraza_add_request_header before process_request_headers, so CRS rules that inspect headers evaluate real client input instead of a synthetic Host only. Brings in the PR #95 sidecar hardening via merge so both engines share one allowlist implementation and one status/bound contract. Behavioral header-battery evidence lands with the issue-11 battery fixture (PR #110); this slice ships the plumbing and keeps the stub contract unchanged. --- src/coraza_inprocess.rs | 39 +++++++++++++++++++++++++++------------ src/lib.rs | 3 ++- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/coraza_inprocess.rs b/src/coraza_inprocess.rs index ede045f..bac5ed7 100644 --- a/src/coraza_inprocess.rs +++ b/src/coraza_inprocess.rs @@ -16,6 +16,10 @@ use libloading::Library; use crate::coraza_audit::CorazaIngestedHit; use crate::proven_engine::ProvenEngineOutcome; +/// Maximum number of client headers forwarded into one libcoraza transaction. +/// Mirrors the sidecar allowlist bound; `Authorization` never forwards. +const FORWARDED_HEADER_LIMIT: usize = 32; + const CORAZA_ERROR: c_int = -1; const CORAZA_INTERRUPTION: c_int = 1; @@ -127,14 +131,19 @@ impl InProcessCoraza { /// Evaluate one HTTP transaction. Never includes the library path in the /// outcome reason (that can identify a host layout). + /// + /// `headers` carries a bounded allowlist of forwarded client headers so + /// CRS rules that inspect `User-Agent`, cookies, and friends see the same + /// input a sidecar deployment would. `Authorization` must not be passed. pub fn evaluate( &self, method: &str, uri: &str, body: &str, client_ip: Option, + headers: &[(String, String)], ) -> ProvenEngineOutcome { - match self.evaluate_inner(method, uri, body, client_ip) { + match self.evaluate_inner(method, uri, body, client_ip, headers) { Ok(outcome) => outcome, Err(reason) => ProvenEngineOutcome::Unavailable { reason }, } @@ -146,6 +155,7 @@ impl InProcessCoraza { uri: &str, body: &str, client_ip: Option, + headers: &[(String, String)], ) -> Result { let method_c = c_string(method)?; let uri_c = c_string(uri)?; @@ -175,15 +185,20 @@ impl InProcessCoraza { { 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())?, - ); + for (name, value) in headers.iter().take(FORWARDED_HEADER_LIMIT) { + // Interior NULs cannot cross the C ABI; skip such values + // rather than failing the whole transaction. + let (Ok(name_c), Ok(value_c)) = (c_string(name), c_string(value)) else { + continue; + }; + let _ = (self.api.add_request_header)( + tx.tx, + name_c.as_ptr(), + c_len(name.len())?, + value_c.as_ptr(), + c_len(value.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()); @@ -459,7 +474,7 @@ mod tests { 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) { + match engine.evaluate("GET", "/app?crs-probe=1", "", None, &[]) { ProvenEngineOutcome::Hit(hit) => { assert_eq!(hit.action, "block"); assert!(hit.reason.contains("942100"), "{}", hit.reason); @@ -468,7 +483,7 @@ mod tests { other => panic!("expected hit, got {other:?}"), } assert_eq!( - engine.evaluate("GET", "/app?q=hello", "", None), + engine.evaluate("GET", "/app?q=hello", "", None, &[]), ProvenEngineOutcome::Clean ); } diff --git a/src/lib.rs b/src/lib.rs index 1a354ea..440114c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2406,8 +2406,9 @@ async fn consult_proven_engine( let method = method.to_owned(); let request_uri = request_uri.to_owned(); let body_text = body_text.to_owned(); + let headers_owned = forwarded_headers.to_vec(); return match tokio::task::spawn_blocking(move || { - engine.evaluate(&method, &request_uri, &body_text, client_ip) + engine.evaluate(&method, &request_uri, &body_text, client_ip, &headers_owned) }) .await {