Write custom rules with plugins
This feature is tech preview. Tech preview features may be subject to significant changes before they become GA.
A WAFPlugin (namespace) or GlobalWAFPlugin (cluster) holds custom ModSecurity SecLang directives in four lifecycle slots — config, before, rules, after — for app-specific detections and false-positive exceptions the Core Rule Set doesn't cover on its own. Cluster operators wanting the same rules org-wide use GlobalWAFPlugin instead — same fields, different scope. For what each slot does and when it runs, see Plugins — custom rules.
ctl:ruleEngine=Off inside a chained rule or per-request disables the engine for that transaction only. A top-level SecRuleEngine Off is rejected by the admission webhook.
SecRuleRemoveById/ctl:ruleRemoveById in after apply for the rest of phase processing — use to suppress your own false positives. Protected Core Rule Set baseline rules can't be removed from any slot; the webhook rejects the attempt.
Prerequisites
- Gateway WAF is enabled. See Enable Gateway WAF.
- You have write access to
WAFPluginin your namespace (orGlobalWAFPluginat cluster scope). - A
WAFPolicyorGlobalWAFPolicyexists to reference the plugin from — a plugin does nothing until a policy loads it. See Attach WAF to a Gateway or route.
Write a plugin
-
Find your namespace's SecRule ID range. Every
SecRule/SecActionneeds an ID, and each namespace gets a 10,000-ID block enforced at admission:kubectl get namespace <your-namespace> \-o jsonpath='{.metadata.annotations.applicationlayer\.projectcalico\.org/waf-id-range}'# example output: 420000-429999The range is populated the first time a plugin is referenced in your namespace. See SecRule ID ranges for protected ranges and the allowlist.
-
Write the
WAFPlugin, placing each rule in the slot that matches when it should fire. This example uses all four slots to protect a GraphQL API. The4200xxIDs below assume the420000-429999example range from the previous step — swap in IDs from your own namespace's range, or the admission webhook rejects the plugin withIDOutsideRange:apiVersion: applicationlayer.projectcalico.org/v3kind: WAFPluginmetadata:name: graphql-wafnamespace: platformspec:description: "GraphQL introspection guard and false-positive exceptions"config: |# Flag the application type so later rules can branch on it.SecAction \"id:420001,phase:1,pass,nolog,\setvar:tx.is_graphql=1"before: |# Allowlist the schema endpoint used by the internal schema registry —# skip WAF analysis entirely for that specific path.SecRule REQUEST_URI "@streq /graphql/schema-registry" \"id:420010,phase:1,pass,nolog,\ctl:ruleEngine=Off,\tag:allowlist"rules: |# Block introspection queries (production guard).SecRule TX:IS_GRAPHQL "@eq 1" \"id:420020,phase:2,deny,status:403,\msg:'GraphQL introspection blocked',\logdata:'body_snippet=%{MATCHED_VAR}',\chain"SecRule REQUEST_BODY "@rx __schema|__type" ""# Block suspicious user-agents known to scan GraphQL APIs.SecRule REQUEST_HEADERS:User-Agent "@pmf graphql-scanners.dat" \"id:420021,phase:1,deny,status:403,\msg:'Known GraphQL scanner blocked',\logdata:'ua=%{MATCHED_VAR}'"after: |# Remove a false positive: CRS rule 941130 fires on a legitimate# Content-Type value used by our GraphQL client library.SecRuleRemoveById 941130tip@pmfloads a data file, which must be bundled into the Coraza WASM filter image — ask your cluster operator, or use@pm(inline phrase match) for a short string list. -
Reference the plugin from a
WAFPolicy, inspec.plugins[](load order = array position):apiVersion: applicationlayer.projectcalico.org/v3kind: WAFPolicymetadata:name: platform-api-wafnamespace: platformspec:targetRefs:- group: gateway.networking.k8s.iokind: HTTPRoutename: graphql-apiaction: Blockplugins:- kind: WAFPluginname: graphql-waf # ← your plugin aboveIf the policy also references a
GlobalWAFPlugin, its slots load before the namespace plugin's slots at each position.noteNot all SecLang directives are permitted — directives that read the filesystem, fetch remote rules, or write to arbitrary audit log paths are rejected. See SecRule ID ranges for the allowlist.
-
Confirm acceptance and reconciliation:
kubectl get wafplugin graphql-waf -n platform -o yaml | grep -A10 "conditions:"kubectl get wafpolicy platform-api-waf -n platform -o yaml | grep -A20 "conditions:"Ready=Trueon theWAFPolicyconfirms the merged ruleset passed validation and theEnvoyExtensionPolicywas programmed.
Gateway WAF runs the OWASP Core Rule Set v4 via Coraza; the ModSecurity reference manual covers the full SecLang directive set.
Next steps
- Validate configuration with Rego — enforce guardrails on the merged ruleset.
- Reference — SecRule ID ranges, allowed directives, and status conditions.
- Troubleshooting — diagnose a plugin that won't accept or program.