diff --git a/.github/styles/config/vocabularies/CalicoTerminology/accept.txt b/.github/styles/config/vocabularies/CalicoTerminology/accept.txt index f84795dba5..5625ab0a05 100644 --- a/.github/styles/config/vocabularies/CalicoTerminology/accept.txt +++ b/.github/styles/config/vocabularies/CalicoTerminology/accept.txt @@ -144,3 +144,20 @@ VNet[s]? [dD]ecap(sulation)? [rR]epo[s]? [dD]iags + +# Gateway WAF (per-route and namespaced WAF) +[rR]ego +[aA]llowlist(ed|ing|s)? +GlobalWAFPolic(y|ies) +WAFPolic(y|ies) +GlobalWAFPlugins? +WAFPlugins? +GlobalWAFValidationPolic(y|ies) +WAFValidationPolic(y|ies) +targetRefs? +namespaceSelector +paranoiaLevel +coreRuleSet +defaultAction +enforcementMode +SecLang diff --git a/calico-enterprise/threat/deploying-waf-ingress-gateway.mdx b/calico-enterprise/threat/deploying-waf-ingress-gateway.mdx index 03aaf89acd..ab136c12f2 100644 --- a/calico-enterprise/threat/deploying-waf-ingress-gateway.mdx +++ b/calico-enterprise/threat/deploying-waf-ingress-gateway.mdx @@ -13,6 +13,12 @@ This feature is tech preview. Tech preview features may be subject to significan ::: +:::caution + +This page describes the original, Gateway-wide WAF for Calico Ingress Gateway. It is superseded by [Gateway WAF](./gateway-waf/overview.mdx), which adds per-route and per-namespace policies, custom rules, and compliance validation. For Gateway-wide protection equivalent to this guide, attach a `WAFPolicy` to your `Gateway` — see [Attach WAF to a route](./gateway-waf/attach-waf-to-a-route.mdx). This page will be removed in a future release. + +::: + ## Prerequisites * [Calico Ingress Gateway](../networking/ingress-gateway/about-calico-ingress-gateway.mdx) is set up for your cluster. diff --git a/calico-enterprise/threat/gateway-waf/attach-waf-to-a-route.mdx b/calico-enterprise/threat/gateway-waf/attach-waf-to-a-route.mdx new file mode 100644 index 0000000000..662c70870b --- /dev/null +++ b/calico-enterprise/threat/gateway-waf/attach-waf-to-a-route.mdx @@ -0,0 +1,108 @@ +--- +description: Attach a WAFPolicy to a Gateway or HTTPRoute to turn on WAF inspection for that target, layering namespace overrides on the cluster baseline. +--- + +# Attach WAF to a Gateway or route + +:::note + +This feature is tech preview. Tech preview features may be subject to significant changes before they become GA. + +::: + +Create a `WAFPolicy` in your namespace pointing at Gateway API targets — WAF inspection doesn't start automatically when the feature is enabled. It inherits the cluster baseline with namespace overrides layered on top. + +## Prerequisites + +- The cluster operator has created the `GlobalWAFPolicy` baseline. See [Set a cluster-wide baseline for Gateway WAF](./set-a-baseline.mdx). +- You have write access to `WAFPolicy` in your namespace. +- A `Gateway` or `HTTPRoute` in your namespace is already receiving traffic. + +## Choose a target + +`WAFPolicy.spec.targetRefs[]` accepts two kinds of targets: + +| `kind` in `targetRefs` | WAF covers | Use when | +| :--- | :--- | :--- | +| `Gateway` | Every `HTTPRoute` bound to that Gateway | You want uniform WAF across all routes on a Gateway | +| `HTTPRoute` | Just that one route | You need per-route tuning — different paranoia level, different plugins | + +Each `targetRefs` entry generates a separate `EnvoyExtensionPolicy` (max 16 entries). + +:::note + +`sectionName` scopes a `targetRefs` entry to a specific listener on a `Gateway` (or a named section), so WAF applies to just that listener rather than the whole Gateway. + +::: + +## Attach a WAFPolicy + +1. Create a `WAFPolicy` in your namespace pointing at the target. This example attaches to one `HTTPRoute`, overriding action and paranoia; the unset `coreRuleSet.state` still inherits from the baseline: + + ```yaml + apiVersion: applicationlayer.projectcalico.org/v3 + kind: WAFPolicy + metadata: + name: payments-api-waf + namespace: payments + spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: payments-api + action: Block # override: Block even if global baseline is Detect + coreRuleSet: + paranoiaLevel: 2 # stricter than the cluster default + # coreRuleSet.state is unset → inherited from GlobalWAFPolicy + ``` + + Apply it with `kubectl apply -f wafpolicy.yaml`. For inheritance details, see the [Overview](./overview.mdx#inheritance-namespaces-override-they-dont-restart). + +1. Verify the policy was accepted and programmed: + + ```bash + kubectl get wafpolicy payments-api-waf -n payments + ``` + + ``` + NAME ACCEPTED PROGRAMMED AGE + payments-api-waf True True 45s + ``` + + `.status.conditions` reports `Accepted`, `Programmed`, and validation findings. Confirm Envoy Gateway received the filter with `kubectl get envoyextensionpolicies -n payments` (one per `targetRefs` entry). + +1. Test that the WAF blocks an attack: + + ```bash + curl -si "https:///api?id=1+OR+1%3D1" + ``` + + ``` + HTTP/1.1 403 Forbidden + content-length: 11 + content-type: text/plain + + Access denied + ``` + + :::tip + + In `Detect` mode the request is allowed (200), but the WAF hit is still recorded in the `tigera_secure_ee_waf` log index (carrying `gateway_name`/`gateway_namespace`). On this release, gateway-WAF hits are not raised as Security Events — see [Observing WAF activity](./troubleshooting.mdx#observing-waf-activity). + + ::: + + :::caution + + `ACCEPTED=False` reason `ValidationFailed` means a `GlobalWAFValidationPolicy` is blocking your config — the status message names the policy and rule. Contact your cluster operator or see [Validate configuration with Rego](./validate-config.mdx). + + ::: + +## Conflict resolution + +Only one `WAFPolicy` may be active per target. If two in the same namespace target the same Gateway/HTTPRoute, $[prodname] follows [GEP-713](https://gateway-api.sigs.k8s.io/geps/gep-713/): oldest `creationTimestamp` wins; the newer is marked `Accepted=False` reason `Conflicted`, and only the winner's directives are merged. Fix by deleting the newer policy or repointing its `targetRefs`. + +## Next steps + +- [Write custom rules with plugins](./write-custom-rules.mdx) — add app-specific detections beyond the baseline. +- [Validate configuration with Rego](./validate-config.mdx) — check the merged config against compliance guardrails. +- [Troubleshooting](./troubleshooting.mdx) — diagnose a policy that won't accept or program. diff --git a/calico-enterprise/threat/gateway-waf/enable.mdx b/calico-enterprise/threat/gateway-waf/enable.mdx new file mode 100644 index 0000000000..3d47e58bda --- /dev/null +++ b/calico-enterprise/threat/gateway-waf/enable.mdx @@ -0,0 +1,91 @@ +--- +description: Turn Gateway WAF on or off by setting the operator GatewayAPI CR's extensions.waf.state field. +--- + +# Enable Gateway WAF + +:::note + +This feature is tech preview. Tech preview features may be subject to significant changes before they become GA. + +::: + +Gateway WAF is off by default. Turning it on is a one-line change to the operator's `GatewayAPI` CR — no new workload is deployed, so there's no extra resource footprint to plan for. + +## Prerequisites + +- $[prodname] is installed with a valid license that includes the Gateway WAF feature. +- $[prodname] Ingress Gateway is already set up. See [About Calico Ingress Gateway](../../networking/ingress-gateway/about-calico-ingress-gateway.mdx). +- Cluster-admin rights — the `GatewayAPI` CR is a cluster-scoped operator resource. + +:::note + +Customer-installed (BYO) Envoy Gateway is not supported. Gateway WAF requires the Ingress Gateway that $[prodname] manages. + +::: + +## Enable Gateway WAF + +1. Edit the operator `GatewayAPI` CR named `default` and set `spec.extensions.waf.state` to `Enabled` (the default is `Disabled`): + + ```bash + kubectl apply -f - <<'EOF' + apiVersion: operator.tigera.io/v1 + kind: GatewayAPI + metadata: + name: default + spec: + extensions: + waf: + state: Enabled + EOF + ``` + + The operator installs the six WAF CRDs and the validating admission webhook, and wires the WAF reconcilers into the existing `calico-kube-controllers` pod in `calico-system`. No new workload is deployed. + +1. Confirm the six CRDs are installed: + + ```bash + kubectl get crd | grep applicationlayer.projectcalico.org + ``` + + ```text + globalwafplugins.applicationlayer.projectcalico.org + globalwafpolicies.applicationlayer.projectcalico.org + globalwafvalidationpolicies.applicationlayer.projectcalico.org + wafplugins.applicationlayer.projectcalico.org + wafpolicies.applicationlayer.projectcalico.org + wafvalidationpolicies.applicationlayer.projectcalico.org + ``` + + All six must be present before you create any WAF resources. + +1. Confirm the admission webhook is active: + + ```bash + kubectl get validatingwebhookconfigurations | grep waf + ``` + + You should see one entry. If it's absent, check the `calico-kube-controllers` pod logs in the `calico-system` namespace. + +## Disable Gateway WAF + +Set `spec.extensions.waf.state` back to `Disabled` on the same `GatewayAPI` CR to turn the feature off: + +```yaml +apiVersion: operator.tigera.io/v1 +kind: GatewayAPI +metadata: + name: default +spec: + extensions: + waf: + state: Disabled +``` + +The operator removes the admission webhook and stops the WAF reconcilers; existing `WAFPolicy`, `GlobalWAFPolicy`, and other WAF resources stay on the cluster but are no longer reconciled or enforced until you re-enable the feature. + +## Next steps + +- [Tutorial](./tutorial.mdx) — deploy a test app and block your first attack. +- [Set a cluster-wide baseline for Gateway WAF](./set-a-baseline.mdx) — start your production rollout. diff --git a/calico-enterprise/threat/gateway-waf/multi-tenant-setup.mdx b/calico-enterprise/threat/gateway-waf/multi-tenant-setup.mdx new file mode 100644 index 0000000000..a7a5d754bf --- /dev/null +++ b/calico-enterprise/threat/gateway-waf/multi-tenant-setup.mdx @@ -0,0 +1,89 @@ +--- +description: Run a full multi-tenant Gateway WAF setup — a cluster baseline, org-wide plugin, and validation policy that stops teams weakening protection while each team tunes independently. +--- + +# Multi-tenant setup + +:::note + +This feature is tech preview. Tech preview features may be subject to significant changes before they become GA. + +::: + +Multi-tenant setup ties the baseline, plugins, and validation guardrails together so a shared cluster stays safe by default while each team still controls its own Gateway. + +:::note[Scenario] + +A cluster shared by `team-a` and `payments`, with a cluster operator setting the baseline via a `GlobalWAFPolicy`, an org-wide `GlobalWAFPlugin`, and a `GlobalWAFValidationPolicy` that stops teams weakening protection. Each team attaches WAF to its own Gateway and tunes independently. + +::: + +## Prerequisites + +- Gateway WAF is enabled and licensed. See [Enable Gateway WAF](./enable.mdx). +- Cluster-admin rights for the cluster-operator steps; each team needs write access to WAF resources in its own namespace. + +## Set it up + +1. As cluster operator, create the baseline resources: the `default` `GlobalWAFPolicy`, an `org-security-baseline` `GlobalWAFPlugin`, and a `production-requirements` `GlobalWAFValidationPolicy` (`Enforce`, scoped to `env: production`). Follow the patterns in [Set a cluster-wide baseline](./set-a-baseline.mdx), [Write custom rules](./write-custom-rules.mdx), and [Validate configuration with Rego](./validate-config.mdx). + +1. Grant application operators read access to the `Global*` resources so they can see what they're inheriting: + + ```yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: waf-global-reader + rules: + - apiGroups: ["applicationlayer.projectcalico.org"] + resources: + - globalwafpolicies + - globalwafplugins + - globalwafvalidationpolicies + verbs: ["get", "list", "watch"] + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: RoleBinding + metadata: + name: waf-global-reader + namespace: team-a # repeat for payments + subjects: + - kind: Group + name: team-a-operators + apiGroup: rbac.authorization.k8s.io + roleRef: + kind: ClusterRole + name: waf-global-reader + apiGroup: rbac.authorization.k8s.io + ``` + + :::note + + This grants read on cluster-scoped resources via a namespace-scoped `RoleBinding` — application operators can see but not edit `Global*` resources. + + ::: + +1. Each team labels its namespace so the validation policy applies (`kubectl label namespace team-a env=production`, same for `payments`), then creates a `WAFPolicy` targeting its own Gateway. `team-a` raises paranoia to 2, inheriting `coreRuleSet.state: Enabled`; `payments` runs paranoia 3 plus a namespace `WAFPlugin` blocking GraphQL introspection, using an ID from its own [SecRule ID range](./reference.mdx#secrule-id-ranges). + +## What happens if a team tries to weaken the baseline + +If `payments` applies `action: Detect`, `production-requirements` (`Enforce`) fires its `must-use-block-mode` critical rule — the controller marks the policy `Programmed=False, Reason=NotAttempted` and surfaces the violation: + +```bash +kubectl get wafpolicy payments-waf -n payments \ + -o jsonpath='{.status.validation.failures}' | jq +``` + +No `EnvoyExtensionPolicy` is generated until corrected — restore `action: Block` or request an exception from the cluster operator. + +## Result: isolation in practice + +- **`team-a`** — Core Rule Set at paranoia 2, own custom rules, own ID range. +- **`payments`** — Core Rule Set at paranoia 3, GraphQL introspection block, a different ID range. +- Neither team can disable the org-wide rule from `GlobalWAFPlugin` or drop below Block mode. +- The cluster operator sees all violations centrally on `GlobalWAFValidationPolicy/production-requirements` status — one place to see which namespaces are non-compliant. + +## Next steps + +- [Reference](./reference.mdx) — CRDs, Rego validation, SecRule ID ranges, and status conditions. +- [Troubleshooting](./troubleshooting.mdx) — diagnose a policy blocked by a guardrail. diff --git a/calico-enterprise/threat/gateway-waf/overview.mdx b/calico-enterprise/threat/gateway-waf/overview.mdx new file mode 100644 index 0000000000..f591995beb --- /dev/null +++ b/calico-enterprise/threat/gateway-waf/overview.mdx @@ -0,0 +1,199 @@ +--- +description: Protect Calico Ingress Gateway traffic with a per-route and per-namespace web application firewall built on the OWASP Core Rule Set — how the resource families, merge order, and enforcement guardrails fit together. +--- + +# Gateway WAF + +:::note + +This feature is tech preview. Tech preview features may be subject to significant changes before they become GA. + +::: + +## Big picture + +Protect the traffic entering your cluster through $[prodname] Ingress Gateway with a web application firewall (WAF) that you attach per Gateway or per route, and tune per namespace. + +## Value + +Edge WAFs traditionally apply one ruleset to everything behind the gateway. Gateway WAF takes a Kubernetes-native, multi-tenant approach: a cluster operator sets a security baseline once, and each application team layers its own rules and exceptions on top — scoped to their own Gateways and `HTTPRoute`s, within guardrails the cluster operator defines. + +Gateway WAF runs the industry-standard [OWASP ModSecurity Core Rule Set v4](https://owasp.org/www-project-modsecurity-core-rule-set/) as a [Coraza](https://coraza.io/) WebAssembly filter inside Envoy Gateway — no sidecars, and no traffic ever leaves the proxy for inspection. + +## How it works + +Gateway WAF is configured entirely through Kubernetes resources. The controller merges every policy and plugin that applies to a target, generates an ordered list of ModSecurity directives, validates the result, and programs it into Envoy Gateway as an `EnvoyExtensionPolicy` that loads the Coraza WASM filter. + +Gateway WAF request flow. GlobalWAFPolicy/WAFPolicy and GlobalWAFPlugin/WAFPlugin are merged into an ordered list of SecLang directives. Rego validation then either passes the result to an EnvoyExtensionPolicy that loads the Coraza WASM filter on Envoy Gateway, or, on an Enforce-mode critical violation, programs no filter and reports the violation on status. The admission webhook validates submitted rules at apply time. + +The inputs to the merge are `GlobalWAFPolicy` + `WAFPolicy` and `GlobalWAFPlugin` + `WAFPlugin`. Two guardrails apply: the admission webhook (structural, at write time) and validation policies (compliance, after the merge). + +## Who does what + +Gateway WAF is configured through six custom resources in the `applicationlayer.projectcalico.org/v3` API group, in three families. Each family splits cleanly across two personas: a cluster-scoped resource owned by the **cluster operator (CO)**, and a namespace-scoped resource owned by the **application operator (AO)**. + +| Family | Cluster operator (CO) | Application operator (AO) | Answers the question | +| :--- | :--- | :--- | :--- | +| **Policy** | `GlobalWAFPolicy` — cluster-wide baseline (singleton, `name: default`) | `WAFPolicy` — attach WAF to a Gateway/`HTTPRoute` (`targetRefs`); per-namespace overrides | *What* to enforce — action, Core Rule Set state, paranoia, which plugins | +| **Plugin** | `GlobalWAFPlugin` — organization-wide custom rules | `WAFPlugin` — app-specific rules and false-positive exceptions | *Which custom rules* to add, in ModSecurity SecLang | +| **Validation** | `GlobalWAFValidationPolicy` — compliance guardrails (`Enforce`/`Audit`) | `WAFValidationPolicy` — advisory self-check, never blocks | *Whether a configuration is allowed* — compliance guardrails | + +Enabling the feature itself (license plus the `GatewayAPI` gate) is a one-time cluster operator step — see [Enable the feature](./enable.mdx) — everything above is ongoing configuration once it's on. + +The cluster operator *sets* the posture; the application operator *tunes* it — can go stricter, but can be stopped from going weaker (see [Validation and enforcement](#validation-and-enforcement)). + +## Policies — what to enforce + +A **policy** declares the action mode (`Detect`/`Block`), whether the Core Rule Set is enabled, the paranoia level, and which plugins to load. + +- `GlobalWAFPolicy` — cluster-wide singleton (`name: default`), sets the baseline for every namespace. +- `WAFPolicy` — namespace-scoped, attaches WAF to a Gateway/`HTTPRoute` via `targetRefs`, can override the baseline action and paranoia. + +## Plugins — custom rules + +A **plugin** holds custom ModSecurity rules in four lifecycle slots — `config`, `before`, `rules`, `after` — modeled on the [Core Rule Set 4 plugin system](https://coreruleset.org/docs/4-about-plugins/4-1-plugins/). Referenced from a policy's `spec.plugins[]`; does nothing on its own. + +- `GlobalWAFPlugin` — organization-wide rules: security baselines, compliance rules. +- `WAFPlugin` — application-specific rules and false-positive exceptions. + +Each slot runs at a fixed point in the merge, so slot choice decides when your rules fire: + +| Slot | Runs | Use for | +| :--- | :--- | :--- | +| `config` | Before Core Rule Set setup | `tx.*` variable setup (anomaly thresholds, optional features, app flags). Rules on request variables belong in `before` instead — the request isn't read yet | +| `before` | After variable setup, before Core Rule Set loads | Early-exit allowlisting of trusted paths, request normalization, flags for later rules | +| `rules` | Alongside Core Rule Set, after it (Tigera extension) | App-specific detections that complement rather than pre-empt the Core Rule Set; can act on anomaly scores | +| `after` | Last | False-positive suppression, response inspection, logging. `GlobalWAFPlugin.after` runs *after* namespace `WAFPlugin.after`, so the cluster operator has final say | + +For the full merge sequence across every policy and plugin, see [merged ruleset load order](#the-merged-ruleset-load-order); to write plugin rules, see [Write custom rules](./write-custom-rules.mdx). + +## Validation policies — guardrails + +A **validation policy** carries [Rego](https://www.openpolicyagent.org/docs/latest/policy-language/) rules evaluated against the *merged, effective* configuration — answering "is this acceptable?", e.g. "Core Rule Set must stay enabled" or "production must run in Block mode." + +- `GlobalWAFValidationPolicy` — enforced by the cluster operator: `Enforce` mode blocks programming on a `critical` violation, `Audit` mode only warns. `namespaceSelector` scopes which namespaces it applies to. +- `WAFValidationPolicy` — advisory only, surfaces violations on status for the application operator to act on, never blocks. No enforce mode at namespace scope. + +(Validation policies aren't the admission webhook — see [Validation and enforcement](#validation-and-enforcement) for the distinction.) + +## The global policy is a singleton + +`GlobalWAFPolicy` is a cluster-wide singleton — must be named `default`. It sets the baseline (default action, Core Rule Set state, paranoia level) every namespace inherits unless it overrides. + +`GlobalWAFPlugin` and `GlobalWAFValidationPolicy` are **not** singletons — create as many as needed. + +## Inheritance: namespaces override, they don't restart + +A `WAFPolicy` doesn't have to restate the whole baseline — its `action` and `coreRuleSet` fields are optional; unset fields inherit from `GlobalWAFPolicy`. + +```yaml +apiVersion: applicationlayer.projectcalico.org/v3 +kind: WAFPolicy +metadata: + name: payments-waf + namespace: payments +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: payments-api + action: Block # override the global default for this namespace + coreRuleSet: + paranoiaLevel: 2 # stricter than the baseline + # coreRuleSet.state is unset → inherited from GlobalWAFPolicy +``` + +| `WAFPolicy` field | When set | When unset | +| :--- | :--- | :--- | +| `action` | overrides the global `defaultAction` for this namespace | inherits `GlobalWAFPolicy.spec.defaultAction` | +| `coreRuleSet.state` | overrides the global state | inherits the global state | +| `coreRuleSet.paranoiaLevel` | overrides the global level | inherits the global level | + +## Plugins are additive, not overriding + +Plugins stack rather than override. The merger applies all referenced global plugins first (`GlobalWAFPolicy.spec.plugins[]` order), then all namespace plugins (`WAFPolicy.spec.plugins[]` order) — order is array position; there's no priority field. + +This lets baseline rules and application exceptions coexist in one ruleset — the interleaving, and why global `after` runs last, is covered below. + +## Merge order and enforcement + +When a `WAFPolicy` attaches to a target, the controller gathers every applicable policy and plugin, merges them into one ordered list of ModSecurity directives, validates the result, and programs it into Envoy Gateway as an `EnvoyExtensionPolicy`. Each `targetRefs[]` entry produces its own `EnvoyExtensionPolicy` — three routes means three filters. + +Three things govern the outcome: **merge order**, the **admission webhook**, and **validation policies**. + +### The merged ruleset load order + +Directive order isn't cosmetic: the Core Rule Set needs variables set before loading, and post-processing after. Fixed order: + +**Engine setup** +1. `SecRuleEngine On` / `DetectionOnly` — the effective action mode from the merged config. +2. Engine settings — audit logging and request-body access. + +**Plugin `config` (variable setup)** +3. Global plugin `config` blocks, in `GlobalWAFPolicy.spec.plugins[]` order. +4. Namespace plugin `config` blocks, in `WAFPolicy.spec.plugins[]` order. + +**Core Rule Set setup** +5. Core Rule Set setup — reads the `tx.*` variables set above. +6. Blocking paranoia level, from the merged paranoia level. +7. Detection paranoia level (always ≥ blocking level). + +**Pre-Core Rule Set rules (`before`)** +8. Global plugin `before` rules. +9. Namespace plugin `before` rules. + +**Core Rule Set** +10. The full OWASP Core Rule Set. + +**Custom rules (`rules`)** +11. Global plugin `rules`. +12. Namespace plugin `rules`. + +**Post-processing (`after`)** +13. Namespace plugin `after` rules. +14. Global plugin `after` rules — **runs last, giving the cluster operator the final say** on anomaly scoring and logging. + +Pattern: global-before-namespace in the early slots, namespace-before-global in `after`. Within a plugin, its own slots always run `config` → `before` → `rules` → `after`. + +:::note + +When two `WAFPolicy` resources target the same route, $[prodname] follows Gateway API [GEP-713](https://gateway-api.sigs.k8s.io/geps/gep-713/): oldest `creationTimestamp` wins (ties by `namespace/name`). The loser is marked `Accepted=False` reason `Conflicted`, directives not merged. + +::: + +### Structural safety: the admission webhook + +Before any of the above runs, a validating admission webhook checks the raw SecLang in a `WAFPlugin` or `WAFPolicy` at apply time, rejecting the resource if any directive fails one of three checks: + +| Check | Passes when | Rejected with | +| :--- | :--- | :--- | +| Directive is allowed | the directive type is on the allowlist (`SecRule`, `SecAction`, `SecRuleRemoveById`, and other safe directives) | `UnsafeDirective` | +| Rule ID is in range | a `SecRule`/`SecAction` ID falls inside the namespace's allocated ID range | `IDOutsideRange` | +| Removal is permitted | a `SecRuleRemoveById` targets an ID inside the namespace's own range, not a protected baseline rule | `ProtectedIDRemoval` | + +This stops one namespace colliding with another's rule IDs, or disabling the cluster's baseline Core Rule Set rules. Each namespace gets an automatic 10,000-ID range; see [SecRule ID ranges](./reference.mdx#secrule-id-ranges) for how it's derived and which IDs are protected. + +### Validation and enforcement + +The admission webhook checks *raw SecLang*; validation policies check the *merged result*, running after the merge because some problems only show up there. Two innocent plugins can combine to disable all SQL-injection rules — invisible to admission-time checks, but visible to a Rego rule against the merged directive list. + +Validation rules are [Rego](https://www.openpolicyagent.org/docs/latest/policy-language/) modules with severity `info`, `warning`, or `critical`. What happens on a violation depends on the policy: + +| Validation policy | Mode | `critical` violation | `warning` / `info` violation | +| :--- | :--- | :--- | :--- | +| `GlobalWAFValidationPolicy` | `Enforce` | **blocks** — no filter is programmed; status reports the rejection | warns on status; filter still programmed | +| `GlobalWAFValidationPolicy` | `Audit` (default) | warns on status; filter still programmed | warns on status; filter still programmed | +| `WAFValidationPolicy` | advisory only | surfaces on `WAFPolicy` status; never blocks | surfaces on status; never blocks | + +To write the rules, see [Rego validation](./reference.mdx#rego-validation). To read the outcome, see [Status conditions](./reference.mdx#status-conditions). + +## Next steps + +- [Enable the feature](./enable.mdx) — turn on Gateway WAF and verify the CRDs and webhook. +- [Tutorial](./tutorial.mdx) — deploy a test app and block your first attack. +- [Set a baseline](./set-a-baseline.mdx), [Attach WAF](./attach-waf-to-a-route.mdx), [Write custom rules](./write-custom-rules.mdx), [Validate config](./validate-config.mdx), [Multi-tenant setup](./multi-tenant-setup.mdx) — production tasks. +- [Reference](./reference.mdx) — CRDs, Rego validation, SecRule ID ranges, and status conditions. +- [Troubleshooting](./troubleshooting.mdx) — diagnose common failure modes. diff --git a/calico-enterprise/threat/gateway-waf/reference.mdx b/calico-enterprise/threat/gateway-waf/reference.mdx new file mode 100644 index 0000000000..aed22524e9 --- /dev/null +++ b/calico-enterprise/threat/gateway-waf/reference.mdx @@ -0,0 +1,376 @@ +--- +description: Field reference for the six Gateway WAF custom resources, the Rego validation input schema, SecRule ID range allocation, and status conditions. +--- + +# Reference + +:::note + +This feature is tech preview. Tech preview features may be subject to significant changes before they become GA. + +::: + +## CRDs + +Six custom resources in the `applicationlayer.projectcalico.org/v3` API group, in three families; each has a cluster-scoped and namespace-scoped resource sharing almost all fields. + +| Resource | Scope | Short name | Persona | Purpose | +| :--- | :--- | :--- | :--- | :--- | +| `GlobalWAFPolicy` | Cluster | `gwafp` | CO | Cluster-wide baseline (singleton, `name: default`) | +| `WAFPolicy` | Namespace | `wafp` | AO | Attach WAF to a target; per-namespace overrides | +| `GlobalWAFPlugin` | Cluster | `gwafplugin` | CO | Organization-wide custom rules | +| `WAFPlugin` | Namespace | `wafplugin` | AO | App-specific rules and exceptions | +| `GlobalWAFValidationPolicy` | Cluster | `gwafvp` | CO | Compliance guardrails (enforce or audit) | +| `WAFValidationPolicy` | Namespace | `wafvp` | AO | Advisory self-validation | + +### Policy family + +`GlobalWAFPolicy` sets the cluster baseline; `WAFPolicy` attaches WAF to a target and overrides the baseline for its namespace. + +```yaml +apiVersion: applicationlayer.projectcalico.org/v3 +kind: GlobalWAFPolicy +metadata: + name: default # singleton: name MUST be "default" +spec: + defaultAction: Block # Detect | Block + coreRuleSet: + state: Enabled # Enabled | Disabled + paranoiaLevel: 1 # 1 (balanced) to 4 (paranoid) + plugins: # order = array position + - kind: GlobalWAFPlugin + name: security-baseline +``` + +`WAFPolicy` adds `targetRefs` (`Gateway`/`HTTPRoute`); `action` and `coreRuleSet` are optional overrides of the global defaults: + +```yaml +apiVersion: applicationlayer.projectcalico.org/v3 +kind: WAFPolicy +metadata: + name: payments-waf + namespace: payments +spec: + targetRefs: # attach to a Gateway and/or HTTPRoute + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: payments-api + - group: gateway.networking.k8s.io + kind: Gateway + name: team-gateway + action: Block # optional; nil = inherit global defaultAction + coreRuleSet: + paranoiaLevel: 2 # optional; nil = inherit + plugins: + - kind: WAFPlugin + name: api-exceptions +``` + +**Field reference — Policy family** + +| Field | Type | Resource | Notes | +| :--- | :--- | :--- | :--- | +| `defaultAction` | `Detect` \| `Block` | GlobalWAFPolicy | Cluster default; defaults to `Detect` | +| `action` | `Detect` \| `Block` | WAFPolicy | Optional; **nil = inherit** the global default | +| `coreRuleSet.state` | `Enabled` \| `Disabled` | both | Defaults to `Enabled`; nil on WAFPolicy = inherit | +| `coreRuleSet.paranoiaLevel` | integer 1–4 | both | Defaults to `1`; nil on WAFPolicy = inherit | +| `plugins[]` | list of `{kind, name}` | both | `kind`: `WAFPlugin` \| `GlobalWAFPlugin`; order = array position; max 64 | +| `targetRefs[]` | Gateway API target refs | **WAFPolicy only** | `kind`: `Gateway` \| `HTTPRoute`; min 1, max 16. `sectionName` optionally scopes a ref to a specific listener/section | + +### Plugin family + +`GlobalWAFPlugin`/`WAFPlugin` share an identical spec: custom ModSecurity rules in four lifecycle slots — see [Write custom rules](./write-custom-rules.mdx). + +```yaml +apiVersion: applicationlayer.projectcalico.org/v3 +kind: GlobalWAFPlugin +metadata: + name: security-baseline +spec: + description: "Cluster security baseline" + config: | # 1. set tx.* variables + SecAction "id:1234001,phase:1,pass,nolog,setvar:tx.enforce_https=1" + before: | # 2. pre-CRS processing + SecRule REQUEST_URI "@beginsWith /webhooks/" \ + "id:1234010,phase:1,pass,nolog,ctl:ruleRemoveById=942200" + rules: | # 3. custom rules alongside CRS + SecRule REQUEST_HEADERS:User-Agent "@pmFromFile bad-bots.data" \ + "id:1234020,phase:1,deny,status:403,msg:'Blocked bot'" + after: | # 4. post-CRS processing + SecAction "id:1234030,phase:5,pass,nolog,log" +``` + +`WAFPlugin` uses the identical spec at namespace scope — only `metadata.namespace` and the persona differ. + +**Field reference — Plugin family** + +| Field | Type | Notes | +| :--- | :--- | :--- | +| `description` | string | Human-readable purpose; max 1024 chars | +| `config` | string (SecLang) | Slot 1 — `tx.*` variable setup, runs before Core Rule Set setup; max 65536 chars | +| `before` | string (SecLang) | Slot 2 — pre-Core Rule Set rules; max 65536 chars | +| `rules` | string (SecLang) | Slot 3 — custom rules alongside Core Rule Set (Tigera extension); max 65536 chars | +| `after` | string (SecLang) | Slot 4 — post-Core Rule Set rules; max 65536 chars | + +Rule IDs must fall in your namespace's allocated range — see [SecRule ID ranges](#secrule-id-ranges). + +### Validation family + +`GlobalWAFValidationPolicy` can enforce; `WAFValidationPolicy` is advisory only. Both carry Rego rules — see [Rego validation](#rego-validation). + +```yaml +apiVersion: applicationlayer.projectcalico.org/v3 +kind: GlobalWAFValidationPolicy +metadata: + name: security-requirements +spec: + enforcementMode: Enforce # Enforce | Audit (default Audit) + namespaceSelector: # optional; empty = all namespaces + matchLabels: + env: production + rules: + - name: crs-must-be-enabled + severity: critical + rego: | + package waf + violations[{"msg": "OWASP CRS must be enabled"}] { + input.config.crsState != "Enabled" + } +``` + +`WAFValidationPolicy` is namespace-scoped, advisory only — no `enforcementMode`, no `namespaceSelector`; every rule runs as if in `Audit` mode. + +**Field reference — Validation family** + +| Field | Type | Resource | Notes | +| :--- | :--- | :--- | :--- | +| `enforcementMode` | `Audit` \| `Enforce` | **GlobalWAFValidationPolicy only** | Defaults to `Audit`. `Enforce` blocks on a `critical` violation | +| `namespaceSelector` | label selector | **GlobalWAFValidationPolicy only** | Empty = all namespaces | +| `rules[]` | list of `ValidationRule` | both | At least one required | +| `rules[].name` | string | both | 1–253 chars | +| `rules[].rego` | string (Rego module) | both | Must declare `package waf` and a `violations` rule; max 16384 chars | +| `rules[].severity` | `info` \| `warning` \| `critical` | both | Defaults to `warning` | + +--- + +## Rego validation + +A validation policy carries [Rego](https://www.openpolicyagent.org/docs/latest/policy-language/) rules evaluated after the configuration is merged — so a rule can reason about the final action, paranoia level, and merged directive list, regardless of which policy or plugin contributed them. + +### The input document + +Every rule evaluates against the same `input` document — the merged configuration and its provenance: + +| Path | Type | Meaning | +| :--- | :--- | :--- | +| `input.config.action` | string | Effective action after merge — `Detect` or `Block` | +| `input.config.crsState` | string | Core Rule Set state — `Enabled` or `Disabled` | +| `input.config.paranoiaLevel` | integer | Effective paranoia level, 1–4 | +| `input.directives` | array of string | The complete, ordered list of merged ModSecurity directives | +| `input.source.globalPolicy` | string | Name of the global policy applied (always `default`) | +| `input.source.namespacePolicy` | string | Name of the `WAFPolicy`, if any | +| `input.source.globalPlugins` | array of string | Global plugin names, in load order | +| `input.source.namespacePlugins` | array of string | Namespace plugin names, in load order | + +### The violations contract + +A rule's `rego` field must declare `package waf` and a `violations` rule producing objects with at least a `msg` field. No violations means compliant; each object is a finding, surfaced on status, with any extra fields passed through. + +```rego +package waf # required — the engine evaluates this package + +# Each block that adds to `violations` is one check. +violations[{"msg": msg}] { # a violation is an object with a msg field + input.config.crsState != "Enabled" # the condition that is NOT allowed + msg := "OWASP CRS must be enabled" +} + +violations[{"msg": msg}] { + input.config.paranoiaLevel < 2 # body lines are ANDed; all must hold + msg := sprintf("paranoia level %d is below the minimum of 2", [input.config.paranoiaLevel]) +} +``` + +Or inspect the merged directive list directly — useful for catching a plugin that removes a baseline rule: + +```rego +package waf + +violations[{"msg": msg}] { + d := input.directives[_] # iterate the merged directives + startswith(d, "SecRuleRemoveById") + re_match(`94[0-9]{4}`, d) # a CRS SQL-injection rule (942xxx) + msg := sprintf("removes a baseline SQLi rule: %s", [d]) +} +``` + +### Worked examples + +How different merged configurations evaluate against the rules above: + +| Effective configuration | `violations` | +| :--- | :--- | +| `crsState: Enabled`, `paranoiaLevel: 2` | empty — compliant | +| `crsState: Disabled` | `"OWASP CRS must be enabled"` | +| `paranoiaLevel: 1` | `"paranoia level 1 is below the minimum of 2"` | +| a plugin with `SecRuleRemoveById 942100` | `"removes a baseline SQLi rule: ..."` | + +### Severity + +Each rule has a `severity` — `info`, `warning`, `critical` (default `warning`) — deciding the consequence, not whether it fires: + +- In `Enforce` mode, a `critical` violation blocks the configuration from being programmed. +- In `Audit` mode — and in any `WAFValidationPolicy` — violations are reported on status but never block. + +See the enforcement table in [How it works](./overview.mdx#validation-and-enforcement) for the full matrix. + +--- + +## SecRule ID ranges + +Every custom rule in a `WAFPlugin` carries a numeric ModSecurity ID. Gateway WAF allocates each namespace a fixed ID range and blocks any namespace from disabling baseline rules — both enforced by the admission webhook. + +### How a namespace's range is derived + +Each namespace is allocated a contiguous block of 10,000 IDs, derived deterministically from the namespace name: + +```text +prefix = first four hex digits of sha256(namespace name), modulo 10000 +range = [ prefix * 10000 , prefix * 10000 + 9999 ] +``` + +The range is a pure function of the namespace name — it never changes and needs no cluster state. It isn't a globally unique reservation: two namespaces can map to the same block, harmlessly, since each runs its own WAF engine and neither can touch the protected ranges below. + +### Finding your range + +The first time a namespace references a plugin, the controller stamps the range onto it as an annotation: + +```bash +kubectl get namespace payments \ + -o jsonpath='{.metadata.annotations.applicationlayer\.projectcalico\.org/waf-id-range}' +``` + +Pick the IDs for your `WAFPlugin` rules from inside this range. + +### Protected ranges + +Some IDs are reserved and rejected for every namespace: + +- **`900000`–`999999`** — the OWASP Core Rule Set baseline. +- **Tigera-shipped baseline rules** — the rules that implement the managed security baseline. + +A namespace can neither define rules with these IDs nor remove them with `SecRuleRemoveById` — stopping a tenant from quietly disabling cluster protection. + +### What the admission webhook rejects + +Applying a `WAFPlugin`/`WAFPolicy` triggers the webhook to parse every directive; failing any check below rejects the resource, with the reason on its `Accepted` condition. + +| Check | Rejected with | Meaning | +| :--- | :--- | :--- | +| Directive not on the allowlist | `UnsafeDirective` | The directive type isn't permitted (for example, one that reads files or fetches remote rules) | +| Rule ID outside your range | `IDOutsideRange` | A `SecRule`/`SecAction` ID falls outside the namespace's allocated range | +| Removing a protected rule | `ProtectedIDRemoval` | A `SecRuleRemoveById` targets an ID outside the namespace's range (such as a Core Rule Set baseline rule) | + +The controller re-runs these checks at reconcile as a backstop, marking `Accepted=False` with the matching reason instead of programming. + +### Allowed directives + +Allowed: `SecRule`, `SecAction`, `SecDefaultAction`, `SecRuleRemoveById`, `SecRuleRemoveByTag`, `SecRuleUpdateActionById`, `SecRuleUpdateTargetById`, `SecMarker`, and request/response body and argument limits. Denied: directives that read the filesystem, write audit logs to arbitrary paths, or fetch remote rules. + +### Example + +A namespace whose range is `420000`–`429999` writes rules using IDs from that block: + +```yaml +apiVersion: applicationlayer.projectcalico.org/v3 +kind: WAFPlugin +metadata: + name: api-exceptions + namespace: payments +spec: + rules: | + SecRule REQUEST_BODY "@rx __schema" \ + "id:420020,phase:2,deny,status:403,msg:'Introspection blocked'" +``` + +--- + +## Status conditions + +Gateway WAF reports policy state through standard Kubernetes status conditions: + +```bash +kubectl describe wafpolicy payments-waf -n payments +# or +kubectl get wafpolicy payments-waf -n payments -o jsonpath='{.status.conditions}' | jq +``` + +A healthy policy has `Licensed=True`, `Accepted=True`, `Validated=True`, `Programmed=True`, `Ready=True`, evaluated roughly in that order — an early failure explains why later conditions are absent or `False`. + +### Licensed + +Gateway WAF is a $[prodname] feature and requires a valid license. + +| Reason | Meaning | +| :--- | :--- | +| `LicenseValid` | The license entitles this feature | +| `LicenseRequired` | No valid entitlement; the policy is not programmed | + +### Accepted + +Whether the resource is structurally valid and admitted. Several reasons come straight from the [admission webhook](#what-the-admission-webhook-rejects). + +| Reason | Meaning | +| :--- | :--- | +| `Accepted` | The resource is valid and admitted | +| `Invalid` | The spec or a directive is invalid (the message says which) | +| `ValidationFailed` | A `GlobalWAFValidationPolicy` in `Enforce` mode reported a `critical` violation | +| `Conflicted` | Another policy already targets this route; the older one wins (the message names it) | +| `PluginNotFound` | A referenced `WAFPlugin`/`GlobalWAFPlugin` does not exist | +| `UnsafeDirective` | A directive is not on the allowlist | +| `IDOutsideRange` | A rule ID is outside the namespace's allocated range | +| `ProtectedIDRemoval` | A `SecRuleRemoveById` targets a protected baseline rule | + +### Validated + +The result of evaluating [validation policies](#rego-validation) against the merged configuration. + +| Reason | Meaning | +| :--- | :--- | +| `Compliant` | No violations | +| `ViolationsFound` | One or more rules reported a violation (see `status.validation.failures`) | +| `NotEvaluated` | No validation policy applies to this resource | + +### Programmed + +Whether the Coraza WASM filter was programmed into Envoy Gateway. + +| Reason | Meaning | +| :--- | :--- | +| `ConfigurationApplied` | The `EnvoyExtensionPolicy` was generated and the filter is active | +| `TargetNotFound` | No valid `targetRefs`, or a referenced `Gateway`/`HTTPRoute` does not exist | +| `RenderTooLarge` | The merged ruleset exceeds the render size limit | +| `WASMUnavailable` | The Coraza WASM image could not be pulled | +| `NotAttempted` | A `critical` violation under `Enforce` blocked programming | + +### Ready + +A roll-up of the conditions above. + +| Reason | Meaning | +| :--- | :--- | +| `Ready` | The policy is active on the data plane | +| `NotReady` | One of the conditions above is not satisfied | + +### Validation summary + +`WAFPolicy.status.validation` summarizes validation without reading individual conditions: + +| Field | Meaning | +| :--- | :--- | +| `status` | `Valid`, `Audited`, or `Rejected` | +| `securityPosture` | `Compliant`, `Warning`, `Degraded`, or `Critical` | +| `failures[]` | Per-rule failures — policy name, rule, severity, and message | +| `lastEvaluated` | When validation last ran | + +A `GlobalWAFValidationPolicy`'s own status aggregates violating namespaces cluster-wide — one place to see who's out of compliance. diff --git a/calico-enterprise/threat/gateway-waf/set-a-baseline.mdx b/calico-enterprise/threat/gateway-waf/set-a-baseline.mdx new file mode 100644 index 0000000000..986410a075 --- /dev/null +++ b/calico-enterprise/threat/gateway-waf/set-a-baseline.mdx @@ -0,0 +1,112 @@ +--- +description: Set the cluster-wide WAF baseline every namespace inherits — action mode, Core Rule Set state, and paranoia level. +--- + +# Set a cluster-wide baseline for Gateway WAF + +:::note + +This feature is tech preview. Tech preview features may be subject to significant changes before they become GA. + +::: + +The baseline is the cluster-wide default every namespace inherits — action mode, Core Rule Set, and paranoia level. Set it once so every Gateway starts protected and teams tune from a known-safe starting point. + +## Prerequisites + +- Gateway WAF is enabled. See [Enable Gateway WAF](./enable.mdx). +- Cluster-admin rights — `GlobalWAFPolicy` is cluster-scoped. + +## Set the baseline + +1. Create the singleton `GlobalWAFPolicy`. It must be named `default` — other names are rejected by the admission webhook. Start in `Detect` mode: it logs matches without blocking, so you can measure false positives before switching to `Block`: + + ```yaml + apiVersion: applicationlayer.projectcalico.org/v3 + kind: GlobalWAFPolicy + metadata: + name: default + spec: + defaultAction: Detect # start here; promote to Block after validating false-positive rate + coreRuleSet: + state: Enabled + paranoiaLevel: 1 + ``` + + Apply it with `kubectl apply -f globalwafpolicy.yaml`. + +1. Pick a paranoia level. Higher catches more attacks at the cost of more false positives: + + | Level | Posture | Typical use | + | :--- | :--- | :--- | + | 1 | Balanced — low false-positive rate | General-purpose default; a safe starting point | + | 2 | Moderate — catches common evasion patterns | APIs that handle sensitive data | + | 3 | Strict — flags most suspicious patterns | High-value or compliance-regulated endpoints | + | 4 | Paranoid — near-zero tolerance | Extremely high-security targets; expect false positives | + + :::tip + + Start at level 1 cluster-wide; let application operators raise it per namespace, and enforce a minimum later via a validation policy. + + ::: + +1. Once you've validated the false-positive rate, set `defaultAction: Block`. $[prodname] then returns HTTP 403 for matching requests. + +## Add organization-wide rules with a GlobalWAFPlugin (optional) + +A `GlobalWAFPlugin` holds custom ModSecurity rules applied cluster-wide via the global policy's `plugins[]` list — for a security baseline every namespace inherits. + +1. Create the `GlobalWAFPlugin`. Rules use the four lifecycle slots described in [Write custom rules](./write-custom-rules.mdx): + + ```yaml + apiVersion: applicationlayer.projectcalico.org/v3 + kind: GlobalWAFPlugin + metadata: + name: org-baseline + spec: + description: "Organization-wide security baseline rules" + config: | + SecAction "id:800001,phase:1,pass,nolog,setvar:tx.enforce_https=1" + before: | + SecRule REQUEST_HEADERS:X-Forwarded-Proto "!@streq https" \ + "id:800002,phase:1,deny,status:403,msg:'HTTPS required',chain" + SecRule TX:ENFORCE_HTTPS "@eq 1" "" + rules: | + SecRule REQUEST_HEADERS:X-Request-ID "@rx ^$" \ + "id:800003,phase:1,deny,status:400,msg:'Missing X-Request-ID header'" + after: | + # Cluster operator's after slot runs last in the merge order. + SecRule TX:BLOCKING_ANOMALY_SCORE "@gt 0" \ + "id:800099,phase:5,pass,log,msg:'Request blocked by WAF'" + ``` + +1. Reference it from `GlobalWAFPolicy.spec.plugins[]` — for example `plugins: [{kind: GlobalWAFPlugin, name: org-baseline}]`. `plugins[]` is ordered by array position, up to 64 entries; `WAFPlugin` entries can be added here too. + +## Verify the baseline + +1. Confirm the policy is accepted: + + ```bash + kubectl get globalwafpolicy default + ``` + + ``` + NAME ACCEPTED AGE + default True 30s + ``` + +Detail lives in `.status.conditions` (`kubectl get globalwafpolicy default -o yaml`): `Accepted=True` reason `Accepted` = programmed; `Accepted=False` reason `ValidationFailed` = a validation policy blocked it (the message describes the violation). See [Status conditions](./reference.mdx#status-conditions). + +Every namespace with a `WAFPolicy` inherits `defaultAction`, `coreRuleSet.state`, and `coreRuleSet.paranoiaLevel` from `GlobalWAFPolicy` unless it overrides them: + +``` +GlobalWAFPolicy (baseline) + └─ WAFPolicy in namespace payments → overrides action: Block, paranoiaLevel: 2 + └─ WAFPolicy in namespace team-a → inherits everything from GlobalWAFPolicy +``` + +## Next steps + +- [Attach WAF to a Gateway or route](./attach-waf-to-a-route.mdx) — turn on inspection for a target. +- [Write custom rules with plugins](./write-custom-rules.mdx) — add app-specific detections and exceptions. +- [Validate configuration with Rego](./validate-config.mdx) — enforce compliance guardrails on the merged config. diff --git a/calico-enterprise/threat/gateway-waf/troubleshooting.mdx b/calico-enterprise/threat/gateway-waf/troubleshooting.mdx new file mode 100644 index 0000000000..93661f150c --- /dev/null +++ b/calico-enterprise/threat/gateway-waf/troubleshooting.mdx @@ -0,0 +1,199 @@ +--- +description: Diagnose and fix common Gateway WAF problems by reading policy status conditions and working through symptom-to-fix guidance for each failure mode. +--- + +# Troubleshooting + +:::note + +This feature is tech preview. Tech preview features may be subject to significant changes before they become GA. + +::: + +Most problems surface on the policy's status conditions: + +```bash +kubectl describe wafpolicy -n +``` + +A healthy policy has `Licensed=True`, `Accepted=True`, `Validated=True`, `Programmed=True`, `Ready=True` — evaluated roughly in order, so an early failure explains later absences. See [Status conditions](./reference.mdx#status-conditions) for reason meanings. + +## Observing WAF activity + +WAF hits (both `Block` and `Detect`/`would_block`) are recorded in the `tigera_secure_ee_waf` log index, carrying `gateway_name`/`gateway_namespace` for the originating Gateway. On this release they are **not** raised as Security Events: the intrusion-detection controller excludes WAF logs that carry a gateway name, so gateway-WAF activity is observed through the WAF log index rather than the Security Events view. Re-surfacing gateway WAF as Security Events (or on a dedicated dashboard) is planned for a later release. + +Raw Envoy proxy logs are a last-resort diagnostic when a hit is missing from the index. + +--- + +## `Accepted=False, Reason=Conflicted` + +**Cause.** Two `WAFPolicy` resources in the same namespace target the same Gateway/HTTPRoute. Per [GEP-713](https://gateway-api.sigs.k8s.io/geps/gep-713/), oldest `creationTimestamp` wins; the newer is marked `Conflicted`, directives never merged. + +**Fix.** Identify the competing policies: + +```bash +kubectl get wafpolicy -n -o wide +``` + +Delete one, or repoint its `targetRefs` — see [How it works](./overview.mdx#merge-order-and-enforcement) for why only one policy owns a target. + +--- + +## `Accepted=False, Reason=UnsafeDirective | IDOutsideRange | ProtectedIDRemoval` + +**Cause.** The admission webhook rejected the SecLang in a `WAFPlugin` or `WAFPolicy` at apply time, before the resource reached the controller. + +| Reason | Meaning | +| :--- | :--- | +| `UnsafeDirective` | The directive type is not on the allowlist (for example, a directive that reads from the filesystem) | +| `IDOutsideRange` | A `SecRule` or `SecAction` ID falls outside the namespace's allocated 10,000-ID range | +| `ProtectedIDRemoval` | A `SecRuleRemoveById` targets a protected ID — a Core Rule Set baseline rule or a Tigera-managed rule | + +**Fix.** Read the rejection message on `status.conditions`: + +```bash +kubectl get wafpolicy -n \ + -o jsonpath='{.status.conditions[?(@.type=="Accepted")].message}' +``` + +For `IDOutsideRange`/`ProtectedIDRemoval`: use only IDs from your namespace's range: + +```bash +kubectl get namespace \ + -o jsonpath='{.metadata.annotations.applicationlayer\.projectcalico\.org/waf-id-range}' +``` + +For `UnsafeDirective`: remove or replace the directive. See [SecRule ID ranges](./reference.mdx#secrule-id-ranges) for the allowlist. + +--- + +## `Programmed=False, Reason=WASMUnavailable` + +**Cause.** The Envoy proxy couldn't pull the Coraza WASM image — usually a registry issue: an unreachable reference, or a missing pull secret. + +**Diagnostic.** Check the `EnvoyExtensionPolicy` status the controller generated: + +```bash +kubectl get envoyextensionpolicy \ + -n \ + -l applicationlayer.projectcalico.org/waf-policy= \ + -o jsonpath='{.items[*].status}' | jq +``` + +Check Envoy proxy logs for `could not fetch Wasm OCI image`: + +```bash +kubectl logs -n \ + -l gateway.envoyproxy.io/owning-gateway-namespace= \ + -c envoy | grep -i wasm +``` + +**Fix.** The operator manages the image reference. In a private registry, wire in the pull secret via operator configuration, and confirm the node can reach the registry with the secret attached to the Envoy proxy pods. + +--- + +## `Programmed=False, Reason=NotAttempted` + +**Cause.** A `GlobalWAFValidationPolicy` in `Enforce` mode found a `critical` violation, so the controller never programmed the Coraza filter. + +**Diagnostic.** Read the validation failures: + +```bash +kubectl get wafpolicy -n \ + -o jsonpath='{.status.validation}' | jq +``` + +`failures[]` names the failed rule, with severity and message; `status.validation.status` is `Rejected`. + +**Fix.** Satisfy the failing rule — e.g. restore `action: Block` if the policy requires it — or ask the cluster operator for an exception. See [Validate configuration with Rego](./validate-config.mdx) and [Status conditions](./reference.mdx#status-conditions). + +--- + +## `Accepted=False, Reason=TargetNotFound` + +**Cause.** `targetRefs` references a `Gateway`/`HTTPRoute` that doesn't exist in the namespace. + +**Fix.** Verify the target exists: + +```bash +kubectl get gateway,httproute -n +``` + +Check `name`, `kind`, `group` in `targetRefs` match the resource, then create it and re-apply, or fix the reference — reconciliation is automatic once it exists. + +--- + +## Attacks not blocked + +**Cause.** One or more of: + +- `action` is `Detect` (explicit or inherited from `GlobalWAFPolicy`) — inspected, not blocked. +- `coreRuleSet.state` is `Disabled` — Core Rule Set isn't loaded. +- `coreRuleSet.paranoiaLevel` is below the triggering rule's paranoia floor — the rule doesn't evaluate. + +**Diagnostic.** Check the policy's conditions and spec: + +```bash +kubectl describe wafpolicy -n +``` + +Confirm action is `Block` and Core Rule Set `Enabled`. If paranoia looks right but the attack still passes, check whether that rule's floor is higher than configured — some only activate at paranoia 2 or 3. + +:::tip + +`kubectl waf` (coming in a future release) will render the full merged directive list so you can inspect the effective `SecRuleEngine` line and paranoia settings directly. + +::: + +Also check the `GlobalWAFPolicy` default action, inherited when a namespace `action` is unset: + +```bash +kubectl get globalwafpolicy default -o jsonpath='{.spec.defaultAction}' +``` + +See [How it works](./overview.mdx#inheritance-namespaces-override-they-dont-restart) for field inheritance rules. + +--- + +## `403` on every request / false positives + +**Cause.** The paranoia level is too high for the traffic pattern, or a specific Core Rule Set rule is matching legitimate requests (a false positive). + +**Diagnostic.** The firing rule ID and message are in the `tigera_secure_ee_waf` log index for the hit. As a last resort, the Envoy proxy access logs in the Gateway pod also carry them: + +```bash +kubectl logs -n \ + -l gateway.envoyproxy.io/owning-gateway-namespace= \ + -c envoy | grep -i "waf\|403" | tail -30 +``` + +**Fix.** Two options: + +1. **Lower the paranoia level**, if false positives come from a broad class of higher-paranoia rules and your threat model allows it. +2. **Add a plugin exception** — suppress via `SecRuleRemoveById`, or skip a known-safe target via `SecRuleUpdateTargetById`. Preferred for surgical tuning without weakening the overall posture. + +For how to write exception rules within your namespace's ID range, see [Write custom rules with plugins](./write-custom-rules.mdx). + +:::caution + +`SecRuleRemoveById` can only target rule IDs within your namespace's allocated range. You cannot remove Core Rule Set baseline rules (IDs `900000`–`999999`) or Tigera-managed rules — the admission webhook will reject the plugin with `ProtectedIDRemoval`. + +::: + +--- + +## General diagnostic sequence + +If no specific symptom matches above, walk the condition chain in order: + +```bash +kubectl get wafpolicy -n \ + -o jsonpath='{range .status.conditions[*]}{.type}={.status} ({.reason}): {.message}{"\n"}{end}' +``` + +1. `Licensed=False` → license missing or doesn't entitle Gateway WAF. Check the [Enable Gateway WAF](./enable.mdx#prerequisites) prerequisites. +2. `Accepted=False` → spec rejected. Read the `Reason`/`message` — see the sections above. +3. `Validated=False` → a validation rule fired. Read `status.validation.failures`. +4. `Programmed=False` → filter not loaded into Envoy. Read the `Reason` — see `WASMUnavailable`/`NotAttempted` above. +5. All conditions `True` but not blocking → give Envoy a few seconds to load the WASM module after `EnvoyExtensionPolicy` creation; if it still doesn't block, the effective action is likely `Detect` — see [Attacks not blocked](#attacks-not-blocked) above. diff --git a/calico-enterprise/threat/gateway-waf/tutorial.mdx b/calico-enterprise/threat/gateway-waf/tutorial.mdx new file mode 100644 index 0000000000..2029d1c8e7 --- /dev/null +++ b/calico-enterprise/threat/gateway-waf/tutorial.mdx @@ -0,0 +1,227 @@ +--- +description: Deploy a test app behind Calico Ingress Gateway, set a WAF baseline, attach it to the Gateway, and confirm a SQL injection and XSS attack are both blocked. +--- + +# Tutorial + +:::note + +This feature is tech preview. Tech preview features may be subject to significant changes before they become GA. + +::: + +This tutorial takes you from a running Ingress Gateway to a blocked attack in about five minutes. + +## Prerequisites + +- Gateway WAF is enabled. See [Enable Gateway WAF](./enable.mdx). +- $[prodname] Ingress Gateway is set up. See [About Calico Ingress Gateway](../../networking/ingress-gateway/about-calico-ingress-gateway.mdx). +- A namespace where you can deploy a test app — this tutorial creates one called `httpbin`. + +## Block an attack in five minutes + +1. Deploy a test application. Create an `httpbin` namespace with a deployment and service to act as the backend: + + ```yaml + apiVersion: v1 + kind: Namespace + metadata: + name: httpbin + --- + apiVersion: apps/v1 + kind: Deployment + metadata: + name: httpbin + namespace: httpbin + spec: + replicas: 1 + selector: + matchLabels: + app: httpbin + template: + metadata: + labels: + app: httpbin + spec: + containers: + - name: httpbin + image: kong/httpbin:0.1.0 + ports: + - containerPort: 80 + --- + apiVersion: v1 + kind: Service + metadata: + name: httpbin + namespace: httpbin + spec: + selector: + app: httpbin + ports: + - protocol: TCP + port: 80 + targetPort: 80 + ``` + + Apply it, then wait for the pod to be ready: + + ```bash + kubectl wait --for=condition=Ready pod -l app=httpbin -n httpbin --timeout=60s + ``` + +1. Create a Gateway and HTTPRoute: + + ```yaml + apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + name: httpbin-gateway + namespace: httpbin + spec: + gatewayClassName: tigera-gateway-class + listeners: + - name: http + protocol: HTTP + port: 9080 + --- + apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httpbin-route + namespace: httpbin + spec: + parentRefs: + - name: httpbin-gateway + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: httpbin + port: 80 + ``` + + :::note + + Use port 9080, not 8080. Port 8080 is reserved by the Calico L7 log collector inside the Envoy proxy pod. + + ::: + + Apply it, then wait for the Gateway to be accepted: + + ```bash + kubectl wait --for=condition=Accepted gateway/httpbin-gateway -n httpbin --timeout=60s + ``` + +1. Confirm traffic flows. Find the Envoy service the Ingress Gateway created for this Gateway (the service lands in the Gateway's own namespace, `httpbin`): + + ```bash + export GATEWAY_SVC=$(kubectl get svc -n httpbin \ + -o jsonpath='{.items[?(@.metadata.labels.gateway\.envoyproxy\.io/owning-gateway-name=="httpbin-gateway")].metadata.name}') + ``` + + Send a test request from inside the cluster: + + ```bash + kubectl run curl-test -n httpbin --rm -it --restart=Never --image=curlimages/curl -- \ + curl -s -o /dev/null -w "%{http_code}" \ + http://$GATEWAY_SVC.httpbin.svc.cluster.local:9080/get + ``` + + ```text + 200 + ``` + + Traffic is flowing through the Gateway with no WAF protection yet. + +1. Apply a `GlobalWAFPolicy` to set the cluster-wide baseline. Create the singleton `default` policy with the OWASP Core Rule Set enabled in `Block` mode: + + :::tip + + This tutorial uses `Block` mode immediately so you can see blocking in action. Most production rollouts start in `Detect` mode to baseline traffic before switching. See [Merge order and enforcement](./overview.mdx#merge-order-and-enforcement) for the tradeoffs. + + ::: + + ```yaml + apiVersion: applicationlayer.projectcalico.org/v3 + kind: GlobalWAFPolicy + metadata: + name: default + spec: + defaultAction: Block + coreRuleSet: + state: Enabled + paranoiaLevel: 1 + ``` + + This defines the global posture. It does not attach WAF to any traffic yet — that requires a namespace-scoped `WAFPolicy` with `targetRefs`. + +1. Attach WAF to the Gateway. Create a `WAFPolicy` in the `httpbin` namespace targeting the Gateway: + + ```yaml + apiVersion: applicationlayer.projectcalico.org/v3 + kind: WAFPolicy + metadata: + name: httpbin-waf + namespace: httpbin + spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: httpbin-gateway + ``` + + The reconciler merges the `GlobalWAFPolicy` baseline with this `WAFPolicy`, generates an ordered ModSecurity ruleset, and programs it into Envoy Gateway as an `EnvoyExtensionPolicy`. Check that the policy was accepted and programmed: + + ```bash + kubectl get wafpolicy httpbin-waf -n httpbin \ + -o jsonpath='{range .status.conditions[*]}{.type}={.status} ({.reason}){"\n"}{end}' + ``` + + ```text + Licensed=True (LicenseValid) + Accepted=True (Accepted) + Validated=Unknown (NotEvaluated) + Programmed=True (ConfigurationApplied) + Ready=True (Ready) + ``` + + All four executable conditions (`Licensed`, `Accepted`, `Programmed`, `Ready`) must be `True` before WAF is active. `Validated=Unknown (NotEvaluated)` is expected when no `WAFValidationPolicy` is in scope. If `Programmed=False`, check the condition message — a common cause is the WASM image not being reachable (see [Troubleshooting](./troubleshooting.mdx)). Allow a few seconds for Envoy to load the WASM filter before running the tests below. + +1. Test WAF blocking. A normal request still passes, but a SQL injection probe and an XSS probe are both blocked: + + ```bash + # Normal request — expect 200 + kubectl run curl-ok -n httpbin --rm -it --restart=Never --image=curlimages/curl -- \ + curl -s -o /dev/null -w "%{http_code}" \ + http://$GATEWAY_SVC.httpbin.svc.cluster.local:9080/get + + # SQL injection probe — expect 403 + kubectl run curl-sqli -n httpbin --rm -it --restart=Never --image=curlimages/curl -- \ + curl -s -o /dev/null -w "%{http_code}" \ + "http://$GATEWAY_SVC.httpbin.svc.cluster.local:9080/get?id=1%27%20OR%20%271%27%3D%271" + + # XSS probe — expect 403 + kubectl run curl-xss -n httpbin --rm -it --restart=Never --image=curlimages/curl -- \ + curl -s -o /dev/null -w "%{http_code}" \ + "http://$GATEWAY_SVC.httpbin.svc.cluster.local:9080/get?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E" + ``` + + The Coraza WASM filter, running inside Envoy, evaluated each request against the OWASP Core Rule Set. Core Rule Set rule group 942xxx detects SQL injection; group 941xxx detects cross-site scripting. Both attacks are blocked before the request reaches the application. + +## Clean up + +```bash +kubectl delete namespace httpbin +kubectl delete globalwafpolicies default +``` + +## Troubleshoot + +If a `WAFPolicy` you attach to a route later shows `Programmed=False` with reason `WASMUnavailable`, the Coraza WASM image could not be pulled. Private registries require a pull secret to be configured on the `GatewayAPI` CR. See [Troubleshooting](./troubleshooting.mdx) for diagnosis steps and the pull-secret configuration. + +## Next steps + +- [Set a cluster-wide baseline for Gateway WAF](./set-a-baseline.mdx) — start production configuration in `Detect` mode. +- [Reference](./reference.mdx) — CRDs, Rego validation, SecRule ID ranges, and status conditions. diff --git a/calico-enterprise/threat/gateway-waf/validate-config.mdx b/calico-enterprise/threat/gateway-waf/validate-config.mdx new file mode 100644 index 0000000000..cfe79d08bd --- /dev/null +++ b/calico-enterprise/threat/gateway-waf/validate-config.mdx @@ -0,0 +1,127 @@ +--- +description: Write Rego validation rules with GlobalWAFValidationPolicy and WAFValidationPolicy to enforce or audit compliance guardrails on the merged WAF configuration. +--- + +# Validate configuration with Rego + +:::note + +This feature is tech preview. Tech preview features may be subject to significant changes before they become GA. + +::: + +Gateway WAF has two guardrail layers: the admission webhook checks raw SecLang at apply time (structural safety); validation policies check the **merged result** at reconcile time, catching problems only visible once everything's merged. See [Validation and enforcement](./overview.mdx#validation-and-enforcement) for the matrix and [Rego validation](./reference.mdx#rego-validation) for the input schema. + +## Prerequisites + +- Gateway WAF is enabled. See [Enable Gateway WAF](./enable.mdx). +- Cluster-admin rights to create a `GlobalWAFValidationPolicy` (it's cluster-scoped). A `WAFValidationPolicy` only needs namespace write access. +- Rego rules evaluate against the merged config — see [Rego validation](./reference.mdx#rego-validation) for the input schema and worked examples. + +## Enforce guardrails cluster-wide with GlobalWAFValidationPolicy + +`GlobalWAFValidationPolicy` is cluster-scoped, applying to every namespace or a label-selected subset via `namespaceSelector`. It carries Rego rules and an `enforcementMode`: `Audit` (default) records violations while the filter stays programmed; `Enforce` blocks programming on a `critical` violation. `warning`/`info` violations are always recorded. + +1. Write the policy with Rego rules and an `enforcementMode`. This one requires the Core Rule Set enabled and `Block` mode for namespaces labeled `env: production`: + + ```yaml + apiVersion: applicationlayer.projectcalico.org/v3 + kind: GlobalWAFValidationPolicy + metadata: + name: production-requirements + spec: + enforcementMode: Enforce + namespaceSelector: + matchLabels: + env: production + rules: + + # critical: CRS must be enabled + - name: crs-must-be-enabled + severity: critical + rego: | + package waf + violations[{"msg": "OWASP CRS must be enabled"}] { + input.config.crsState != "Enabled" + } + + # critical: production namespaces must use Block mode + - name: block-mode-required + severity: critical + rego: | + package waf + violations[{"msg": "WAF must be in Block mode for production"}] { + input.config.action != "Block" + } + ``` + + For more rule patterns — a minimum paranoia level, or inspecting the merged directive list to catch a plugin removing a baseline rule — see [Rego validation](./reference.mdx#rego-validation). + +1. Start in `Audit` mode, review violations on existing `WAFPolicy` status, then switch to `Enforce` — so you don't block teams whose config predates the policy. + + Leave `namespaceSelector` empty for cluster-wide, or scope with `matchLabels`. Multiple policies can coexist — every matching rule is evaluated, and one `critical` violation (in `Enforce`) blocks programming. + +## Self-check your config with WAFValidationPolicy + +`WAFValidationPolicy` is namespace-scoped and advisory only — violations appear on status but never block programming. Use it as a self-check. + +1. Write a `WAFValidationPolicy` in your namespace: + + ```yaml + apiVersion: applicationlayer.projectcalico.org/v3 + kind: WAFValidationPolicy + metadata: + name: team-self-check + namespace: platform + spec: + rules: + + - name: graphql-plugin-active + severity: warning + rego: | + package waf + violations[{"msg": "graphql-waf plugin is not loaded — introspection unguarded"}] { + not "graphql-waf" in input.source.namespacePlugins + } + ``` + + Being advisory, it's safe to hold yourself to a stricter standard than the baseline — e.g. a higher minimum paranoia level. Violations surface on `WAFPolicy` status without interrupting traffic. + +## Read violation results + +Violations from matching policies appear in the affected `WAFPolicy`'s status conditions. + +1. Check the policy status: + + ```bash + kubectl get wafpolicy -n -o yaml + ``` + + A blocked policy shows the failing rule: + + ```yaml + status: + conditions: + - type: Ready + status: "False" + reason: ValidationFailed + message: > + GlobalWAFValidationPolicy/production-requirements rule "block-mode-required": + WAF must be in Block mode for production + - type: Accepted + status: "True" + ``` + +An audit violation instead leaves `Ready=True` and adds a `ValidationWarning=True` condition, reason `AuditViolation`, with a message like `rce-rules-intact" [warning]: RCE rule removal detected in merged config: SecRuleRemoveById 932100` (the filter stays programmed). See [Status conditions](./reference.mdx#status-conditions) for the full reference. + +:::tip + +Write Rego against the schema in [Rego validation](./reference.mdx#rego-validation); test locally with `opa eval` first. A syntax error invalidates the whole policy — status reports which rule failed to parse. + +::: + +## Next steps + +- [Multi-tenant setup](./multi-tenant-setup.mdx) — baseline, plugins, and guardrails working together on a shared cluster. +- [Reference](./reference.mdx) — the Rego input schema, worked examples, and status conditions. +- [Troubleshooting](./troubleshooting.mdx) — diagnose a policy blocked by validation. diff --git a/calico-enterprise/threat/gateway-waf/write-custom-rules.mdx b/calico-enterprise/threat/gateway-waf/write-custom-rules.mdx new file mode 100644 index 0000000000..65a86b7a57 --- /dev/null +++ b/calico-enterprise/threat/gateway-waf/write-custom-rules.mdx @@ -0,0 +1,139 @@ +--- +description: Write custom ModSecurity rules in a WAFPlugin or GlobalWAFPlugin, using the four lifecycle slots and your namespace's SecRule ID range. +--- + +# Write custom rules with plugins + +:::note + +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](https://github.com/owasp-modsecurity/ModSecurity/wiki/Reference-Manual-(v3.x)) 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](./overview.mdx#plugins--custom-rules). + +:::caution + +`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. + +::: + +:::note + +`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](./enable.mdx). +- You have write access to `WAFPlugin` in your namespace (or `GlobalWAFPlugin` at cluster scope). +- A `WAFPolicy` or `GlobalWAFPolicy` exists to reference the plugin from — a plugin does nothing until a policy loads it. See [Attach WAF to a Gateway or route](./attach-waf-to-a-route.mdx). + +## Write a plugin + +1. Find your namespace's SecRule ID range. Every `SecRule`/`SecAction` needs an ID, and each namespace gets a 10,000-ID block enforced at admission: + + ```bash + kubectl get namespace \ + -o jsonpath='{.metadata.annotations.applicationlayer\.projectcalico\.org/waf-id-range}' + # example output: 420000-429999 + ``` + + The range is populated the first time a plugin is referenced in your namespace. See [SecRule ID ranges](./reference.mdx#secrule-id-ranges) for protected ranges and the allowlist. + +1. 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. The `4200xx` IDs below assume the `420000-429999` example range from the previous step — swap in IDs from your own namespace's range, or the admission webhook rejects the plugin with `IDOutsideRange`: + + ```yaml + apiVersion: applicationlayer.projectcalico.org/v3 + kind: WAFPlugin + metadata: + name: graphql-waf + namespace: platform + spec: + 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 941130 + ``` + + :::tip + + `@pmf` loads 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. + + ::: + +1. Reference the plugin from a `WAFPolicy`, in `spec.plugins[]` (load order = array position): + + ```yaml + apiVersion: applicationlayer.projectcalico.org/v3 + kind: WAFPolicy + metadata: + name: platform-api-waf + namespace: platform + spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: graphql-api + action: Block + plugins: + - kind: WAFPlugin + name: graphql-waf # ← your plugin above + ``` + + If the policy also references a `GlobalWAFPlugin`, its slots load before the namespace plugin's slots at each position. + + :::note + + Not 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](./reference.mdx#secrule-id-ranges) for the allowlist. + + ::: + +1. Confirm acceptance and reconciliation: + + ```bash + 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=True` on the `WAFPolicy` confirms the merged ruleset passed validation and the `EnvoyExtensionPolicy` was programmed. + +Gateway WAF runs the [OWASP Core Rule Set v4](https://coreruleset.org/docs/) via [Coraza](https://coraza.io/); the [ModSecurity reference manual](https://github.com/owasp-modsecurity/ModSecurity/wiki/Reference-Manual-(v3.x)) covers the full SecLang directive set. + +## Next steps + +- [Validate configuration with Rego](./validate-config.mdx) — enforce guardrails on the merged ruleset. +- [Reference](./reference.mdx) — SecRule ID ranges, allowed directives, and status conditions. +- [Troubleshooting](./troubleshooting.mdx) — diagnose a plugin that won't accept or program. diff --git a/sidebars-calico-enterprise.js b/sidebars-calico-enterprise.js index c091945751..83ea59dd77 100644 --- a/sidebars-calico-enterprise.js +++ b/sidebars-calico-enterprise.js @@ -475,6 +475,22 @@ module.exports = { 'threat/deeppacketinspection', 'threat/web-application-firewall', 'threat/deploying-waf-ingress-gateway', + { + type: 'category', + label: 'Gateway WAF', + items: [ + 'threat/gateway-waf/overview', + 'threat/gateway-waf/tutorial', + 'threat/gateway-waf/enable', + 'threat/gateway-waf/set-a-baseline', + 'threat/gateway-waf/attach-waf-to-a-route', + 'threat/gateway-waf/write-custom-rules', + 'threat/gateway-waf/validate-config', + 'threat/gateway-waf/multi-tenant-setup', + 'threat/gateway-waf/troubleshooting', + 'threat/gateway-waf/reference', + ], + }, ], }, { diff --git a/static/img/calico-enterprise/gateway-waf-architecture.svg b/static/img/calico-enterprise/gateway-waf-architecture.svg new file mode 100644 index 0000000000..135ebd9eac --- /dev/null +++ b/static/img/calico-enterprise/gateway-waf-architecture.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + Admission webhook + directive allowlist + + per-namespace SecRule ID range + + + + GlobalWAFPolicy + WAFPolicy + GlobalWAFPlugin + WAFPlugin + + + + Merge + + + + Ordered SecLang + directives + + + + Rego + validation + + + + EnvoyExtensionPolicy + Coraza WASM filter + + + + Envoy Gateway → request + + + + No filter programmed + status reports the violation + + + + + + + pass + + + + at apply + + + fail (Enforce + Critical) +