ReviewOS

zig-utils/zig-waf

[WAF-22] Implement native Zig and C plugin registries

#23
Closed chris opened this 9 days ago ·
9 days ago

Objective

Define registration and lifetime contracts for actions, operators, transformations, directives, body processors, collections, loggers, and audit writers, including static WASM registration.

Deliverables

  • Implement the complete scoped behavior described above.
  • Keep request-path work independent from UI, PostgreSQL, and blocking audit I/O.
  • Use Zig 0.17-dev and dependencies pinned through Pantry; do not add Git submodules.
  • Keep public APIs explicit about ownership, resource limits, errors, security policy, and compatibility.
  • Add focused unit, integration, differential, fuzz, benchmark, and documentation coverage as applicable.

Acceptance criteria

  • The scoped implementation and tests are merged on main in small conventional commits.
  • Zig formatting, focused tests, and the relevant CI suite pass.
  • Compatibility claims have executable evidence and no unexplained skips or silent fallbacks.
  • Untrusted inputs have deterministic bounds; hot paths have benchmark evidence.
  • Documentation and the machine-readable compatibility matrix are updated.

Relationships

Blocked by

Blocks

Baselines

  • ModSecurity 3.0.16
  • Coraza 3.7.0
  • OWASP CRS 4.28.0
  • libinjection 4.0.0
  • Zig 0.17-dev resolved and locked through Pantry
9 days ago

Dependency update: WAF-11 #12 is complete at 19b64bc with exact hosted CI green. This issue can now consume the immutable typed IR, stable diagnostics, prefilters/macro programs, fingerprints, shared payloads, and explicit WAF plan ownership contract.

9 days ago

Complete. src/plugin.zig, documented in docs/plugins.md.

Registration and lifetime contracts — the registry is borrowed for the WAF's lifetime, so the host owns the storage and must outlive both the WAF and its transactions: the same ownership rule as the persistent-collection backend. Names must be distinct and non-empty, because two plugins answering to one name would make which of them runs depend on registration order.

Operators (b370739) — a host registers a name and a callback, invoked exactly where the operator appears. This is how @geoLookup, @rbl, @inspectFile, and @fuzzyHash are reachable: each needs a file, a socket, or a process, the request path must not block on any of them, and where that data lives is a deployment decision the WAF has no standing to make.

Body processors (d3d0b83) — selected by ctl:requestBodyProcessor exactly like the built-ins, publishing what they extract as ordinary request-body arguments, bounded by the transaction's own argument limits.

Two properties a security control can rely on:

  1. An extension nothing provides fails to compile. A rule naming an unregistered operator, or a ctl naming an unregistered processor, is a compile error — not a rule that never matches or a body nothing reads. That is the difference between a ruleset missing coverage and one that only appears to have it, which is why plugin names are passed to plan.compileWithPlugins: the compiler can tell "the host provides this" from "nobody does".
  2. Unable to answer is not no-match. An operator plugin returns unavailable; the rule does not match, but the engine counts it and pluginUnavailableCount reports it. A GeoIP database that failed to load would otherwise appear as an unbroken stream of requests from nowhere in particular. A body processor says malformed for the same reason and raises REQBODY_PROCESSOR_ERROR rather than passing as empty.

Collections and audit delivery already had their contracts: persistent.Backend for IP/SESSION/USER/GLOBAL/RESOURCE (feature-bit versioned, #10) and audit_writer.Sink for serial, concurrent, and callback delivery (#31).

Deliberately not extensible, and why:

  • Transformations — a closed typed union pinned to the ModSecurity ∪ Coraza set and checked against both upstreams' own sources by zig build test-transformation-inventory (mismatches=0). A host-supplied transformation would break the property that makes that check mean anything: that every transformation a rule can name behaves byte-for-byte as the baselines do.
  • Actions and directives — closed for the same reason; their semantics are the compatibility surface. An unrecognized one is a diagnostic, not an extension point.
  • Static WASM registration — there is no embedded runtime and adding one would put an interpreter on the request path. The supported cross-language boundary is the versioned C ABI (#33), which a host can back with a WASM runtime it manages itself. Recorded as a decision rather than left as an implied gap.

Sign in to comment on this issue.