Skip to content

frostmoln_appgw_waf_rule (Resource) ​

Manages one rule in an Application Gateway WAF policy's draft.

Writing a rule changes nothing a request sees. It lands on the draft, and takes effect when a frostmoln_appgw_waf_policy_publication publishes it and the gateway applies its configuration.

rule_key is the rule's identity everywhere: it is this resource's id, the anchor a diff is keyed on, and what a report points at. Changing it replaces the rule.

This resource manages tenant-owned rules only. Platform rules cannot be created, edited or deleted; read them with the frostmoln_appgw_waf_platform_rules data source.

Authoritative scope — who owns what on this resource, declared in internal/scopedecl and machine-checked against the schema.

Enacted and reconciled — every configurable attribute not listed below: the platform applies it, and a refresh reads the truth back.

Create-immutable — gateway_id, policy_id, rule_key: the rule is identified by its key within its policy; changing one is a different rule.

Example Usage ​

terraform
# Rules land on the policy's DRAFT and change nothing a request sees until a
# frostmoln_appgw_waf_policy_publication publishes them.

# The structured surface. It is lowered to the engine's own rule language by the
# same renderer everything else goes through, so a dry-run cannot disagree with
# what is enforced.
resource "frostmoln_appgw_waf_rule" "block_scanners" {
  gateway_id  = frostmoln_application_gateway.edge.id
  policy_id   = frostmoln_appgw_waf_policy.main.id
  rule_key    = "block-known-scanners"
  kind        = "builder"
  description = "Refuse traffic identifying itself as a vulnerability scanner."

  builder_json = jsonencode({
    phase = 1
    conditions = [
      {
        variable        = "REQUEST_HEADERS"
        selector        = "User-Agent"
        operator        = "@rx"
        value           = "(?i)sqlmap|nikto|nessus"
        transformations = ["lowercase"]
      }
    ]
    action = {
      type    = "deny"
      status  = 403
      message = "known scanner"
    }
  })
}

# Restrict an endpoint by source address.
resource "frostmoln_appgw_waf_rule" "admin_office_only" {
  gateway_id  = frostmoln_application_gateway.edge.id
  policy_id   = frostmoln_appgw_waf_policy.main.id
  rule_key    = "admin-office-only"
  kind        = "builder"
  ordinal     = 10
  description = "/admin is reachable from the office range only."

  builder_json = jsonencode({
    phase = 1
    conditions = [
      {
        variable = "REQUEST_URI"
        operator = "@beginsWith"
        value    = "/admin"
      },
      {
        variable = "REMOTE_ADDR"
        operator = "@ipMatch"
        value    = "198.51.100.0/24"
        negated  = true
      }
    ]
    action = {
      type    = "deny"
      status  = 403
      message = "admin is office-only"
    }
  })
}

# Exempt traffic you know is safe from the anomaly-score decision — the one
# that refuses a request for accumulating enough suspicion, and the one that
# produces false positives. An uptime checker, a webhook sender, or a payload
# shape that looks like an attack and is not.
#
# THREE THINGS TO KNOW BEFORE YOU WRITE ONE.
#
#   EVERY CONDITION MUST MATCH. This rule exempts /health ONLY when the request
#   also carries the token — the two conditions are ANDed, exactly as they are
#   in a rule that denies. It is not "/health, for anyone".
#
#   IT EXEMPTS LESS THAN THE WORD SUGGESTS. The policy's allowed_methods and
#   allowed_request_content_types, a request body the gateway could not parse,
#   your own denying rules and any rule Frostmoln placed on the policy all
#   still refuse, because this rule runs after them. An allowed request is also
#   not scored, so it carries no anomaly-score line in the inspection record.
#
#   phase = 2 IS REQUIRED, and phase 1 is refused rather than moved: there the
#   rule would also skip the gateway's own method and content-type refusals,
#   which are not exemptable. A rule that only reads headers still works at
#   phase 2 — the headers are there.
#
# `status` is the response code for a deny and is ignored here, so it is left
# out. `message` is recorded whatever the action is, and is how you tell which
# of your rules exempted a request.
resource "frostmoln_appgw_waf_rule" "allow_uptime_checker" {
  gateway_id  = frostmoln_application_gateway.edge.id
  policy_id   = frostmoln_appgw_waf_policy.main.id
  rule_key    = "allow-uptime-checker"
  kind        = "builder"
  ordinal     = 20
  description = "Our uptime checker posts a payload the managed rules keep scoring."

  builder_json = jsonencode({
    phase = 2
    conditions = [
      {
        variable = "REQUEST_URI"
        operator = "@beginsWith"
        value    = "/health"
      },
      {
        variable = "REQUEST_HEADERS"
        selector = "X-Uptime-Token"
        operator = "@streq"
        value    = "replace-me"
      }
    ]
    action = {
      type    = "allow"
      message = "uptime checker"
    }
  })
}

# Record a match and change nothing else — how you measure what a rule WOULD
# refuse before you make it refuse anything.
resource "frostmoln_appgw_waf_rule" "watch_legacy_client" {
  gateway_id  = frostmoln_application_gateway.edge.id
  policy_id   = frostmoln_appgw_waf_policy.main.id
  rule_key    = "watch-legacy-client"
  kind        = "builder"
  ordinal     = 30
  description = "Count requests from the client we are about to retire."

  builder_json = jsonencode({
    phase = 1
    conditions = [
      {
        variable = "REQUEST_HEADERS"
        selector = "User-Agent"
        operator = "@beginsWith"
        value    = "acme-legacy/"
      }
    ]
    action = {
      type    = "log"
      message = "legacy client"
    }
  })
}

# Turn one managed rule off where it is a false positive for your traffic.
# Prefer a frostmoln_appgw_waf_exclusion where you can: it narrows the rule to
# one field on one path instead of disabling it everywhere.
resource "frostmoln_appgw_waf_rule" "quiet_942100" {
  gateway_id         = frostmoln_application_gateway.edge.id
  policy_id          = frostmoln_appgw_waf_policy.main.id
  rule_key           = "quiet-942100"
  kind               = "managedOverride"
  managed_secrule_id = 942100
  managed_action     = "disable"
  description        = "Our search endpoint legitimately posts SQL-shaped text."
}

# NOTE: kind = "raw" is not usable from Terraform today. The platform requires
# the rule text to carry the rule id it allocates during the same write, and a
# configuration cannot reference its own computed secrule_id. Use "builder" or
# "managedOverride".

Schema ​

Required ​

  • gateway_id (String) The Application Gateway the policy belongs to.
  • kind (String) Which authoring surface this rule uses:
  • builder — a structured rule, given as builder_json.

  • raw — SecLang text, given as raw. Not usable from Terraform today — see the raw attribute.

  • managedOverride — change a platform-managed rule, via managed_secrule_id and managed_action.

    Exactly one payload belongs to each kind.

  • policy_id (String) The WAF policy this rule belongs to.
  • rule_key (String) Your stable name for this rule: 1-64 lowercase letters, digits or hyphens, starting and ending with a letter or digit.

Optional ​

  • builder_json (String) A structured rule as a JSON document, for kind = "builder". Use jsonencode({ conditions = [...], action = {...}, phase = 2 }).

    A string rather than a nested block because the condition list is an ordered sequence whose shape the server owns; encoding it as JSON keeps this resource from having to re-declare — and drift from — that schema.

    Conditions are ANDed — every one must match. An OR is two rules.

    action.type is one of three:

  • deny — refuse the request with action.status (403 when unset).

  • log — record the match and change nothing else.

  • allow — do not refuse this request.

    action.status is the response code for a deny and is ignored otherwise. action.message is recorded whatever the action is, and is how you tell your own rules apart in the decision log — including which allow rule exempted a request.

    ~> allow exempts far less than the word suggests. What it skips is the managed ruleset's anomaly-score decision — the one that answers 403 because a request accumulated enough suspicion, and the one that produces false positives. Everything else still refuses: the policy's allowed_methods and allowed_request_content_types, a request body the gateway could not parse, your own denying rules and any rule Frostmoln placed on the policy, because the rule runs after them. Every condition must match — an allow with two conditions exempts a request only when both are true, exactly like a deny. An allowed request is also not scored, so it carries no anomaly-score line in the inspection record.

    allow requires phase = 2 and phase 1 is refused rather than moved: there it would also skip the gateway's own method and content-type refusals, which are not exemptable. A rule that only reads headers still works at phase 2. allow is not available to kind = "raw".

  • description (String) What this rule is for. At most 1024 characters.

  • enabled (Boolean) Whether the rule is evaluated.

  • managed_action (String) What to do with the managed rule: disable or updateTarget.

  • managed_secrule_id (Number) The managed rule to override, for kind = "managedOverride".

  • managed_target (String) The new target, for managed_action = "updateTarget".

  • ordinal (Number) Evaluation order within the policy.

  • raw (String) SecLang rule text, for kind = "raw".

    ~> Not usable from Terraform today. The platform requires the text to carry the SecRule id it allocates during the same write (id:4000000, and so on), and a configuration cannot reference its own computed secrule_id. Until the platform injects that id, author rules with kind = "builder" or kind = "managedOverride".

    The accepted subset is also narrower than SecLang: exactly one directive per rule (no chain), an allow-listed set of actions and transformations, RE2 regular expressions only, phase 1 or 2, and at most 8192 characters.

Read-Only ​

  • created_at (String) The creation timestamp.

  • owner (String) Always tenant for a rule managed by this resource.

  • revision (Number) A server-assigned counter, bumped on every write.

    This is what orders a publish after your rule changes without depends_on. Feed it into frostmoln_appgw_waf_policy_publication.rule_revisions: any rule change changes an input to the publication, so Terraform's own graph runs the publish last, and re-runs it exactly when something changed.

  • secrule_id (Number) The SecRule id the platform assigned. Derived from a reserved customer range and then fixed, so it never moves. Never supplied by you: a chosen id could collide with the managed ruleset.

  • updated_at (String) The last update timestamp.