From 0bf411237066f5fa3b26ccc1bbc4ccef25c11cab Mon Sep 17 00:00:00 2001 From: Thanatat Tamtan Date: Sat, 11 Jul 2026 08:50:11 +0700 Subject: [PATCH 1/2] feat(waf): materialize managed rules (OWASP CRS) as the project Coraza zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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- 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). --- go.mod | 23 +++++++++- go.sum | 59 +++++++++++++++++++++++-- k8s/coraza.go | 73 +++++++++++++++++++++++++++++++ k8s/coraza_test.go | 106 +++++++++++++++++++++++++++++++++++++++++++++ k8s/ingress.go | 20 +++++++-- k8s/waf.go | 54 ++++++++++++++++++----- k8s/waf_test.go | 92 +++++++++++++++++++++++++++++++++++++++ main.go | 4 +- 8 files changed, 409 insertions(+), 22 deletions(-) create mode 100644 k8s/coraza.go create mode 100644 k8s/coraza_test.go diff --git a/go.mod b/go.mod index 6b86cd8..20f17d2 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,14 @@ module github.com/deploys-app/deployer -go 1.26.4 +go 1.26.5 require ( cloud.google.com/go/pubsub v1.50.2 github.com/acoshift/configfile v1.9.0 github.com/cert-manager/cert-manager v1.20.2 - github.com/deploys-app/api v0.0.0-20260708143424-74880377593a + github.com/corazawaf/coraza-coreruleset/v4 v4.25.0 + github.com/deploys-app/api v0.0.0-20260711014233-d27048143494 + github.com/moonrhythm/parapet-ingress-controller v0.15.4-0.20260710175536-ad90a1a7b02c github.com/redis/go-redis/v9 v9.21.0 github.com/samber/lo v1.53.0 gopkg.in/yaml.v2 v2.4.0 @@ -27,6 +29,8 @@ require ( github.com/acoshift/arpc/v2 v2.2.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/corazawaf/coraza/v3 v3.7.0 // indirect + github.com/corazawaf/libinjection-go v0.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect @@ -48,17 +52,31 @@ require ( github.com/go-openapi/swag/stringutils v0.26.1 // indirect github.com/go-openapi/swag/typeutils v0.26.1 // indirect github.com/go-openapi/swag/yamlutils v0.26.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect + github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kaptinlin/go-i18n v0.1.4 // indirect + github.com/kaptinlin/jsonschema v0.4.6 // indirect + github.com/kavu/go_reuseport v1.5.0 // indirect + github.com/magefile/mage v1.17.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/moonrhythm/parapet v0.18.4 // indirect github.com/moonrhythm/validator v1.3.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect github.com/x448/float16 v0.8.4 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -89,6 +107,7 @@ require ( k8s.io/apiextensions-apiserver v0.36.2 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260623045532-0b43c5e46c6b // indirect + rsc.io/binaryregexp v0.2.0 // indirect sigs.k8s.io/gateway-api v1.5.1 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index 8d26681..6bbbf2f 100644 --- a/go.sum +++ b/go.sum @@ -37,14 +37,20 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc= +github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU= +github.com/corazawaf/coraza-coreruleset/v4 v4.25.0 h1:tqFO1lfVpTiyWtlN618OXpZMfw+nnN0Q4///W5W+/HM= +github.com/corazawaf/coraza-coreruleset/v4 v4.25.0/go.mod h1:nRuGXITxOPvsLF2VxaTB7pYok8QB8BitX3ZenXcUryY= +github.com/corazawaf/coraza/v3 v3.7.0 h1:LIQqu1r+l6e/U/gyiZeykWaNNBY1TzRLz+aaI+QYEEM= +github.com/corazawaf/coraza/v3 v3.7.0/go.mod h1:dOSt5evqC7EstouEv6ghhui01+oVUwp9X1vybWwqTlo= +github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI= +github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/deploys-app/api v0.0.0-20260708135725-e3e33ca238b4 h1:3pI35CsHR6x9cwAIUEWbn+IGy5s8ZOOCpFpnltf1RUU= -github.com/deploys-app/api v0.0.0-20260708135725-e3e33ca238b4/go.mod h1:X70UJs5awlRV4Cmn0FPJAgEbn4N933K8YgSKc1y9aD8= -github.com/deploys-app/api v0.0.0-20260708143424-74880377593a h1:+rzyRv/fVPHTwq82Qw7lGLER9LsqNFEDUIfnoQCqIBA= -github.com/deploys-app/api v0.0.0-20260708143424-74880377593a/go.mod h1:X70UJs5awlRV4Cmn0FPJAgEbn4N933K8YgSKc1y9aD8= +github.com/deploys-app/api v0.0.0-20260711014233-d27048143494 h1:qbfkNMsK2bwVUNCoEUuJpFJ6jGiRUDEYJAsnXpcyB6o= +github.com/deploys-app/api v0.0.0-20260711014233-d27048143494/go.mod h1:X70UJs5awlRV4Cmn0FPJAgEbn4N933K8YgSKc1y9aD8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= @@ -60,6 +66,8 @@ github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMD github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -101,6 +109,10 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zW github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -137,26 +149,50 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 h1:b70jEaX2iaJSPZULSUxKtm73LBfsCrMsIlYCUgNGSIs= +github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976/go.mod h1:ZGQeOwybjD8lkCjIyJfqR5LD2wMVHJ31d6GdPxoTsWY= +github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 h1:c7gcNWTSr1gtLp6PyYi3wzvFCEcHJ4YRobDgqmIgf7Q= +github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092/go.mod h1:ZZAN4fkkful3l1lpJwF8JbW41ZiG9TwJ2ZlqzQovBNU= +github.com/jcchavezs/mergefs v0.1.1 h1:D45R17m6dHnSVZefnhynoeZvcK2Uw0oTrRfoUOQ0S5Y= +github.com/jcchavezs/mergefs v0.1.1/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kaptinlin/go-i18n v0.1.4 h1:wCiwAn1LOcvymvWIVAM4m5dUAMiHunTdEubLDk4hTGs= +github.com/kaptinlin/go-i18n v0.1.4/go.mod h1:g1fn1GvTgT4CiLE8/fFE1hboHWJ6erivrDpiDtCcFKg= +github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPsI1T64= +github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0= +github.com/kavu/go_reuseport v1.5.0 h1:UNuiY2OblcqAtVDE8Gsg1kZz8zbBWg907sP1ceBV+bk= +github.com/kavu/go_reuseport v1.5.0/go.mod h1:CG8Ee7ceMFSMnx/xr25Vm0qXaj2Z4i5PWoUx+JZ5/CU= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM= +github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/moonrhythm/parapet v0.18.4 h1:FN2PMoFK4zteypC8WGiRrTpRt156lGJFjY3SzeipP7k= +github.com/moonrhythm/parapet v0.18.4/go.mod h1:mfLCzU9zBm6vn05mnOgO0G8/+pArndciyLv3BYM9HJA= +github.com/moonrhythm/parapet-ingress-controller v0.15.4-0.20260710175536-ad90a1a7b02c h1:Un7U/VR4j5cPdyTTT7L5igupjY9ln+wCzfUmei3xzS0= +github.com/moonrhythm/parapet-ingress-controller v0.15.4-0.20260710175536-ad90a1a7b02c/go.mod h1:0sfbTniUR8c10n9ZEmeJWdTAm/3LwThlF3eLZu8TGis= github.com/moonrhythm/validator v1.3.0 h1:OzZSW8Xtb66Gz7SA8qzY3ANupgdMojLFC1imDs1utOM= github.com/moonrhythm/validator v1.3.0/go.mod h1:gbDyBOwWGzphP8K2Sdrd9+MveRVxzxm+ezecimvu9UU= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 h1:Vpr4VgAizEgEZsaMohpw6JYDP+i9Of9dmdY4ufNP6HI= +github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -182,6 +218,15 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw= +github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= @@ -220,6 +265,8 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -255,6 +302,8 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= @@ -317,6 +366,8 @@ k8s.io/kube-openapi v0.0.0-20260623045532-0b43c5e46c6b h1:ugmmA3LN9pSEzoBo786Ip2 k8s.io/kube-openapi v0.0.0-20260623045532-0b43c5e46c6b/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= k8s.io/utils v0.0.0-20260617174310-a95e086a2553 h1:hmGqDecjc8d7HVzWzRFl0QD9bYuYKbBEG7t8xwnVxfI= k8s.io/utils v0.0.0-20260617174310-a95e086a2553/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0= sigs.k8s.io/gateway-api v1.5.1/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/k8s/coraza.go b/k8s/coraza.go new file mode 100644 index 0000000..b5b1310 --- /dev/null +++ b/k8s/coraza.go @@ -0,0 +1,73 @@ +package k8s + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/deploys-app/api" +) + +const ( + // corazaLabel marks a ConfigMap as a parapet Coraza (managed rules / + // OWASP CRS) zone. Separate label key and watch from the WAF's, same + // global+zone model. + corazaLabel = "parapet.moonrhythm.io/coraza" + // corazaZoneAnnotation binds an Ingress to a Coraza zone by its id + // (= the zone ConfigMap name). parapet resolves it namespace-locally. + corazaZoneAnnotation = "parapet.moonrhythm.io/coraza-zone" +) + +// generateCorazaConf renders the managed-rules SecLang document from the typed +// knobs. Deterministic — fixed line order, defaults rendered explicitly (so the +// conf fingerprint is stable across CRS bumps), exclusions sorted — so repeated +// Sets are byte-identical and parapet's fingerprint skip works. +// +// The include forms are the only ones the embedded coreruleset.FS resolves: +// coraza's Include is a plain fs.ReadFile that globs only when the path +// contains '*', and the FS holds @crs-setup.conf.example (a file) and +// @owasp_crs/ (a directory) — the bare @crs-setup / @owasp_crs forms fail to +// compile. A compile failure is controller-log-only and last-good for a new +// zone is pass-through, so TestGenerateCorazaConfCompiles keeps "the engine +// accepts every conf we can emit" a CI invariant. +// +// Exclusions render after the includes (SecRuleRemoveById removes +// already-loaded rules); the api-side id bounds (911100..948999) keep the two +// platform SecActions, the CRS setup, and the anomaly-scoring machinery out of +// reach. +func generateCorazaConf(m *api.WAFManagedRules) string { + engine := "On" + if m.Mode == "detect" { + engine = "DetectionOnly" + } + paranoia := m.ParanoiaLevel + if paranoia == 0 { + paranoia = 1 + } + threshold := m.AnomalyThreshold + if threshold == 0 { + threshold = 5 + } + + var b strings.Builder + fmt.Fprintf(&b, "SecRuleEngine %s\n", engine) + b.WriteString("SecRequestBodyAccess On\n") + fmt.Fprintf(&b, "SecAction \"id:900000,phase:1,pass,t:none,nolog,setvar:tx.blocking_paranoia_level=%d\"\n", paranoia) + fmt.Fprintf(&b, "SecAction \"id:900110,phase:1,pass,t:none,nolog,setvar:tx.inbound_anomaly_score_threshold=%d\"\n", threshold) + b.WriteString("Include @crs-setup.conf.example\n") + b.WriteString("Include @owasp_crs/*.conf\n") + excluded := slices.Clone(m.ExcludedRules) + slices.Sort(excluded) + for _, id := range excluded { + fmt.Fprintf(&b, "SecRuleRemoveById %d\n", id) + } + return b.String() +} + +// corazaZoneForProject is wafZoneForProject for the project's Coraza (managed +// rules) zone ConfigMap. The ConfigMap exists only while managed rules are +// enabled, so a disabled zone naturally stamps nothing on new ingresses. +func (c *Client) corazaZoneForProject(ctx context.Context, projectID string) (string, error) { + return c.zoneForProject(ctx, corazaLabel, projectID) +} diff --git a/k8s/coraza_test.go b/k8s/coraza_test.go new file mode 100644 index 0000000..446a9e5 --- /dev/null +++ b/k8s/coraza_test.go @@ -0,0 +1,106 @@ +package k8s + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + coreruleset "github.com/corazawaf/coraza-coreruleset/v4" + "github.com/deploys-app/api" + "github.com/moonrhythm/parapet-ingress-controller/corazawaf" +) + +// These tests import the exact engine that consumes the generated document in +// production (parapet-ingress-controller's corazawaf over the embedded OWASP +// CRS). The module pins must track the deployed parapet image's coraza / +// coraza-coreruleset versions (currently coraza v3.7.0 + coreruleset v4.25.0, +// parapet-ingress-controller#181) and bump together with image rollouts, so a +// green CI compile is the production compile. + +// TestGenerateCorazaConfCompiles is the compile gate: SetDirectives must accept +// every document the generator can emit. A rejection in production is +// controller-log-only while the zone reports Success, and last-good for a +// brand-new zone is pass-through — a non-compiling document would be a silent +// no-op WAF. +func TestGenerateCorazaConfCompiles(t *testing.T) { + t.Parallel() + + for _, tc := range generateCorazaConfCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + in := corazawaf.New(corazawaf.Options{RootFS: coreruleset.FS}) + if err := in.SetDirectives(generateCorazaConf(&tc.managed)); err != nil { + t.Fatalf("engine rejected generated conf: %v\n%s", err, tc.want) + } + if !in.Loaded() { + t.Fatal("ruleset must be loaded after SetDirectives") + } + }) + } +} + +// canaryRequest builds a browser-shaped GET so the canary exercises the attack +// signature, not CRS's missing-header hygiene rules. +func canaryRequest(target string) *http.Request { + r := httptest.NewRequest(http.MethodGet, target, nil) + r.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) canary") + r.Header.Set("Accept", "text/html") + return r +} + +func serveCanary(t *testing.T, m *api.WAFManagedRules, target string) int { + t.Helper() + in := corazawaf.New(corazawaf.Options{RootFS: coreruleset.FS}) + if err := in.SetDirectives(generateCorazaConf(m)); err != nil { + t.Fatalf("engine rejected generated conf: %v", err) + } + h := in.ServeHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, canaryRequest(target)) + return rec.Code +} + +// TestCorazaCanaryGETXSS is the behavior canary: with the default-knob +// document, a GET reflected XSS must be denied 403 by CRS anomaly blocking at +// paranoia level 1 with no request body. This is exactly the unconditional +// phase-2 case (949110 and most CRS detections are phase 2) — it goes red if +// an engine/CRS bump reintroduces body-gated phase-2 evaluation or changes the +// include layout. +func TestCorazaCanaryGETXSS(t *testing.T) { + t.Parallel() + + const xss = "/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E" + + enforce := api.WAFManagedRules{Enabled: true} + if code := serveCanary(t, &enforce, xss); code != http.StatusForbidden { + t.Errorf("GET reflected XSS = %d, want 403", code) + } + if code := serveCanary(t, &enforce, "/?q=hello"); code != http.StatusOK { + t.Errorf("clean GET = %d, want 200", code) + } + + detect := api.WAFManagedRules{Enabled: true, Mode: "detect"} + if code := serveCanary(t, &detect, xss); code != http.StatusOK { + t.Errorf("detect mode GET reflected XSS = %d, want 200 (DetectionOnly must never block)", code) + } +} + +// TestCorazaIncludeFormsPinned pins the two include lines to the only forms the +// embedded FS resolves — coraza's Include is a plain fs.ReadFile that globs +// only on '*', so the bare @crs-setup / @owasp_crs forms fail to compile. +func TestCorazaIncludeFormsPinned(t *testing.T) { + t.Parallel() + + conf := generateCorazaConf(&api.WAFManagedRules{Enabled: true}) + for _, line := range []string{ + "Include @crs-setup.conf.example\n", + "Include @owasp_crs/*.conf\n", + } { + if !strings.Contains(conf, line) { + t.Errorf("generated conf missing %q", strings.TrimSpace(line)) + } + } +} diff --git a/k8s/ingress.go b/k8s/ingress.go index 0e9a0e7..b09dbce 100644 --- a/k8s/ingress.go +++ b/k8s/ingress.go @@ -111,8 +111,9 @@ func (c *Client) CreateIngress(ctx context.Context, x Ingress) error { annotation["parapet.moonrhythm.io/forward-auth"] = string(b) } - // Bind to the project's WAF, ratelimit, cache, and transform zones if they - // exist, so routes added after the zones were created are still covered. + // Bind to the project's WAF, ratelimit, coraza, cache, and transform zones + // if they exist, so routes added after the zones were created are still + // covered. // Best-effort: a lookup error must not fail ingress creation since these are // best-effort relative to routing. if zoneID, err := c.wafZoneForProject(ctx, x.ProjectID); err != nil { @@ -125,6 +126,11 @@ func (c *Client) CreateIngress(ctx context.Context, x Ingress) error { } else if zoneID != "" { annotation[rateLimitZoneAnnotation] = zoneID } + if zoneID, err := c.corazaZoneForProject(ctx, x.ProjectID); err != nil { + slog.Error("ingress: looking up coraza zone error", "id", x.ID, "projectId", x.ProjectID, "error", err) + } else if zoneID != "" { + annotation[corazaZoneAnnotation] = zoneID + } if zoneID, err := c.cacheZoneForProject(ctx, x.ProjectID); err != nil { slog.Error("ingress: looking up cache zone error", "id", x.ID, "projectId", x.ProjectID, "error", err) } else if zoneID != "" { @@ -225,8 +231,9 @@ func (c *Client) CreateRedirectIngress(ctx context.Context, x RedirectIngress) e annotation["parapet.moonrhythm.io/forward-auth"] = string(b) } - // Bind to the project's WAF, ratelimit, cache, and transform zones if they - // exist, so routes added after the zones were created are still covered. + // Bind to the project's WAF, ratelimit, coraza, cache, and transform zones + // if they exist, so routes added after the zones were created are still + // covered. // Best-effort: a lookup error must not fail ingress creation since these are // best-effort relative to routing. if zoneID, err := c.wafZoneForProject(ctx, x.ProjectID); err != nil { @@ -239,6 +246,11 @@ func (c *Client) CreateRedirectIngress(ctx context.Context, x RedirectIngress) e } else if zoneID != "" { annotation[rateLimitZoneAnnotation] = zoneID } + if zoneID, err := c.corazaZoneForProject(ctx, x.ProjectID); err != nil { + slog.Error("ingress: looking up coraza zone error", "id", x.ID, "projectId", x.ProjectID, "error", err) + } else if zoneID != "" { + annotation[corazaZoneAnnotation] = zoneID + } if zoneID, err := c.cacheZoneForProject(ctx, x.ProjectID); err != nil { slog.Error("ingress: looking up cache zone error", "id", x.ID, "projectId", x.ProjectID, "error", err) } else if zoneID != "" { diff --git a/k8s/waf.go b/k8s/waf.go index 603839d..fd1d2d4 100644 --- a/k8s/waf.go +++ b/k8s/waf.go @@ -33,11 +33,15 @@ const ( // `rules:` YAML document, plus — when the zone has limits — the project's // ratelimit zone ConfigMap (name = rateLimitZoneID, labeled // parapet.moonrhythm.io/ratelimit: zone) holding them as a parapet `limits:` -// YAML document. It then binds every one of the project's Ingresses in this -// namespace via the parapet.moonrhythm.io/waf-zone and ratelimit-zone -// annotations. An empty limit set removes the ratelimit ConfigMap and -// annotation so parapet drops the zone instead of keeping an empty set. -func (c *Client) CreateWAFZone(ctx context.Context, projectID string, zoneID, rateLimitZoneID string, rules []api.WAFRule, limits []api.WAFLimit) error { +// YAML document, plus — when the zone has managed rules enabled — the +// project's Coraza zone ConfigMap (name = corazaZoneID, labeled +// parapet.moonrhythm.io/coraza: zone) holding the generated OWASP CRS SecLang +// document. It then binds every one of the project's Ingresses in this +// namespace via the parapet.moonrhythm.io/waf-zone, ratelimit-zone, and +// coraza-zone annotations. An empty limit set removes the ratelimit ConfigMap +// and annotation so parapet drops the zone instead of keeping an empty set; +// nil/disabled managed rules do the same for the Coraza zone. +func (c *Client) CreateWAFZone(ctx context.Context, projectID string, zoneID, rateLimitZoneID, corazaZoneID string, rules []api.WAFRule, limits []api.WAFLimit, managed *api.WAFManagedRules) error { if rules == nil { rules = []api.WAFRule{} } @@ -82,26 +86,53 @@ func (c *Client) CreateWAFZone(ctx context.Context, projectID string, zoneID, ra annotations[rateLimitZoneAnnotation] = "" } } + // Empty corazaZoneID = pre-managed-rules apiserver — same mixed-version + // guard as rateLimitZoneID above: leave everything Coraza-related + // untouched. + if corazaZoneID != "" { + if managed != nil && managed.Enabled { + err = c.upsertZoneConfigMap(ctx, projectID, corazaZoneID, corazaLabel, map[string]string{ + "crs.conf": generateCorazaConf(managed), + }) + if err != nil { + return err + } + annotations[corazaZoneAnnotation] = corazaZoneID + } else { + err = c.deleteConfigMap(ctx, corazaZoneID) + if err != nil { + return err + } + annotations[corazaZoneAnnotation] = "" + } + } return c.syncZoneAnnotations(ctx, projectID, annotations) } -// DeleteWAFZone removes the project's WAF zone and ratelimit zone ConfigMaps -// and strips the parapet.moonrhythm.io/waf-zone and ratelimit-zone annotations -// from every one of the project's Ingresses in this namespace. -func (c *Client) DeleteWAFZone(ctx context.Context, projectID string, zoneID, rateLimitZoneID string) error { +// DeleteWAFZone removes the project's WAF zone, ratelimit zone, and Coraza +// zone ConfigMaps and strips the parapet.moonrhythm.io/waf-zone, +// ratelimit-zone, and coraza-zone annotations from every one of the project's +// Ingresses in this namespace. +func (c *Client) DeleteWAFZone(ctx context.Context, projectID string, zoneID, rateLimitZoneID, corazaZoneID string) error { err := c.deleteConfigMap(ctx, zoneID) if err != nil { return err } // Empty rateLimitZoneID = pre-ratelimit apiserver (see CreateWAFZone); - // nothing ratelimit-related exists to tear down. + // nothing ratelimit-related exists to tear down. Same for corazaZoneID. if rateLimitZoneID != "" { err = c.deleteConfigMap(ctx, rateLimitZoneID) if err != nil { return err } } + if corazaZoneID != "" { + err = c.deleteConfigMap(ctx, corazaZoneID) + if err != nil { + return err + } + } annotations := map[string]string{ wafZoneAnnotation: "", @@ -109,6 +140,9 @@ func (c *Client) DeleteWAFZone(ctx context.Context, projectID string, zoneID, ra if rateLimitZoneID != "" { annotations[rateLimitZoneAnnotation] = "" } + if corazaZoneID != "" { + annotations[corazaZoneAnnotation] = "" + } return c.syncZoneAnnotations(ctx, projectID, annotations) } diff --git a/k8s/waf_test.go b/k8s/waf_test.go index 11fcbc3..8a9ddf6 100644 --- a/k8s/waf_test.go +++ b/k8s/waf_test.go @@ -68,3 +68,95 @@ func TestMarshalLimitsYAML(t *testing.T) { t.Errorf("filter key rendered %d times, want exactly 1 (omitempty for the empty filter):\n%s", n, out) } } + +// generateCorazaConfCases are the managed-rules goldens. Every case's output is +// also fed to the real Coraza engine by TestGenerateCorazaConfCompiles +// (coraza_test.go) — text goldens alone cannot catch a well-formed-looking +// document the engine rejects, and a compile rejection in production is a +// silent pass-through (controller-log-only, last-good for a new zone is +// nothing). +var generateCorazaConfCases = []struct { + name string + managed api.WAFManagedRules + want string +}{ + { + // The spec's worked example: exclusions render sorted ascending + // regardless of input order, so repeated Sets are byte-identical. + name: "enforce tuned with exclusions", + managed: api.WAFManagedRules{ + Enabled: true, + Mode: "enforce", + ParanoiaLevel: 2, + AnomalyThreshold: 5, + ExcludedRules: []int{942100, 920420}, + }, + want: `SecRuleEngine On +SecRequestBodyAccess On +SecAction "id:900000,phase:1,pass,t:none,nolog,setvar:tx.blocking_paranoia_level=2" +SecAction "id:900110,phase:1,pass,t:none,nolog,setvar:tx.inbound_anomaly_score_threshold=5" +Include @crs-setup.conf.example +Include @owasp_crs/*.conf +SecRuleRemoveById 920420 +SecRuleRemoveById 942100 +`, + }, + { + // Zero knobs render the defaults explicitly (paranoia 1, threshold 5) + // so the conf fingerprint doesn't depend on CRS-setup defaults. + name: "defaults", + managed: api.WAFManagedRules{Enabled: true}, + want: `SecRuleEngine On +SecRequestBodyAccess On +SecAction "id:900000,phase:1,pass,t:none,nolog,setvar:tx.blocking_paranoia_level=1" +SecAction "id:900110,phase:1,pass,t:none,nolog,setvar:tx.inbound_anomaly_score_threshold=5" +Include @crs-setup.conf.example +Include @owasp_crs/*.conf +`, + }, + { + name: "detect mode", + managed: api.WAFManagedRules{ + Enabled: true, + Mode: "detect", + ParanoiaLevel: 4, + AnomalyThreshold: 10, + ExcludedRules: []int{941100}, + }, + want: `SecRuleEngine DetectionOnly +SecRequestBodyAccess On +SecAction "id:900000,phase:1,pass,t:none,nolog,setvar:tx.blocking_paranoia_level=4" +SecAction "id:900110,phase:1,pass,t:none,nolog,setvar:tx.inbound_anomaly_score_threshold=10" +Include @crs-setup.conf.example +Include @owasp_crs/*.conf +SecRuleRemoveById 941100 +`, + }, +} + +func TestGenerateCorazaConf(t *testing.T) { + t.Parallel() + + for _, tc := range generateCorazaConfCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := generateCorazaConf(&tc.managed); got != tc.want { + t.Errorf("generateCorazaConf() =\n%s\nwant:\n%s", got, tc.want) + } + }) + } +} + +func TestGenerateCorazaConfDeterministic(t *testing.T) { + t.Parallel() + + m := api.WAFManagedRules{Enabled: true, ExcludedRules: []int{942100, 920420, 933100}} + first := generateCorazaConf(&m) + if got := generateCorazaConf(&m); got != first { + t.Error("repeated generation must be byte-identical") + } + // Sorting must not mutate the caller's slice (the command payload). + if m.ExcludedRules[0] != 942100 { + t.Error("generateCorazaConf must not reorder the input slice") + } +} diff --git a/main.go b/main.go index 20b45b0..4500abf 100644 --- a/main.go +++ b/main.go @@ -1347,7 +1347,7 @@ func (w *Worker) wafSet(ctx context.Context, it *api.DeployerCommandWAFSet) { projectID := idString(it.ProjectID) - err := w.Client.CreateWAFZone(ctx, projectID, it.ZoneID, it.RateLimitZoneID, it.Rules, it.Limits) + err := w.Client.CreateWAFZone(ctx, projectID, it.ZoneID, it.RateLimitZoneID, it.CorazaZoneID, it.Rules, it.Limits, it.ManagedRules) if err != nil { slog.Error("waf: setting error", "id", it.ID, "error", err) return @@ -1367,7 +1367,7 @@ func (w *Worker) wafDelete(ctx context.Context, it *api.DeployerCommandWAFDelete projectID := idString(it.ProjectID) - err := w.Client.DeleteWAFZone(ctx, projectID, it.ZoneID, it.RateLimitZoneID) + err := w.Client.DeleteWAFZone(ctx, projectID, it.ZoneID, it.RateLimitZoneID, it.CorazaZoneID) if err != nil { slog.Error("waf: deleting error", "id", it.ID, "error", err) return From 89891a2624b24463f61658bc8765b6baf83a2ed0 Mon Sep 17 00:00:00 2001 From: Thanatat Tamtan Date: Sat, 11 Jul 2026 09:08:12 +0700 Subject: [PATCH 2/2] test(waf): cover Coraza zone transitions against the fake clientset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- k8s/k8s.go | 4 +- k8s/waf_zone_test.go | 209 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 k8s/waf_zone_test.go diff --git a/k8s/k8s.go b/k8s/k8s.go index bc49ef1..5eba968 100644 --- a/k8s/k8s.go +++ b/k8s/k8s.go @@ -7,7 +7,9 @@ import ( ) type Client struct { - client *kubernetes.Clientset + // client is the interface (not *kubernetes.Clientset) so tests can inject + // the fake clientset. + client kubernetes.Interface certManagerClient *certmanager.Clientset namespace string } diff --git a/k8s/waf_zone_test.go b/k8s/waf_zone_test.go new file mode 100644 index 0000000..e22a374 --- /dev/null +++ b/k8s/waf_zone_test.go @@ -0,0 +1,209 @@ +package k8s + +import ( + "context" + "testing" + + "github.com/deploys-app/api" + v1 "k8s.io/api/core/v1" + networking "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" +) + +const testNamespace = "deploys" + +func newTestClient(objs ...runtime.Object) *Client { + return &Client{ + client: fake.NewClientset(objs...), + namespace: testNamespace, + } +} + +func testIngress(name, projectID string, annotations map[string]string) *networking.Ingress { + return &networking.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: testNamespace, + Labels: map[string]string{"projectId": projectID}, + Annotations: annotations, + }, + } +} + +func testCorazaConfigMap(name, projectID string) *v1.ConfigMap { + return &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: testNamespace, + Labels: map[string]string{ + "id": name, + "projectId": projectID, + corazaLabel: "zone", + }, + }, + Data: map[string]string{"crs.conf": "SecRuleEngine On\n"}, + } +} + +// TestWAFZoneCorazaTransitions covers the Coraza half of +// CreateWAFZone/DeleteWAFZone against the fake clientset: materialize +// (ConfigMap upsert + annotation stamp), teardown (ConfigMap delete + +// annotation strip), and the mixed-version guard (an empty corazaZoneID must +// leave all Coraza state untouched — a regression that strips the annotation +// on pre-managed-rules commands would silently unbind live zones). +func TestWAFZoneCorazaTransitions(t *testing.T) { + t.Parallel() + + enabled := &api.WAFManagedRules{Enabled: true} + + cases := []struct { + name string + // initial cluster state + objs []runtime.Object + // action + run func(c *Client) error + // expectations + wantConfigMap bool + wantAnnotation string // "" = annotation must be absent + }{ + { + name: "materialize stamps configmap and annotation", + objs: []runtime.Object{ + testIngress("web", "42", nil), + }, + run: func(c *Client) error { + return c.CreateWAFZone(context.Background(), "42", "waf-42", "", "coraza-42", nil, nil, enabled) + }, + wantConfigMap: true, + wantAnnotation: "coraza-42", + }, + { + name: "disable deletes configmap and strips annotation", + objs: []runtime.Object{ + testIngress("web", "42", map[string]string{corazaZoneAnnotation: "coraza-42"}), + testCorazaConfigMap("coraza-42", "42"), + }, + run: func(c *Client) error { + return c.CreateWAFZone(context.Background(), "42", "waf-42", "", "coraza-42", nil, nil, &api.WAFManagedRules{Enabled: false}) + }, + wantConfigMap: false, + wantAnnotation: "", + }, + { + name: "nil managed rules tears down like disabled", + objs: []runtime.Object{ + testIngress("web", "42", map[string]string{corazaZoneAnnotation: "coraza-42"}), + testCorazaConfigMap("coraza-42", "42"), + }, + run: func(c *Client) error { + return c.CreateWAFZone(context.Background(), "42", "waf-42", "", "coraza-42", nil, nil, nil) + }, + wantConfigMap: false, + wantAnnotation: "", + }, + { + name: "empty corazaZoneID on create leaves live zone untouched", + objs: []runtime.Object{ + testIngress("web", "42", map[string]string{corazaZoneAnnotation: "coraza-42"}), + testCorazaConfigMap("coraza-42", "42"), + }, + run: func(c *Client) error { + return c.CreateWAFZone(context.Background(), "42", "waf-42", "", "", nil, nil, nil) + }, + wantConfigMap: true, + wantAnnotation: "coraza-42", + }, + { + name: "delete removes configmap and strips annotation", + objs: []runtime.Object{ + testIngress("web", "42", map[string]string{corazaZoneAnnotation: "coraza-42"}), + testCorazaConfigMap("coraza-42", "42"), + }, + run: func(c *Client) error { + return c.DeleteWAFZone(context.Background(), "42", "waf-42", "", "coraza-42") + }, + wantConfigMap: false, + wantAnnotation: "", + }, + { + name: "empty corazaZoneID on delete leaves live zone untouched", + objs: []runtime.Object{ + testIngress("web", "42", map[string]string{corazaZoneAnnotation: "coraza-42"}), + testCorazaConfigMap("coraza-42", "42"), + }, + run: func(c *Client) error { + return c.DeleteWAFZone(context.Background(), "42", "waf-42", "", "") + }, + wantConfigMap: true, + wantAnnotation: "coraza-42", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + c := newTestClient(tc.objs...) + if err := tc.run(c); err != nil { + t.Fatal(err) + } + + ctx := context.Background() + _, err := c.client.CoreV1().ConfigMaps(testNamespace).Get(ctx, "coraza-42", metav1.GetOptions{}) + switch { + case tc.wantConfigMap && err != nil: + t.Errorf("coraza-42 ConfigMap must exist: %v", err) + case !tc.wantConfigMap && !errors.IsNotFound(err): + t.Errorf("coraza-42 ConfigMap must be gone, got err = %v", err) + } + + ing, err := c.client.NetworkingV1().Ingresses(testNamespace).Get(ctx, "web", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + got, ok := ing.Annotations[corazaZoneAnnotation] + if tc.wantAnnotation == "" { + if ok { + t.Errorf("annotation %s must be absent, got %q", corazaZoneAnnotation, got) + } + } else if got != tc.wantAnnotation { + t.Errorf("annotation %s = %q, want %q", corazaZoneAnnotation, got, tc.wantAnnotation) + } + }) + } +} + +// TestWAFZoneCorazaMaterializedConf pins the materialized ConfigMap shape: +// data key, generated conf bytes, and the labels parapet's Coraza watch +// selects on (parapet.moonrhythm.io/coraza=zone + projectId). +func TestWAFZoneCorazaMaterializedConf(t *testing.T) { + t.Parallel() + + c := newTestClient(testIngress("web", "42", nil)) + managed := &api.WAFManagedRules{ + Enabled: true, + ParanoiaLevel: 1, + ExcludedRules: []int{942100}, + } + err := c.CreateWAFZone(context.Background(), "42", "waf-42", "", "coraza-42", nil, nil, managed) + if err != nil { + t.Fatal(err) + } + + cm, err := c.client.CoreV1().ConfigMaps(testNamespace).Get(context.Background(), "coraza-42", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if got, want := cm.Labels[corazaLabel], "zone"; got != want { + t.Errorf("label %s = %q, want %q", corazaLabel, got, want) + } + if got, want := cm.Labels["projectId"], "42"; got != want { + t.Errorf("label projectId = %q, want %q", got, want) + } + if got, want := cm.Data["crs.conf"], generateCorazaConf(managed); got != want { + t.Errorf("crs.conf =\n%s\nwant:\n%s", got, want) + } +}