Skip to content

waf: materialize managed rules (OWASP CRS) as the project Coraza zone#35

Draft
acoshift wants to merge 2 commits into
mainfrom
waf-managed-rules
Draft

waf: materialize managed rules (OWASP CRS) as the project Coraza zone#35
acoshift wants to merge 2 commits into
mainfrom
waf-managed-rules

Conversation

@acoshift

Copy link
Copy Markdown
Member

Deployer leg of SPEC-waf-managed-rules.md (§4): materialize WAFManagedRules as the project's parapet Coraza zone.

Do-not-merge gate (why this PR is a draft)

  • Re-pin go.mod before merge — this is the first thing to do, not a follow-up. Both new pins are currently PR-head pseudo-versions that become dangling refs after squash-merge:

    • github.com/deploys-app/api @ d27048143494 (waf-managed-rules branch) → re-pin to the post-merge api main commit/tag.
    • github.com/moonrhythm/parapet-ingress-controller @ v0.15.4-0.20260710175536-ad90a1a7b02c (= PR #181 coraza-v1-fixes HEAD, still OPEN; latest release is v0.15.3, pre-fix) → re-pin to the release tag once cut.

    The parapet pin is not just hygiene: the compile-gate/canary tests (spec §4.5, §10 step 2) are the mechanical enforcement that this PR cannot ship before the §9a engine release exists. With a PR-head pin CI is green while §9a is unreleased, so the PR stays draft until both re-pins land — that is the merge blocker, not review state.

  • Parapet §9a released and rolled out with CORAZA_ENABLED=true on the waf-flagged clusters (rollout step 0).

What it does

  • k8s/coraza.gogenerateCorazaConf: deterministic SecLang from the typed knobs (engine mode, explicit paranoia/threshold setvars so the fingerprint is stable across CRS bumps, the resolving include forms Include @crs-setup.conf.example / Include @owasp_crs/*.conf, sorted SecRuleRemoveById exclusions after the includes).
  • k8s/waf.goCreateWAFZone/DeleteWAFZone gain corazaZoneID + managed: enabled → upsert coraza-<pid> ConfigMap (label parapet.moonrhythm.io/coraza: zone) + stamp parapet.moonrhythm.io/coraza-zone on all project Ingresses via the single syncZoneAnnotations pass; nil/disabled → delete ConfigMap + strip annotation. Empty CorazaZoneID (pre-managed-rules apiserver) leaves all Coraza state untouched — the RateLimitZoneID mixed-version pattern.
  • k8s/ingress.goCreateIngress and CreateRedirectIngress add the fourth best-effort zone lookup (corazaZoneForProject), so routes created after the zone exists stay covered (both halves of the re-stamp rule, spec §4.4).
  • main.go — worker passes the two new command fields through; no new command case.

Test evidence

gofmt clean; go build ./... && go vet ./... && go test ./... green — 53/53 in 3 packages, including:

  • Goldens (k8s/waf_test.go): enforce/detect/defaults/sorted-exclusions conf cases + determinism (input slice not mutated).
  • Compile gate (k8s/coraza_test.go, TestGenerateCorazaConfCompiles): every golden compiles via the production engine (parapet-ingress-controller/corazawaf over the embedded coreruleset.FS) — a compile rejection in production is controller-log-only and last-good for a new zone is pass-through, so this is a CI invariant.
  • Behavior canary (TestCorazaCanaryGETXSS): GET /?q=<script>alert(1)</script> → 403 enforce / 200 clean / 200 detect — pins the §9a unconditional-phase-2 contract (red on any pre-§9a engine).
  • Zone transitions (k8s/waf_zone_test.go, fake clientset): materialize / disable / nil-teardown / delete, ConfigMap shape + labels, and the mixed-version guard on both Create and Delete paths (empty CorazaZoneID must leave a live zone's ConfigMap and annotation untouched).

Rollout (spec §10 / §14 — order is load-bearing)

  1. Parapet §9a released + rolled out with CORAZA_ENABLED=true (+ decide CORAZA_REQUEST_BODY_LIMIT, recommended 131072) on gke.cluster-rcf2 and cluster-lab — before everything. Without the phase-2 fix, CRS never blocks GET query-string attacks.
  2. api PR merged → re-pin here (checklist above).
  3. This PR: deploy the deployer binary before the apiserver. A new apiserver with an old deployer marks zones Success with managed rules silently not materialized (spec §4.6). The new deployer tolerates old apiservers via the empty-CorazaZoneID guard.
  4. apiserver: apply the waf_zones.managed_rules column migration, then the binary; then flip the location feature flags (ops SQL, by name).
  5. console / docs / mcp / CLI re-pin — any order after 4.

Runbook: rollback ⇒ stale enforcement (spec §4.6)

The empty-CorazaZoneID guard is deliberately conservative ("leave Coraza state untouched"), which is safe pre-materialization but has a sharp edge once zones are live: during an apiserver rollback past the managed-rules version, a tenant disabling managed rules (or deleting the WAF zone) gets Success while the stale coraza-<pid> ConfigMap keeps blocking their traffic. The same edge exists during a deployer binary rollback — an old deployer ignores CorazaZoneID/ManagedRules entirely, so a disable in that window also leaves the blocking ConfigMap live. Runbook rule: after any such rollback window with live zones, sweep on roll-forward — re-waf.set affected zones or delete stray parapet.moonrhythm.io/coraza=zone ConfigMaps whose zone row says disabled; treat disable requests during the window as not yet honored.

Expected conflicts with sibling WAF branches

Three sibling branches are open (api/apiserver: waf-test, waf-ip-lists, waf-events), all cut from origin/main. None of them touch this repo or api/deployer.go, so no deployer-side conflicts are expected. The one coupling is the api go.mod re-pin: whichever api PRs merge first, the re-pin here must target an api main commit that contains the managed-rules api leg (WAFManagedRules, DeployerCommandWAFSet.CorazaZoneID/ManagedRules); if the sibling api PRs merge before it, the re-pin simply lands on a later main commit that includes all of them — resolution is "pin newest main", no code changes here.

acoshift added 2 commits July 11, 2026 08:50
…a zone

Generate the deterministic SecLang document from WAFManagedRules (engine
mode, explicit paranoia/threshold setvars, the resolving CRS include
forms, sorted SecRuleRemoveById exclusions), upsert it as the
coraza-<pid> ConfigMap beside the WAF/ratelimit zones, and stamp the
parapet.moonrhythm.io/coraza-zone annotation on all project ingresses —
both on zone set (syncZoneAnnotations) and on later ingress creation
(CreateIngress + CreateRedirectIngress lookups), so new routes stay
covered. Disabled/nil managed rules delete the ConfigMap and strip the
annotation; WAF zone delete tears the Coraza zone down too. An empty
CorazaZoneID in the command means a pre-managed-rules apiserver — all
Coraza state is left untouched (the RateLimitZoneID mixed-version
pattern).

Tests: conf goldens + determinism in k8s/waf_test.go, and in
k8s/coraza_test.go the CI compile gate (every golden must compile via
the production engine, parapet-ingress-controller/corazawaf over the
embedded coreruleset.FS) plus the GET-XSS behavior canary (enforce 403 /
clean 200 / detect 200), which pins the unconditional-phase-2 engine
contract.

Pins: github.com/deploys-app/api @ d27048143494 (waf-managed-rules
branch — re-pin to the next api tag after the api PR merges);
github.com/moonrhythm/parapet-ingress-controller @ ad90a1a7b02c
(PR #181 coraza-v1-fixes — re-pin to the release once cut; carries
coraza v3.7.0 + coraza-coreruleset v4.25.0, the same versions the
deployed parapet image will pin, so the CI compile is the production
compile).
Widen Client.client from *kubernetes.Clientset to kubernetes.Interface so
tests can inject k8s.io/client-go/kubernetes/fake, and add a table test
over the CreateWAFZone/DeleteWAFZone Coraza branches: materialize
(ConfigMap upsert + annotation stamp), disable/nil teardown (ConfigMap
delete + annotation strip), delete, and the load-bearing mixed-version
guard — an empty CorazaZoneID must leave live Coraza state untouched on
both paths, since a regression that strips the annotation on
pre-managed-rules commands would silently unbind live zones. Also pins
the materialized ConfigMap shape (crs.conf key, generated bytes, the
parapet.moonrhythm.io/coraza=zone + projectId labels the controller
watch selects on).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant