Auditing Azure WAF Policies with an AI Coding Assistant

Auditing Azure WAF Policies with an AI Coding Assistant

Auditing dozens of Azure WAF policies with an AI coding assistant — what worked, what didn't, and the practitioner patterns I wish I'd had before I started.

When you operate an Azure Application Gateway WAF in front of a sprawling estate of listeners, sub-policies and rule-group overrides, “is the audit report still accurate?” stops being a rhetorical question. Policies drift. Exclusions get added under deadline pressure. Override blocks get commented out and forgotten. And the report you handed to your security team six months ago no longer matches the IaC running in production.

I recently spent an afternoon putting an AI coding assistant — Claude Code with a small custom sub-agent — to work on exactly this problem. Below is what the workflow looked like, the surprises I hit, and the bits I’d do differently next time.

The setup

The estate in question runs OWASP CRS 3.2 on Azure Application Gateway WAF v2 in Prevention mode, deployed entirely via Bicep:

policies/
├── global/
│   └── rulegroupoverrides.bicep      # @export() shared override variables
└── <env>/prd/
    ├── <policy-name-1>/
    │   ├── exclusions.bicep
    │   ├── customrules.bicep
    │   └── rulegroupoverrides.bicep
    ├── <policy-name-2>/
    │   └── ...
    └── ... (multiple listener and path-based policies)

Three files per policy. Dozens of policies. Plus a global override file that every policy imports from. That’s a lot of surface area for a human auditor to sweep manually.

The starting artefact was an existing security audit report (HTML/DOCX/PDF) the team had generated weeks earlier. The question I wanted to answer wasn’t “is the WAF secure?” — it was the more boring but more important one: does the report still match what’s deployed?

Step 1 — Forward audit: report → code

The first pass was straightforward: for every finding, recommendation or required exclusion in the audit report, verify it exists in the corresponding Bicep file.

I dispatched a custom waf-analyst sub-agent (a tightly-scoped Claude Code agent with read-only access to the policy folder and the report) with a brief like:

For every audit-report finding, determine whether it’s covered by exclusions.bicep, customrules.bicep, or rulegroupoverrides.bicep. Coverage can come from any of the three — don’t assume an exclusion lives in exclusions.bicep. Return a table per policy with finding / covered? / where / gap.

This worked well. The agent produced a clean per-policy matrix and a “gaps to address” list at the bottom. A handful of items flagged in the original report had since been implemented (a partner-IP block rule, a batch of cookie exclusions promoted from dev to prod). A handful were still outstanding. Mission accomplished — or so I thought.

Step 2 — Reverse audit: code → report

Then I asked the question the other way round: is everything deployed actually documented in the report?

This is the audit direction nobody likes to do manually. It’s where drift hides — the exclusions added at 11pm to unblock a release, the rule-group overrides someone disabled to chase a false positive that no one wrote down.

I sent a second task to the same sub-agent:

Enumerate every match-variable + rule-ID combination in exclusions.bicep, every custom rule by name, every rule-ID override in rulegroupoverrides.bicep. Cross-reference each against the audit report. Flag anything deployed that the report does NOT mention.

The agent came back with 47 alleged gaps. Entire policies were missing from the report. Six sub-policies had documented overrides but undocumented exclusions. Two custom rules were live but not mentioned anywhere. A handful of rule lists were short by one or two rule IDs.

This is where the story gets interesting.

Step 3 — Trust, but verify

Before editing 47 entries into the report, I spot-checked the agent’s findings against the actual Bicep. I’m glad I did:

  • For one policy the agent claimed an exclusion on a specific business-domain field with two SQLi rule IDs. I opened the file. The deployed code had no such exclusion at all — only commented-out blocks plus a comment noting the policy inherits from a sibling. The agent had confused two adjacent policies.
  • For another policy the agent claimed an exclusion on a short query parameter with one rule ID. The actual file had an exclusion on a different field with a different rule. The pattern the agent named was real and lived elsewhere in the estate — but on a different policy. The agent had blended two adjacent entries.
  • The agent said one heavy exclusion had “22 SQLI rules” and the report’s claimed count of 20 was wrong. The deployed code actually has exactly 20 rules. The report’s count was correct; its list of rules had two extras (920271 and 920300) that aren’t in code.

The pattern was clear: the agent was excellent at finding where to look and producing a structured first draft, but its specific claims — which rule IDs, which match variable, which selector — were unreliable enough that I couldn’t use them directly. Anything I added to the report had to be verified by reading the actual .bicep file myself.

Once I did that, the real picture emerged: roughly 30 genuine documentation gaps (down from the agent’s 47), plus about five places where the report contradicted the deployed code. Almost all the “factual errors in the report” were cases where the policy had been changed after the report was generated, and nobody had updated the report.

Step 4 — Edit the source artefact

The report’s source-of-truth is an HTML file with a Calibri / Office-blue stylesheet. I made all corrections directly to the HTML:

  • Added 12 new policy subsections for policies that had been completely undocumented
  • Corrected one policy entry that described the wrong field entirely
  • Trimmed two phantom rule IDs from the report’s biggest exclusion table
  • Re-labelled two policies (the report still called them “DEV pending PRD promotion” — they’d been live in production for weeks)
  • Added a clarification where the report said “RCE override block commented out” — in fact a rule in that block was state: 'Disabled', which is functionally equivalent for that rule but materially different to document
  • Documented a custom rule that had been deployed since the report was written

Step 5 — Regenerate docx and pdf

The HTML is the canonical source, but stakeholders want the DOCX and PDF too. With Word installed locally, COM automation does this in a few lines of PowerShell:

$word = New-Object -ComObject Word.Application
$word.Visible = $false
$word.DisplayAlerts = 0

$doc = $word.Documents.Open($html, $false, $true)  # ReadOnly source

# Tighten page margins so wide tables fit
$ps = $doc.PageSetup
$ps.LeftMargin = 56.7; $ps.RightMargin = 56.7   # 2.0 cm
$ps.TopMargin  = 56.7; $ps.BottomMargin = 56.7

# Auto-fit every table to the printable area
foreach ($t in $doc.Tables) {
    $t.PreferredWidthType = 2     # wdPreferredWidthPercent
    $t.PreferredWidth     = 100
    $t.AllowAutoFit       = $true
    $t.AutoFitBehavior(2)         # wdAutoFitWindow
}

$doc.SaveAs([ref]$docx, [ref]16)  # wdFormatDocumentDefault
$doc.SaveAs([ref]$pdf,  [ref]17)  # wdFormatPDF
$doc.Close($false); $word.Quit()

One gotcha worth flagging: Word’s HTML import respects body { margin: ... } in the stylesheet on top of the page margins you set via PageSetup. If your HTML has body { margin: 2.5cm } and you also set 2 cm page margins via COM, you get an effective 4.5 cm of right-side whitespace and tables start spilling. Strip the body margin in CSS, set the page margin via PageSetup, and the layout behaves.

From audit to operating practice

The audit workflow above gets you a clean inventory. What it does not do is teach you how to make better decisions next time someone hands you a 3am Slack message saying “the WAF is blocking prod”. The next four sections are the practitioner content I wish someone had handed me before I started shipping WAF policies — covering exclusion hygiene, the zero-trust custom-rule pattern, the difference between Log and Disabled, and how to live with the per-gateway exclusion ceiling.

Writing exclusions that don’t become security debt

The pressure pattern is always the same: a release is blocked at midnight, a WAF rule fires on a form field nobody has time to analyse, and someone writes an exclusion scoped to the entire rule group just to unblock. Six months later, nobody remembers why the exclusion is there, and removing it feels risky. This is how WAF configurations accumulate debt.

The antidote is the narrowest possible scope, expressed in three orthogonal dimensions: the match variable (where in the request), the selector (which specific field), and the rule ID (which specific rule).

In Bicep, that looks like this:

{
  // FP: field 'redirectTarget' triggers SQLi detection 942440 because it contains
  // a percent-encoded ampersand (%26) in redirect payloads. Confirmed FP
  // by log analysis on 2026-01-14. Revisit if the field becomes user-writeable.
  matchVariable: 'RequestArgNames'
  selectorMatchOperator: 'Equals'
  selector: 'redirectTarget'
  exclusionManagedRuleSets: [
    {
      ruleSetType: 'OWASP'
      ruleSetVersion: '3.2'
      ruleGroups: [
        {
          ruleGroupName: 'REQUEST-942-APPLICATION-ATTACK-SQLI'
          rules: [ { ruleId: '942440' } ]
        }
      ]
    }
  ]
}

The comment is load-bearing. “Why does this exist?” must be answerable from the code itself, because the person debugging a WAF block at midnight in eighteen months will not have access to your Jira ticket. Record the original log evidence, the field name, and a revisit condition.

What you must not do is omit the rules array and target the entire ruleGroupName. That one omission turns a surgical exemption into a blanket pass for forty-plus rules on every request that contains a field called redirectTarget. Azure Application Gateway WAF will accept the broader form — it will not warn you. The CRS group for SQL injection covers rules from 942100 through 942550. Excluding the group because one rule fires on one field leaves you exposed to the other forty-nine.

The same logic applies to selectorMatchOperator. Use Equals unless you genuinely need prefix matching. Contains with a short selector string can silently absorb fields you didn’t intend to exempt.

The zero-trust custom-rule pattern — why trusted-IP rules use Log, not Allow

Azure WAF custom rules evaluate before the managed OWASP rule set. If a custom rule fires with action: 'Allow', processing stops entirely — the request is admitted unconditionally. The managed rules, rate limits, and any downstream custom rules never run. For an allowlist of corporate egress IPs or partner network ranges, that might sound like exactly what you want. It is not.

action: 'Allow' is appropriate for a CDN health-probe source or a monitoring agent where you explicitly accept that nothing else in the chain matters. For any source that is also capable of sending application traffic — internal users, partner systems, dev teams with VPN access — it is the wrong default. Internal users can still trigger injection patterns, accidentally or otherwise. A compromised host on a trusted subnet carries that trust relationship with it.

The correct pattern is action: 'Log':

{
  name: 'AllowTrafficFromTrustedNetworks'
  priority: 10
  ruleType: 'MatchRule'
  action: 'Log'          // zero-trust: admission is logged, not granted
  matchConditions: [
    {
      matchVariables: [ { variableName: 'RemoteAddr' } ]
      operator: 'IPMatch'
      matchValues: [ '10.0.0.0/24', '192.168.10.0/28' ]
    }
  ]
}

With action: 'Log', the rule records that the request originated from a trusted range, but processing continues into the managed rule set. A genuine attack from a trusted IP will still be caught. The log record provides the visibility you need without the bypass you do not want.

The tradeoff is worth stating plainly: in Prevention mode, a matched-and-logged request that also fires a blocking managed rule will be blocked. If you have a legitimate internal tool that consistently hits a CRS rule, the right fix is a targeted exclusion for that tool’s specific traffic pattern, not promoting the allowlist rule to Allow. The zero-trust posture stays intact; the exemption surface is explicit and auditable.

Block vs Log vs Disabled — picking the right downgrade

When a CRS rule produces false positives, you have three options in a rule group override: leave it at Block, downgrade to Log, or set it to Disabled. The choice has consequences that aren’t obvious from the Bicep schema.

Log keeps the rule active as a detection. The rule still evaluates every matching request, writes a log entry, and contributes to anomaly scoring. In detection-mode policies, or for any rule that fires infrequently enough that you want the signal, Log is almost always the right downgrade. You preserve the indicator; you stop the false-positive block.

Disabled removes the rule from evaluation entirely. No log entry, no anomaly score contribution. This is appropriate in one scenario: when the rule is structurally incompatible with your stack. A PHP code injection rule (REQUEST-933) running against a pure .NET API is never going to produce a true positive, and the log noise it generates makes your detection baseline harder to read. In that case, Disabled is defensible — you are making a deliberate architectural statement that the threat category is inapplicable, not papering over a misconfiguration.

In Bicep:

// REQUEST-942-APPLICATION-ATTACK-SQLI
// Rule 942370 — false-positive on base64-encoded state tokens in the
// 'state' parameter. Log only; reassess if state param becomes user-writable.
{ ruleId: '942370', state: 'Enabled', action: 'Log' }

// REQUEST-933-APPLICATION-ATTACK-PHP
// Disabled: .NET-only API surface, PHP injection is not a plausible threat vector.
{ ruleId: '933210', state: 'Disabled', action: 'Log' }

Note that Azure requires action to be present even when state is Disabled — the value is ignored, but the schema demands it. Setting action: 'Log' on a disabled rule is conventional and avoids a confusing action: 'Block' next to a rule that doesn’t run.

The failure mode to watch for is using Disabled because a rule fires frequently and you do not want to investigate it. Frequency is not a justification for disablement — it is a signal that the exclusion is wrong (too broad or too narrow), or that the application is doing something the WAF should be told about explicitly. Disabled chosen for convenience leaves a permanent gap in your detection coverage with no record of why.

The exclusion-count ceiling and how to live with it

Azure Application Gateway WAF enforces a hard limit of 200 exclusion entries per gateway. The limit is not per policy — it is per gateway, and a gateway services all policies in its environment. Every matchVariable: entry across every exclusions.bicep file in the environment counts toward that 200. On an estate with multiple policies, that ceiling becomes a real constraint.

The first thing to do is measure before you add. A one-liner across the deployed policy tree:

$root = "C:\repo\policies\<env>\prd"
(Get-ChildItem $root -Recurse -Filter exclusions.bicep | ForEach-Object {
    (Get-Content $_ | Where-Object {
        $_ -notmatch '^\s*//' -and $_ -match 'matchVariable:'
    }).Count
} | Measure-Object -Sum).Sum

Commented-out entries do not deploy and do not count. Measure only live entries. Run this before every batch of additions. Flag at 180 — you want headroom to handle emergency unblocking without approaching the limit under pressure.

When you are burning through the budget, the levers are:

Prefer per-rule exclusions over per-rule-group exclusions. A per-rule-group exclusion covers more threat surface, which is why you should avoid it for security reasons — but it also costs exactly one matchVariable: entry. The budget pressure does not justify broadening scope; it justifies being precise and accepting that two separate fields need two separate entries.

Use path-scoped sub-policies for expensive exclusion sets. If a single URL path — say, a rich-text editor endpoint or a SOAP ingestion route — accounts for fifteen exclusion entries, and that path is identifiable by prefix, put it in its own path-based policy. The entries still count toward the gateway total, but they are scoped to requests that actually reach that path, which limits blast radius if the exclusion is wrong.

Audit for duplicates across policies. On a large estate it is common to find the same selector excluded for the same rule in eight different policies, each added independently. If the field and rule combination is genuinely universal, consolidate it in the global overrides file. If it is policy-specific, make sure it is not accidentally replicated.

Do not hoard commented-out exclusions. A block of commented-out entries has zero deployment cost, but it creates a false sense of available headroom (“we can uncomment these if we need them”) and makes the count harder to audit correctly. If an exclusion is commented out and has been for more than one release cycle, either delete it or document explicitly why it is being held.

What I’d do differently

A few takeaways that aren’t specific to WAF auditing — they’re about working with AI assistants on this class of structured-config drift problem:

  1. Always run the audit in both directions. A forward audit (spec → code) tells you what’s missing. A reverse audit (code → spec) tells you what’s drifting. Most teams only ever do the first one.

  2. Treat agent output as a structured first draft, never a finished artefact. The agent in this workflow saved hours by enumerating policies and producing a per-finding table. It also confidently invented specific rule IDs and field names. Verify every concrete claim against the source file before propagating it.

  3. Have the agent emit verification-friendly output. I asked the agent to include the source file path and line numbers next to every finding. That made spot-checking a 30-second Read per item instead of a hunt.

  4. The source-of-truth document and the generated artefacts must stay in sync mechanically. Editing the DOCX directly is a trap — you lose round-tripping. Edit the HTML; regenerate the DOCX/PDF with one command.

  5. The most interesting findings weren’t the ones the audit flagged — they were the ones it had missed. The forward audit found a handful of known gaps. The reverse audit found two whole policies the report didn’t mention at all, and one place where the report was actively wrong about which field had a 20-rule exclusion. That second pass is the high-value one.

Final thought

This kind of work — methodical, structured, low on novelty and high on volume — is exactly where a coding assistant earns its keep. Not by replacing the engineer, but by doing the legwork of enumerating, cross-referencing and drafting, so the engineer can spend their time on the actual verification and the editorial decisions.

The deliverable was a corrected audit report. The harder-won deliverable was a clearer mental model of where my WAF configuration actually lives, what it actually does, and how far it had drifted from the documentation. That’s the part that pays dividends long after the report is filed.

Questions? Requests? Suggestions?

We are looking forward to hearing from you!

Are you looking for
Consulting Services,
Support Plans or
Training & Workshops?