feat(waf): evaluate live gateway transactions with in-process libcoraza - #97
Conversation
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.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| 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()); | ||
| } |
There was a problem hiding this comment.
📝 Info: WAF handle can leak when build returns handle plus error string
construct_waf at coraza_inprocess.rs returns Err whenever err_ptr is non-null, before the waf == 0 check. If a real libcoraza ever returns a valid WAF while also writing a message, that handle is never freed. The test stub only sets the error on failure, so this path is untested. Low likelihood and depends on undocumented behavior.
Was this helpful? React with 👍 or 👎 to provide feedback.
| 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(), | ||
| }, | ||
| }; |
There was a problem hiding this comment.
📝 Info: Per-request spawn_blocking for in-process evaluation
consult_proven_engine at lib.rs runs each in-process evaluate on spawn_blocking. Under heavy gateway load this consumes blocking-pool threads. The CI stub also serializes all calls on one global mutex; production relies on libcoraza being concurrent-safe. Worth watching for latency under load.
Was this helpful? React with 👍 or 👎 to provide feedback.
# Conflicts: # docs/product-technical-gap-baseline.md # src/lib.rs
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.
| 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(); | ||
| let headers_owned = forwarded_headers.to_vec(); | ||
| return match tokio::task::spawn_blocking(move || { | ||
| engine.evaluate(&method, &request_uri, &body_text, client_ip, &headers_owned) | ||
| }) | ||
| .await | ||
| { | ||
| Ok(outcome) => outcome, | ||
| Err(_) => ProvenEngineOutcome::Unavailable { | ||
| reason: "coraza in-process task failed".to_string(), | ||
| }, | ||
| }; |
There was a problem hiding this comment.
📝 Info: In-process evaluation has no timeout, unlike the sidecar
The sidecar path bounds a hung engine with SIDECAR_TIMEOUT, but the in-process path runs engine.evaluate on spawn_blocking with no timeout (lib.rs). A hung libcoraza call ties up a blocking-pool thread per request with no fail-closed fallback. CRS evaluation is CPU-bound so a true hang is unlikely, but the robustness asymmetry between the two engine modes is worth noting.
Was this helpful? React with 👍 or 👎 to provide feedback.
| let rules = unsafe { (api.rules_count)(waf) }; | ||
| if rules <= 0 { | ||
| unsafe { | ||
| (api.free_waf)(waf); | ||
| } | ||
| return Err("libcoraza loaded an empty ruleset".to_string()); | ||
| } |
There was a problem hiding this comment.
📝 Info: Empty-ruleset check semantics differ between stub and real libcoraza
construct_waf fails startup when rules_count(waf) <= 0 (coraza_inprocess.rs). The CI stub counts one per rules_add/rules_add_file call, so it always passes; real libcoraza's coraza_rules_count returns the compiled rule count. A CORAZA_DIRECTIVES value that is valid but loads zero SecRules (e.g. SecRuleEngine On alone) would fail startup as an empty ruleset. Likely the intended fail-closed behavior, but the stub cannot exercise it.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Pull request overview
OpenCode cannot approve yet because required coverage evidence did not pass.
Review outcome
1. HIGH .github/workflows/opencode-review.yml:1 - Coverage evidence did not prove required test/docstring evidence
-
Problem: The required coverage-evidence job result was
failure, so OpenCode cannot establish approval sufficiency for this head. -
Root cause: Automated approval is only valid when the same-head coverage-evidence job proves supported repository test suites passed and configured docstring gates passed or were advisory, or reports not applicable because no supported source files or package manifests exist. Missing, failed, skipped, unavailable, or unsupported-tooling test evidence is a blocker.
-
Fix: Install or configure the repository test/docstring evidence tooling when source files or package manifests exist, rerun the current-head coverage-evidence job, and approve only after it reports
successwith required evidence or explicit no-source not-applicable evidence. -
Regression test: Keep the approval branch checking
needs.coverage-evidence.result == successbefore posting APPROVE, and publish REQUEST_CHANGES when coverage-evidence blocker states such as cancelled, skipped, failed, unsupported-tooling, or below-100 evidence are present. -
Result: REQUEST_CHANGES
-
Reason: coverage-evidence result was
failure, so required test/docstring evidence was not proven for current head2ca7db557a28f5134e7f4480bd26a8d74f2f9dc1. -
Head SHA:
2ca7db557a28f5134e7f4480bd26a8d74f2f9dc1 -
Workflow run: 32702410979
-
Workflow attempt: 1
Coverage evidence
Coverage Decision
- Result: FAIL
- Test evidence: not proven passing
- Docstring evidence: not proven passing when configured
- Failure count: 1
Changed-File Evidence Map
flowchart LR
PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
Evidence --> S1["Changed file (11 files)"]
S1 --> I1["repository behavior"]
I1 --> R1["Review risk: Changed file (11 files)"]
R1 --> V1["required checks"]
Evidence --> S2["Docs (6 files)"]
S2 --> I2["operator or user guidance"]
I2 --> R2["Review risk: Docs (6 files)"]
R2 --> V2["docs review"]
Evidence --> S3["Test: binary.rs"]
S3 --> I3["regression suite"]
I3 --> R3["Review risk: Test: binary.rs"]
R3 --> V3["targeted test run"]
OpenCode Review Overview
Pull request overviewOpenCode cannot approve yet because required coverage evidence did not pass. Review outcome1. HIGH .github/workflows/opencode-review.yml:1 - Coverage evidence did not prove required test/docstring evidence
Coverage evidenceCoverage Decision
Changed-File Evidence Mapflowchart LR
PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
Evidence --> S1["Changed file (11 files)"]
S1 --> I1["repository behavior"]
I1 --> R1["Review risk: Changed file (11 files)"]
R1 --> V1["required checks"]
Evidence --> S2["Docs (6 files)"]
S2 --> I2["operator or user guidance"]
I2 --> R2["Review risk: Docs (6 files)"]
R2 --> V2["docs review"]
Evidence --> S3["Test: binary.rs"]
S3 --> I3["regression suite"]
I3 --> R3["Review risk: Test: binary.rs"]
R3 --> V3["targeted test run"]
|
…rocess-libcoraza Resolves a fresh conflict against the (non-main) stacked base branch: docs/product-technical-gap-baseline.md merged additively, keeping both loops' shipped-gap entries. src/lib.rs and src/proven_engine.rs auto-merged cleanly. Verified: cargo fmt --check clean, cargo test 144 passed (0 failed).
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
🔍 Config handle freed after new_waf
construct_waf frees the config via coraza_free_waf_config on return (ConfigGuard drop), after coraza_new_waf has used it. The CI stub treats new_waf as non-consuming, so this is exercised safely. If real libcoraza takes ownership of the config inside coraza_new_waf, freeing it here double-frees at startup. Confirm the ownership contract against the real ABI (coraza_inprocess.rs).
Was this helpful? React with 👍 or 👎 to provide feedback.
| 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(); | ||
| let headers_owned = forwarded_headers.to_vec(); | ||
| return match tokio::task::spawn_blocking(move || { | ||
| engine.evaluate(&method, &request_uri, &body_text, client_ip, &headers_owned) |
There was a problem hiding this comment.
🟨 WAF inspects lossy body, upstream gets raw bytes
The in-process engine receives body_text, a String::from_utf8_lossy copy built at lib.rs, while proxy_request forwards the original raw bytes. Invalid UTF-8 is replaced with U+FFFD before CRS inspection, so a non-UTF-8 body can read as benign yet reach the upstream intact, allowing body-based rule evasion. The sidecar path shares this limitation.
Was this helpful? React with 👍 or 👎 to provide feedback.
* 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. * docs: record PR #96 in the product-technical gap baseline * 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. * 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. * 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. * docs: record PR #97 in the product-technical gap baseline * feat(waf): forward bounded client headers into in-process libcoraza 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. * feat(store): require PostgreSQL as the production control plane (#98) * 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. * 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. * feat(store): rustls for production PostgreSQL sslmode=require (#100) * feat(store): rustls for production PostgreSQL sslmode=require Issue #80 remainder. sslmode=require/verify-ca/verify-full connect with rustls and Mozilla roots; certificates are always verified. allow/prefer are still rejected so the process cannot silently drop to plaintext. Live test against plaintext CI postgres proves fail-closed. Do not re-implement the postgres gate or the outbox. * docs: record PR #100 in the product-technical gap baseline * fix(store): rewrite verify-full sslmode for tokio-postgres 0.7 Still-valid #100 Devin finding. tokio-postgres 0.7 only parses disable/prefer/require. Map verify-ca/verify-full to require before connect; rustls still verifies certificates. Password query-lookalikes are left untouched. * feat(store): bound outbox listing and prune processed rows (#101) * feat(store): bound outbox listing and prune processed rows Still-valid #99 finding. GET /api/outbox returns at most EVENT_LIMIT rows (dead letters, then pending, then leased, then processed). Processed outbox_message rows prune to that cap; receipts and dead letters stay. Do not re-implement the outbox, postgres gate, or rustls. * docs: record PR #101 in the product-technical gap baseline * fix(store): prune processed outbox to EVENT_LIMIT on save and ack Still-valid #101 Devin finding. Snapshot save and worker ack used LIST_LIMIT (1000) while append used operator EVENT_LIMIT. Store the configured cap on PostgresPlane so all three paths retain the same processed-row bound. Receipts and dead letters stay. * feat(store): logical backup and isolated restore drill (#102) * feat(store): logical backup and isolated restore drill Issue #80 remainder stacked on #101. GET /api/backup exports a hashed tenant snapshot; POST /api/backup restores after schema and payload-hash checks; POST /api/backup/drill restores into an isolated tenant, compares unmasked invariants, and drops the drill rows. Declared RPO is last successful export; declared RTO is 60s. File/memory adapters report backup=disabled. Do not re-implement rustls, outbox, or retention. * docs: record PR #102 in the product-technical gap baseline * feat(store): non-owner PostgreSQL runtime role after migrate (#103) * feat(store): non-owner PostgreSQL runtime role after migrate Still-valid #98 finding. CI connects as a superuser, which bypasses FORCE RLS. Migrations stay on the login role, then SET ROLE wardnet_runtime (NOSUPERUSER, NOBYPASSRLS, not table owner). Missing tenant GUC yields no rows; DROP TABLE and DISABLE RLS are denied. Do not re-implement rustls, outbox, retention, or backup/restore. * docs: record PR #103 in the product-technical gap baseline * fix(store): restore logical backups across role-only schema versions v3 only provisions wardnet_runtime and does not change table shape. verify() accepts schema 2 through the current migration version so a role-only upgrade cannot void the last pre-upgrade logical backup. * feat(store): HASH-partition security_event by tenant (#104) * feat(store): HASH-partition security_event by tenant Convert unpartitioned security_event to PARTITION BY HASH (tenant_id) with eight children under pg_advisory_lock. Rows keep unmasked client IPs and paths. /healthz.event_partitions reports the child count. Logical restore still accepts schema 2 through the current version. * docs: record PR #104 in the product-technical gap baseline * feat(security): add bounded outbound fetch API (#113) * feat(security): add bounded outbound fetch API * fix(security): isolate fetch DNS pins per request * feat(security): route browser DNS and HTTPS through Wardnet (#116) * feat(security): add bounded outbound fetch API * feat(security): route browser DNS and HTTPS through Wardnet * fix(security): isolate fetch DNS pins per request * fix(dns): bound concurrent UDP query handling * fix(security): close destination policy review gaps * fix(runtime): make worker shutdown durable
* 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. * docs: record PR #96 in the product-technical gap baseline * 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. * 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. * 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. * docs: record PR #97 in the product-technical gap baseline * 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. * 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. * feat(store): rustls for production PostgreSQL sslmode=require (#100) * feat(store): rustls for production PostgreSQL sslmode=require Issue #80 remainder. sslmode=require/verify-ca/verify-full connect with rustls and Mozilla roots; certificates are always verified. allow/prefer are still rejected so the process cannot silently drop to plaintext. Live test against plaintext CI postgres proves fail-closed. Do not re-implement the postgres gate or the outbox. * docs: record PR #100 in the product-technical gap baseline * fix(store): rewrite verify-full sslmode for tokio-postgres 0.7 Still-valid #100 Devin finding. tokio-postgres 0.7 only parses disable/prefer/require. Map verify-ca/verify-full to require before connect; rustls still verifies certificates. Password query-lookalikes are left untouched. * feat(store): bound outbox listing and prune processed rows (#101) * feat(store): bound outbox listing and prune processed rows Still-valid #99 finding. GET /api/outbox returns at most EVENT_LIMIT rows (dead letters, then pending, then leased, then processed). Processed outbox_message rows prune to that cap; receipts and dead letters stay. Do not re-implement the outbox, postgres gate, or rustls. * docs: record PR #101 in the product-technical gap baseline * fix(store): prune processed outbox to EVENT_LIMIT on save and ack Still-valid #101 Devin finding. Snapshot save and worker ack used LIST_LIMIT (1000) while append used operator EVENT_LIMIT. Store the configured cap on PostgresPlane so all three paths retain the same processed-row bound. Receipts and dead letters stay. * feat(store): logical backup and isolated restore drill (#102) * feat(store): logical backup and isolated restore drill Issue #80 remainder stacked on #101. GET /api/backup exports a hashed tenant snapshot; POST /api/backup restores after schema and payload-hash checks; POST /api/backup/drill restores into an isolated tenant, compares unmasked invariants, and drops the drill rows. Declared RPO is last successful export; declared RTO is 60s. File/memory adapters report backup=disabled. Do not re-implement rustls, outbox, or retention. * docs: record PR #102 in the product-technical gap baseline * feat(store): non-owner PostgreSQL runtime role after migrate (#103) * feat(store): non-owner PostgreSQL runtime role after migrate Still-valid #98 finding. CI connects as a superuser, which bypasses FORCE RLS. Migrations stay on the login role, then SET ROLE wardnet_runtime (NOSUPERUSER, NOBYPASSRLS, not table owner). Missing tenant GUC yields no rows; DROP TABLE and DISABLE RLS are denied. Do not re-implement rustls, outbox, retention, or backup/restore. * docs: record PR #103 in the product-technical gap baseline * fix(store): restore logical backups across role-only schema versions v3 only provisions wardnet_runtime and does not change table shape. verify() accepts schema 2 through the current migration version so a role-only upgrade cannot void the last pre-upgrade logical backup. * feat(store): HASH-partition security_event by tenant (#104) * feat(store): HASH-partition security_event by tenant Convert unpartitioned security_event to PARTITION BY HASH (tenant_id) with eight children under pg_advisory_lock. Rows keep unmasked client IPs and paths. /healthz.event_partitions reports the child count. Logical restore still accepts schema 2 through the current version. * docs: record PR #104 in the product-technical gap baseline * feat(store): optimistic concurrency on postgres snapshots Issue #80 last remainder. tenant_account.snapshot_version must match the loaded token or persist fails closed (HTTP 409). Restores overwrite. File/memory stay single-writer. Do not re-implement rustls, outbox, runtime role, HASH, or backup/restore. * docs: record PR #105 in the product-technical gap baseline * fix(store): keep postgres snapshot_version aligned after startup save load_postgres was saving with OCC and leaving the in-memory token one behind the database, so every later management write returned HTTP 409. Advance the loaded snapshot_version to the value save() wrote. * feat(store): outbox consumers for TAXII, Clearfolio, and orchestrator Enqueue operator-triggered TAXII polls, Clearfolio submits, and contextual-orchestrator SOC analysis on the PostgreSQL leased outbox. Request path returns 202; GET /api/outbox/{id} exposes receipt evidence. Secrets stay in the credential registry. Startup postgres save advances snapshot_version so the first management write cannot false-conflict. * docs: record PR #106 in the product-technical gap baseline * feat(release): tagged GitHub Release with SHA-256 and immutable GHCR (#107) * feat(release): tagged GitHub Release with SHA-256 and immutable GHCR Issue #84 first slice. A vX.Y.Z tag builds a locked binary, checksums, a GitHub Release, and ghcr.io/contextualwisdomlab/waf-ids-ai-soc:vX.Y.Z with no moving latest tag. Promotion and rollback are tag-for-tag. Do not re-implement store slices or OCC. * docs: record PR #107 in the product-technical gap baseline * fix(release): basename checksums and serialize postgres GRANTs SHA256SUMS recorded dist/ prefixes so sha256sum -c failed next to the downloaded binary. Emit basenames. Parallel PostgresPlane connects raced HASH convert GRANT with SET ROLE GRANT (tuple concurrently updated); hold the advisory lock across both. Do not re-implement HASH layout. * feat(release): keyless cosign, SPDX SBOM, and SLSA on the same tag Issue #84 remainder. GitHub OIDC signs the binary, checksums, SBOMs, and the GHCR image by digest. Release is created only after signatures. Syft SPDX fails closed without syft or non-SPDX JSON. NIST SP 800-218 is attached. Do not re-implement checksums or store slices. * feat(release): refuse lightweight tags and pin k8s by digest Issue #84 remainder. Annotated vX.Y.Z tags only; lightweight tags fail closed before the release job builds. Kubernetes pin is the GHCR content digest; tag aliases are refused. Do not re-implement checksums or cosign/SBOM. * docs: record PR #109 in the product-technical gap baseline * feat(waf): detect OWASP CRS attack battery on the live binary Issue #11 first slice. The build-script libcoraza ABI stub gains a deterministic battery covering SQLi (942100), XSS (941100), path traversal (930100), Unix RCE (932100, with first-match ordering so '; cat /etc/passwd' attributes to RCE over traversal), and Log4j JNDI (944120) in raw and percent-encoded forms across URI and POST-body phases. tests/binary.rs now starts the real gateway with the stub engine, creates a block route through the admin API, fires nine cases over HTTP, and asserts each is 403-blocked citing the expected CRS rule id while a benign request still forwards; /api/events must record one event per attempt with the forwarded client IP kept unmasked. Doctoring: docs/doctoring/ci-attack-evidence-battery.md grounds the split between detection-path evidence (CI) and detection efficacy (operator-supplied libcoraza + Core Rule Set), APA 7th. * fix(control-plane): close OCC and credential race gaps * fix(release): capture pushed image digest
Summary
Issue #86 remainder (in-process libcoraza). Does not re-implement the #86 sidecar slice (#95) or the #79 TCP-peer pin (#96).
Live
/gatewaytransactions are evaluated by operator-supplied libcoraza whenCORAZA_LIB_PATHis set, using the documented C ABI plusCORAZA_RULES_PATHand/orCORAZA_DIRECTIVES. In-process wins over the sidecar. Missing library, missing rules, or an empty ruleset fail startup before bind. CI stays hermetic with a fixture cdylib that exports the same symbols.Stacked on #96 (
feat/issue-79-destination-policy). Merge order: #95, then #96, then this PR. Org ruleset 18156473 still requires two independent approvals; do not--adminmerge.Operator-visible
CORAZA_LIB_PATH+CORAZA_RULES_PATH/CORAZA_DIRECTIVES/healthz.proven_engine=coraza_in_processGET /api/waf/engine-statusreportsin_process_configuredandin_process_rules(no library path)PROVEN_ENGINE_FAIL_CLOSEDstill fail-closes per-request engine errorsTests
cargo fmt --checkcargo test --locked --workspacecargo clippy --locked --workspace --all-targets -- -D warnings/healthz+/admin; stub-loaded/healthz+/api/commercial/readiness(target_sale_value_krwremains 2_000_000_000)Doctoring:
docs/doctoring/in-process-libcoraza.md(APA 7th).