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
# 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 asbuilder_json.raw— SecLang text, given asraw. Not usable from Terraform today — see therawattribute.managedOverride— change a platform-managed rule, viamanaged_secrule_idandmanaged_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, forkind = "builder". Usejsonencode({ 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.typeis one of three:
deny— refuse the request withaction.status(403 when unset).log— record the match and change nothing else.allow— do not refuse this request.action.statusis the response code for adenyand is ignored otherwise.action.messageis recorded whatever the action is, and is how you tell your own rules apart in the decision log — including whichallowrule exempted a request.~>
allowexempts 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'sallowed_methodsandallowed_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 — anallowwith two conditions exempts a request only when both are true, exactly like adeny. An allowed request is also not scored, so it carries no anomaly-score line in the inspection record.allowrequiresphase = 2and 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.allowis not available tokind = "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:disableorupdateTarget.managed_secrule_id(Number) The managed rule to override, forkind = "managedOverride".managed_target(String) The new target, formanaged_action = "updateTarget".ordinal(Number) Evaluation order within the policy.raw(String) SecLang rule text, forkind = "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 computedsecrule_id. Until the platform injects that id, author rules withkind = "builder"orkind = "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) Alwaystenantfor 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 intofrostmoln_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.