diff --git a/Azure WAF/WAF Triage Solution/README.md b/Azure WAF/WAF Triage Solution/README.md new file mode 100644 index 00000000..6f2ae5fd --- /dev/null +++ b/Azure WAF/WAF Triage Solution/README.md @@ -0,0 +1,200 @@ +# Azure WAF Triage Solution + +[![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2FAzure%2FAzure-Network-Security%2Fmain%2FAzure%2520WAF%2FWAF%2520Triage%2520Solution%2Fazuredeploy.json) + +> **Disclaimer:** This solution is provided **as-is** with no warranty or support. It is a community sample, not an official Microsoft product or service. Use it at your own risk. +> +> **Before applying any changes**, always review the proposed WAF exclusion or rule change carefully. This workbook helps prioritize tuning candidates using statistical analysis of WAF logs, but **it cannot determine whether traffic is truly legitimate**. Creating exclusions or disabling rules reduces WAF protection for the affected match patterns. You are responsible for validating that any change is appropriate for your application and security posture. +> +> Test changes in **Detection mode** before switching to Prevention mode whenever possible. + +A solution to help identify and resolve **false positives** in Azure Application Gateway Web Application Firewall (WAF). It provides an Azure Monitor Workbook with evidence-based scoring, anomaly-scoring-aware tracing, and one-click remediation via Azure Automation. + +## Overview + +This solution deploys: + +1. **Azure Monitor Workbook** — Triages WAF blocked requests, scores tuning candidates, and provides one-click exclusion creation +2. **Azure Automation Runbook** — Creates WAF exclusions or disables rules programmatically +3. **Logic App** — HTTP trigger that connects workbook actions to the automation runbook + +## Key Features + +- **FP Confidence Scoring** — Ranks tuning candidates with a 0–100 evidence-based score using 7 signals: trace evidence, breadth, recurrence, concentration, selector quality, mitigation safety, and transaction volume +- **Anomaly Scoring Awareness** — Traces blocked transactions back to contributing Matched rules, so you see the actual rules to exclude rather than the mandatory blocking rules (949/959/980) +- **One-Click Remediation** — Create per-rule exclusions or disable rules directly from the workbook with a single click +- **Quick Lookup** — Paste a transaction ID from logs or a support ticket to find the exact blocked request and fix it immediately +- **Attack Payload Filtering** — Automatically filters out selectors that contain XSS, injection, or other attack patterns to prevent accidental security weakening +- **Window-Relative Scoring** — Score components automatically adjust to the selected time range, preventing score inflation on longer time windows +- **Disable-Rule Safety Cap** — Broad disable-rule recommendations are capped below "Very High" confidence since they have wider security impact than scoped exclusions + +## Workbook Tabs + +| Tab | Purpose | +| --- | --- | +| **Auto-Tuning** | Proactive workflow — view all tuning candidates ranked by FP Confidence score, review evidence, and apply fixes | +| **Quick Lookup** | Reactive workflow — paste a transaction ID to find and fix a specific blocked request | +| **Overview** | Dashboard with summary tiles, time charts, top blocking rules, top blocked IPs and URIs | + +## Supported Actions + +| Action | When Used | Description | +| --- | --- | --- | +| **Create Exclusion** | Match variable is excludable (ARGS, REQUEST_HEADERS, REQUEST_COOKIES, etc.) | Creates a per-rule exclusion for the specific match variable and selector | +| **Disable Rule** | Match variable is non-excludable (REQUEST_URI, XML, etc.) | Disables the entire managed rule in the WAF policy | + +## FP Confidence Score + +The score is additive (0–100) with 7 components: + +| Component | Max Points | What it measures | +| --- | ---: | --- | +| Trace Evidence | 15 | Is the candidate linked to real blocked transactions? | +| Breadth | 25 | How many endpoints and IPs per day does the pattern affect? | +| Recurrence | 10 | Does it recur consistently over the observation window? | +| Concentration | 20 | Is the traffic spread across sources, or dominated by one IP/URI? | +| Selector Quality | 5 | Does the parser find a precise, usable selector? | +| Mitigation Safety | 5 | Is the action a scoped exclusion (safer) or a full rule disable? | +| Transaction Volume | 20 | How many transactions per day does it affect? | + +| Score Range | Label | Recommended Action | +| ---: | --- | --- | +| 85–100 | Very High | Strong candidate — review evidence and apply | +| 70–84 | High | Good candidate — review carefully before applying | +| 50–69 | Medium | Investigate further before acting | +| < 50 | Low | Weak evidence — do not auto-tune | + +> **Important:** The FP Confidence score measures statistical prominence of a pattern. It does not prove that traffic is legitimate. Always review sample data before applying changes. + +## Prerequisites + +1. **Azure Subscription** with permissions to create Automation Accounts, Logic Apps, and Workbooks +2. **Log Analytics Workspace** receiving WAF diagnostic logs (both `ApplicationGatewayFirewallLog` and `ApplicationGatewayAccessLog`) +3. **WAF Policy** attached to your Application Gateway + +## Deployment + +### Option 1: Deploy to Azure Button + +Click the button at the top of this page. Fill in: + +- **Resource Group** — Where to deploy the solution components +- **Workspace Name** — Name of your existing Log Analytics workspace with WAF logs +- **Workspace Resource Group** — Resource group of the workspace + +All other parameters have sensible defaults. + +### Option 2: Azure CLI + +```bash +az deployment group create \ + --resource-group \ + --template-file azuredeploy.json \ + --parameters workspaceName= workspaceResourceGroup= +``` + +### Post-Deployment Steps + +1. **Grant the Automation Account's Managed Identity `Contributor` access** to the WAF policies you want to manage: + + ```bash + # Get the Automation Account's principal ID from the deployment output + az automation account show --name aa-waf-triage --resource-group \ + --query identity.principalId -o tsv + + # Grant Contributor on the WAF policy + az role assignment create \ + --assignee \ + --role Contributor \ + --scope /subscriptions//resourceGroups//providers/Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies/ + ``` + +2. **Open the Workbook** in Azure Portal: Monitor > Workbooks > Azure WAF Triage Solution + +## Usage + +### Proactive Tuning (Auto-Tuning Tab) + +1. Select your subscription and workspace +2. Select the time range +3. Click an Application Gateway row in Step 1 +4. Review candidates in the Auto-Tuning tab — sorted by FP Confidence score +5. Click a candidate row to see the impact preview +6. Click **Create Exclusion** or **Disable Rule** to apply + +### Reactive Fix (Quick Lookup Tab) + +1. Get the transaction ID from WAF logs or a support ticket +2. Select the correct Application Gateway scope in Step 1 +3. Switch to the Quick Lookup tab +4. Paste the transaction ID +5. Click the matched rule row you want to fix +6. Click **Apply Fix** + +## Architecture + +``` ++------------------------------------------------------------------+ +| Azure Monitor Workbook | +| +----------------+ +--------------+ +--------+ | +| | Auto-Tuning | | Quick Lookup | | Overview| | +| +--------+-------+ +------+-------+ +--------+ | +| | | | +| KQL Queries ----------------+----> Log Analytics Workspace | +| ARG Queries ----------------+----> Azure Resource Graph | +| | | +| | User clicks "Apply" | +| | ARM Action (HTTP POST) | ++-----------+-------------------------------------------------------+ + | + v ++--------------------------+ +--------------------------+ +| Logic App (HTTP) | ----> | Azure Automation Runbook | +| - Validates input | | - Creates exclusion | +| - Triggers runbook | | - OR disables rule | ++--------------------------+ +------------+-------------+ + | + v + +--------------------------+ + | WAF Policy | + | - Exclusion added | + | - OR rule disabled | + +--------------------------+ +``` + +## File Structure + +``` +WAF Triage Solution/ +├── README.md # This file +├── azuredeploy.json # Unified ARM template (Deploy to Azure) +├── runbooks/ +│ └── New-WafExclusion.ps1 # Automation Runbook +└── workbook/ + └── waf-triage-workbook.json # Azure Monitor Workbook definition +``` + +## Match Variable Mapping + +The workbook automatically maps WAF log match variables to the correct exclusion API variables: + +| WAF Log Variable | Exclusion API Variable | Action | +| --- | --- | --- | +| `ARGS` / `ARGS_GET` / `ARGS_POST` | `RequestArgValues` | Create Exclusion | +| `ARGS_NAMES` | `RequestArgKeys` | Create Exclusion | +| `REQUEST_HEADERS` | `RequestHeaderValues` | Create Exclusion | +| `REQUEST_HEADERS_NAMES` | `RequestHeaderKeys` | Create Exclusion | +| `REQUEST_COOKIES` | `RequestCookieValues` | Create Exclusion | +| `REQUEST_COOKIES_NAMES` | `RequestCookieKeys` | Create Exclusion | +| `REQUEST_BODY` | `RequestArgValues` | Create Exclusion | +| `REQUEST_URI` / `REQUEST_FILENAME` | — | Disable Rule | +| `XML:` | — | Disable Rule | +| `REQUEST_METHOD` / `REQUEST_PROTOCOL` | — | Disable Rule | + +## Contributing + +This project welcomes contributions and suggestions. Please open an issue or submit a pull request. + +## License + +This project is licensed under the MIT License. See the [LICENSE](../../LICENSE) file for details. diff --git a/Azure WAF/WAF Triage Solution/azuredeploy.json b/Azure WAF/WAF Triage Solution/azuredeploy.json new file mode 100644 index 00000000..feab66ba --- /dev/null +++ b/Azure WAF/WAF Triage Solution/azuredeploy.json @@ -0,0 +1,462 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "description": "Unified one-click deployment for the WAF False Positive Triage Solution. Deploys an Automation Account, Runbook, API Connection, Logic App, role assignment, and Azure Monitor Workbook." + }, + "parameters": { + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Location for all resources." + } + }, + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the existing Log Analytics workspace that contains WAF diagnostic logs." + } + }, + "workspaceResourceGroup": { + "type": "string", + "metadata": { + "description": "Resource group of the existing Log Analytics workspace." + } + }, + "automationAccountName": { + "type": "string", + "defaultValue": "aa-waf-triage", + "metadata": { + "description": "Name of the Automation Account to create." + } + }, + "logicAppName": { + "type": "string", + "defaultValue": "la-waf-exclusion-trigger", + "metadata": { + "description": "Name of the Logic App to create." + } + }, + "workbookDisplayName": { + "type": "string", + "defaultValue": "WAF FP Confidence Workbook", + "metadata": { + "description": "Display name for the Azure Monitor Workbook." + } + }, + "runbookScriptUri": { + "type": "string", + "defaultValue": "https://raw.githubusercontent.com/Azure/Azure-Network-Security/main/Azure%20WAF/Triage%20Workbook/runbooks/New-WafExclusion.ps1", + "metadata": { + "description": "URI to the New-WafExclusion.ps1 runbook script hosted on GitHub." + } + } + }, + "variables": { + "automationConnectionName": "azureautomation", + "runbookName": "New-WafExclusion", + "workbookId": "[guid(resourceGroup().id, parameters('workbookDisplayName'))]", + "workbookSourceId": "[resourceId(parameters('workspaceResourceGroup'), 'Microsoft.OperationalInsights/workspaces', parameters('workspaceName'))]", + "automationJobOperatorRoleId": "4fe576fe-1146-4730-92eb-48519fa6bf9f", + "roleAssignmentName": "[guid(resourceId('Microsoft.Automation/automationAccounts', parameters('automationAccountName')), parameters('logicAppName'), variables('automationJobOperatorRoleId'))]" + }, + "resources": [ + { + "type": "Microsoft.Automation/automationAccounts", + "apiVersion": "2023-11-01", + "name": "[parameters('automationAccountName')]", + "location": "[parameters('location')]", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "sku": { + "name": "Basic" + } + } + }, + { + "type": "Microsoft.Automation/automationAccounts/runbooks", + "apiVersion": "2023-11-01", + "name": "[concat(parameters('automationAccountName'), '/', variables('runbookName'))]", + "location": "[parameters('location')]", + "dependsOn": [ + "[resourceId('Microsoft.Automation/automationAccounts', parameters('automationAccountName'))]" + ], + "properties": { + "runbookType": "PowerShell", + "logProgress": false, + "logVerbose": false, + "description": "Creates WAF exclusions or disables rules on an Azure WAF policy. Triggered by the WAF Triage Workbook via Logic App.", + "publishContentLink": { + "uri": "[parameters('runbookScriptUri')]" + } + } + }, + { + "type": "Microsoft.Web/connections", + "apiVersion": "2016-06-01", + "name": "[variables('automationConnectionName')]", + "location": "[parameters('location')]", + "dependsOn": [ + "[resourceId('Microsoft.Automation/automationAccounts', parameters('automationAccountName'))]" + ], + "properties": { + "displayName": "Azure Automation Connection", + "api": { + "id": "[subscriptionResourceId('Microsoft.Web/locations/managedApis', parameters('location'), 'azureautomation')]" + }, + "parameterValueType": "Alternative" + }, + "kind": "V1" + }, + { + "type": "Microsoft.Logic/workflows", + "apiVersion": "2019-05-01", + "name": "[parameters('logicAppName')]", + "location": "[parameters('location')]", + "identity": { + "type": "SystemAssigned" + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/connections', variables('automationConnectionName'))]" + ], + "properties": { + "state": "Enabled", + "definition": { + "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "$connections": { + "defaultValue": {}, + "type": "Object" + } + }, + "triggers": { + "manual": { + "type": "Request", + "kind": "Http", + "inputs": { + "schema": { + "type": "object", + "properties": { + "resourceGroupName": { + "type": "string", + "description": "Resource group containing the WAF policy" + }, + "wafPolicyName": { + "type": "string", + "description": "Name of the WAF policy" + }, + "action": { + "type": "string", + "description": "Action type: createExclusion or disableRule" + }, + "ruleId": { + "type": "string", + "description": "Rule ID to exclude (optional for global exclusion)" + }, + "ruleGroupName": { + "type": "string", + "description": "Rule group name" + }, + "ruleSetType": { + "type": "string", + "description": "Rule set type (OWASP, Microsoft_DefaultRuleSet, Microsoft_BotManagerRuleSet)" + }, + "ruleSetVersion": { + "type": "string", + "description": "Rule set version" + }, + "matchVariable": { + "type": "string", + "description": "Match variable for exclusion" + }, + "selectorMatchOperator": { + "type": "string", + "description": "Selector match operator" + }, + "selector": { + "type": "string", + "description": "Selector value" + }, + "description": { + "type": "string", + "description": "Optional description" + } + }, + "required": [ + "resourceGroupName", + "wafPolicyName", + "ruleSetType", + "ruleSetVersion" + ] + } + } + } + }, + "actions": { + "Initialize_Result": { + "type": "InitializeVariable", + "runAfter": {}, + "inputs": { + "variables": [ + { + "name": "Result", + "type": "object", + "value": { + "status": "Processing", + "timestamp": "@{utcNow()}" + } + } + ] + } + }, + "Validate_Input": { + "type": "Compose", + "runAfter": { + "Initialize_Result": [ + "Succeeded" + ] + }, + "inputs": { + "resourceGroupName": "@triggerBody()?['resourceGroupName']", + "wafPolicyName": "@triggerBody()?['wafPolicyName']", + "action": "@coalesce(triggerBody()?['action'], 'createExclusion')", + "ruleId": "@coalesce(triggerBody()?['ruleId'], '')", + "ruleGroupName": "@coalesce(triggerBody()?['ruleGroupName'], '')", + "ruleSetType": "@triggerBody()?['ruleSetType']", + "ruleSetVersion": "@triggerBody()?['ruleSetVersion']", + "matchVariable": "@triggerBody()?['matchVariable']", + "selectorMatchOperator": "@triggerBody()?['selectorMatchOperator']", + "selector": "@triggerBody()?['selector']", + "description": "@coalesce(triggerBody()?['description'], 'Created via WAF Triage Workbook')" + } + }, + "Create_Exclusion_Job": { + "type": "ApiConnection", + "runAfter": { + "Validate_Input": [ + "Succeeded" + ] + }, + "inputs": { + "host": { + "connection": { + "name": "@parameters('$connections')['azureautomation']['connectionId']" + } + }, + "method": "put", + "path": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', resourceGroup().name, '/providers/Microsoft.Automation/automationAccounts/', parameters('automationAccountName'), '/jobs')]", + "queries": { + "x-ms-api-version": "2015-10-31", + "runbookName": "[variables('runbookName')]", + "wait": true + }, + "body": { + "properties": { + "parameters": { + "ResourceGroupName": "@outputs('Validate_Input')?['resourceGroupName']", + "WafPolicyName": "@outputs('Validate_Input')?['wafPolicyName']", + "Action": "@outputs('Validate_Input')?['action']", + "RuleId": "@outputs('Validate_Input')?['ruleId']", + "RuleGroupName": "@outputs('Validate_Input')?['ruleGroupName']", + "RuleSetType": "@outputs('Validate_Input')?['ruleSetType']", + "RuleSetVersion": "@outputs('Validate_Input')?['ruleSetVersion']", + "MatchVariable": "@outputs('Validate_Input')?['matchVariable']", + "SelectorMatchOperator": "@outputs('Validate_Input')?['selectorMatchOperator']", + "Selector": "@outputs('Validate_Input')?['selector']", + "Description": "@outputs('Validate_Input')?['description']" + } + } + } + } + }, + "Get_Job_Output": { + "type": "ApiConnection", + "runAfter": { + "Create_Exclusion_Job": [ + "Succeeded" + ] + }, + "inputs": { + "host": { + "connection": { + "name": "@parameters('$connections')['azureautomation']['connectionId']" + } + }, + "method": "get", + "path": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', resourceGroup().name, '/providers/Microsoft.Automation/automationAccounts/', parameters('automationAccountName'), '/jobs/@{encodeURIComponent(body(''Create_Exclusion_Job'')?[''properties'']?[''jobId''])}/output')]", + "queries": { + "x-ms-api-version": "2015-10-31" + } + } + }, + "Check_Job_Status": { + "type": "If", + "runAfter": { + "Get_Job_Output": [ + "Succeeded" + ] + }, + "expression": { + "or": [ + { + "equals": [ + "@body('Create_Exclusion_Job')?['properties']?['status']", + "Failed" + ] + }, + { + "contains": [ + "@string(body('Get_Job_Output'))", + "[[ERROR]" + ] + } + ] + }, + "actions": { + "Job_Failed_Response": { + "type": "Response", + "inputs": { + "statusCode": 500, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "status": "Failed", + "message": "Runbook execution failed - check job output for details", + "jobId": "@body('Create_Exclusion_Job')?['properties']?['jobId']", + "jobStatus": "@body('Create_Exclusion_Job')?['properties']?['status']", + "jobOutput": "@body('Get_Job_Output')", + "exclusionDetails": { + "resourceGroup": "@outputs('Validate_Input')?['resourceGroupName']", + "wafPolicy": "@outputs('Validate_Input')?['wafPolicyName']", + "ruleId": "@outputs('Validate_Input')?['ruleId']", + "matchVariable": "@outputs('Validate_Input')?['matchVariable']", + "selector": "@outputs('Validate_Input')?['selector']" + }, + "timestamp": "@utcNow()" + } + } + } + }, + "else": { + "actions": { + "Job_Success_Response": { + "type": "Response", + "inputs": { + "statusCode": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "status": "Success", + "message": "WAF exclusion created successfully", + "jobId": "@body('Create_Exclusion_Job')?['properties']?['jobId']", + "jobOutput": "@body('Get_Job_Output')", + "exclusionDetails": { + "resourceGroup": "@outputs('Validate_Input')?['resourceGroupName']", + "wafPolicy": "@outputs('Validate_Input')?['wafPolicyName']", + "ruleId": "@outputs('Validate_Input')?['ruleId']", + "matchVariable": "@outputs('Validate_Input')?['matchVariable']", + "selector": "@outputs('Validate_Input')?['selector']" + }, + "timestamp": "@utcNow()" + } + } + } + } + } + }, + "Connector_Failed_Response": { + "type": "Response", + "runAfter": { + "Create_Exclusion_Job": [ + "Failed", + "TimedOut" + ] + }, + "inputs": { + "statusCode": 500, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "status": "Failed", + "message": "Automation connector failed to create or run the job", + "error": "@body('Create_Exclusion_Job')?['properties']?['exception']", + "timestamp": "@utcNow()" + } + } + } + }, + "outputs": {} + }, + "parameters": { + "$connections": { + "value": { + "azureautomation": { + "connectionId": "[resourceId('Microsoft.Web/connections', variables('automationConnectionName'))]", + "connectionName": "[variables('automationConnectionName')]", + "connectionProperties": { + "authentication": { + "type": "ManagedServiceIdentity" + } + }, + "id": "[subscriptionResourceId('Microsoft.Web/locations/managedApis', parameters('location'), 'azureautomation')]" + } + } + } + } + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[variables('roleAssignmentName')]", + "scope": "[resourceId('Microsoft.Automation/automationAccounts', parameters('automationAccountName'))]", + "dependsOn": [ + "[resourceId('Microsoft.Automation/automationAccounts', parameters('automationAccountName'))]", + "[resourceId('Microsoft.Logic/workflows', parameters('logicAppName'))]" + ], + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('automationJobOperatorRoleId'))]", + "principalId": "[reference(resourceId('Microsoft.Logic/workflows', parameters('logicAppName')), '2019-05-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Insights/workbooks", + "apiVersion": "2022-04-01", + "name": "[variables('workbookId')]", + "location": "[parameters('location')]", + "kind": "shared", + "dependsOn": [ + "[resourceId('Microsoft.Logic/workflows', parameters('logicAppName'))]" + ], + "properties": { + "displayName": "[parameters('workbookDisplayName')]", + "serializedData": "[replace('{\"version\":\"Notebook/1.0\",\"items\":[{\"type\":1,\"content\":{\"json\":\"# WAF False Positive Auto-Tuning (vNext)\\n---\\nThis workbook uses an **evidence-based FP Confidence score** to identify WAF tuning candidates from diagnostic logs. It traces blocked transactions back to contributing `Matched` rules, groups recurring selector-level patterns, scores confidence using breadth/recurrence/concentration signals, and lets operators apply reviewed changes from the workbook.\"},\"name\":\"Title\"},{\"type\":9,\"content\":{\"version\":\"KqlParameterItem/1.0\",\"parameters\":[{\"id\":\"p-sub-001\",\"version\":\"KqlParameterItem/1.0\",\"name\":\"subscription\",\"label\":\"Subscription\",\"type\":6,\"isRequired\":true,\"multiSelect\":true,\"quote\":\"''\",\"delimiter\":\",\",\"value\":[\"all\"],\"typeSettings\":{\"additionalResourceOptions\":[],\"includeAll\":false}},{\"id\":\"p-ws-002\",\"version\":\"KqlParameterItem/1.0\",\"name\":\"workspace\",\"label\":\"Workspace\",\"type\":5,\"isRequired\":true,\"query\":\"where type =~ ''microsoft.operationalinsights/workspaces''\\r\\n| summarize by id, name\\r\\n| project id\",\"crossComponentResources\":[\"{subscription}\"],\"value\":\"\",\"typeSettings\":{\"additionalResourceOptions\":[]},\"queryType\":1,\"resourceType\":\"microsoft.resourcegraph/resources\"},{\"id\":\"p-dt-003\",\"version\":\"KqlParameterItem/1.0\",\"name\":\"detectionTime\",\"label\":\"Time Range\",\"type\":4,\"isRequired\":true,\"value\":{\"durationMs\":86400000},\"typeSettings\":{\"selectableValues\":[{\"durationMs\":3600000},{\"durationMs\":14400000},{\"durationMs\":43200000},{\"durationMs\":86400000},{\"durationMs\":259200000},{\"durationMs\":604800000},{\"durationMs\":1209600000},{\"durationMs\":2592000000}]}},{\"id\":\"p-la-005\",\"version\":\"KqlParameterItem/1.0\",\"name\":\"logicApp\",\"label\":\"Logic App\",\"type\":1,\"isRequired\":true,\"value\":\"__LOGIC_APP_RESOURCE_ID__\",\"isHiddenWhenLocked\":true},{\"id\":\"p-act-006\",\"version\":\"KqlParameterItem/1.0\",\"name\":\"actionFilter\",\"label\":\"Actions\",\"type\":2,\"isRequired\":true,\"multiSelect\":true,\"quote\":\"''\",\"delimiter\":\",\",\"jsonData\":\"[{\\\"value\\\":\\\"Blocked\\\",\\\"label\\\":\\\"Blocked\\\",\\\"selected\\\":true},{\\\"value\\\":\\\"Matched\\\",\\\"label\\\":\\\"Matched\\\",\\\"selected\\\":true},{\\\"value\\\":\\\"Detected\\\",\\\"label\\\":\\\"Detected\\\",\\\"selected\\\":true}]\",\"value\":[\"Blocked\",\"Matched\",\"Detected\"]},{\"id\":\"p-tab-default\",\"version\":\"KqlParameterItem/1.0\",\"name\":\"SelectedTab\",\"type\":1,\"isRequired\":false,\"value\":\"auto-exclusion\",\"isHiddenWhenLocked\":true}],\"style\":\"pills\",\"queryType\":0,\"resourceType\":\"microsoft.operationalinsights/workspaces\"},\"name\":\"Parameters\"},{\"type\":3,\"content\":{\"version\":\"KqlItem/1.0\",\"query\":\"AzureDiagnostics\\n| where Category == \\\"ApplicationGatewayFirewallLog\\\"\\n| where TimeGenerated {detectionTime}\\n| where action_s in ({actionFilter})\\n| extend Id = new_guid()\\n| project-reorder Id, ResourceId, policyScopeName_s, action_s\\n| evaluate pivot(action_s, count(), ResourceId, policyScopeName_s)\",\"size\":1,\"title\":\"Step 1 Select listener scope (click a row)\",\"exportedParameters\":[{\"fieldName\":\"policyScopeName_s\",\"parameterName\":\"PolicyScope\",\"parameterType\":1},{\"fieldName\":\"ResourceId\",\"parameterName\":\"ResourceId\",\"parameterType\":1}],\"queryType\":0,\"resourceType\":\"microsoft.operationalinsights/workspaces\",\"crossComponentResources\":[\"{workspace}\"],\"visualization\":\"table\",\"gridSettings\":{\"formatters\":[{\"columnMatch\":\"$gen_group\",\"formatter\":13,\"formatOptions\":{\"linkTarget\":\"\",\"showIcon\":true}},{\"columnMatch\":\"ResourceId\",\"formatter\":5},{\"columnMatch\":\"Blocked\",\"formatter\":18,\"formatOptions\":{\"thresholdsOptions\":\"colors\",\"thresholdsGrid\":[{\"operator\":\"Default\",\"representation\":\"redBright\",\"text\":\"{0}{1}\"}]}},{\"columnMatch\":\"Detected\",\"formatter\":18,\"formatOptions\":{\"thresholdsOptions\":\"colors\",\"thresholdsGrid\":[{\"operator\":\"Default\",\"representation\":\"yellow\",\"text\":\"{0}{1}\"}]}},{\"columnMatch\":\"Matched\",\"formatter\":18,\"formatOptions\":{\"thresholdsOptions\":\"colors\",\"thresholdsGrid\":[{\"operator\":\"Default\",\"representation\":\"blue\",\"text\":\"{0}{1}\"}]}}],\"rowLimit\":500,\"hierarchySettings\":{\"treeType\":1,\"groupBy\":[\"ResourceId\"]},\"labelSettings\":[{\"columnId\":\"ResourceId\",\"label\":\"App Gateway\"},{\"columnId\":\"policyScopeName_s\",\"label\":\"Policy Scope\"}]}},\"name\":\"ScopeSelection\"},{\"type\":9,\"content\":{\"version\":\"KqlParameterItem/1.0\",\"parameters\":[{\"id\":\"p-wafname-008\",\"version\":\"KqlParameterItem/1.0\",\"name\":\"wafPolicyName\",\"label\":\"WAF Policy\",\"type\":2,\"isRequired\":false,\"query\":\"resources\\r\\n| where type =~ ''microsoft.network/applicationgateways''\\r\\n| where id =~ ''{ResourceId}''\\r\\n| extend gwPol = tostring(properties.firewallPolicy.id)\\r\\n| mv-expand listener = properties.httpListeners\\r\\n| extend listenerName = tostring(listener.name), listenerPol = tostring(listener.properties.firewallPolicy.id)\\r\\n| summarize gwPol = take_any(gwPol), listenerPol = max(iff(listenerName =~ ''{PolicyScope}'', listenerPol, ''''))\\r\\n| extend resolvedPol = iff(isnotempty(listenerPol), listenerPol, gwPol)\\r\\n| project value = tostring(split(resolvedPol, ''/'')[8]), label = tostring(split(resolvedPol, ''/'')[8]), selected = true\",\"crossComponentResources\":[\"{subscription}\"],\"isHiddenWhenLocked\":true,\"typeSettings\":{\"additionalResourceOptions\":[]},\"queryType\":1,\"resourceType\":\"microsoft.resourcegraph/resources\"},{\"id\":\"p-wafrg-009\",\"version\":\"KqlParameterItem/1.0\",\"name\":\"wafPolicyRG\",\"type\":2,\"isRequired\":false,\"query\":\"resources\\r\\n| where type =~ ''microsoft.network/applicationgateways''\\r\\n| where id =~ ''{ResourceId}''\\r\\n| extend gwPol = tostring(properties.firewallPolicy.id)\\r\\n| mv-expand listener = properties.httpListeners\\r\\n| extend listenerName = tostring(listener.name), listenerPol = tostring(listener.properties.firewallPolicy.id)\\r\\n| summarize gwPol = take_any(gwPol), listenerPol = max(iff(listenerName =~ ''{PolicyScope}'', listenerPol, ''''))\\r\\n| extend resolvedPol = iff(isnotempty(listenerPol), listenerPol, gwPol)\\r\\n| project value = tostring(split(resolvedPol, ''/'')[4]), label = tostring(split(resolvedPol, ''/'')[4]), selected = true\",\"crossComponentResources\":[\"{subscription}\"],\"isHiddenWhenLocked\":true,\"typeSettings\":{\"additionalResourceOptions\":[]},\"queryType\":1,\"resourceType\":\"microsoft.resourcegraph/resources\"},{\"id\":\"p-wafid-010\",\"version\":\"KqlParameterItem/1.0\",\"name\":\"wafPolicyId\",\"type\":2,\"isRequired\":false,\"query\":\"resources\\r\\n| where type =~ ''microsoft.network/applicationgateways''\\r\\n| where id =~ ''{ResourceId}''\\r\\n| extend gwPol = tostring(properties.firewallPolicy.id)\\r\\n| mv-expand listener = properties.httpListeners\\r\\n| extend listenerName = tostring(listener.name), listenerPol = tostring(listener.properties.firewallPolicy.id)\\r\\n| summarize gwPol = take_any(gwPol), listenerPol = max(iff(listenerName =~ ''{PolicyScope}'', listenerPol, ''''))\\r\\n| extend resolvedPol = iff(isnotempty(listenerPol), listenerPol, gwPol)\\r\\n| project value = resolvedPol, label = resolvedPol, selected = true\",\"crossComponentResources\":[\"{subscription}\"],\"isHiddenWhenLocked\":true,\"typeSettings\":{\"additionalResourceOptions\":[]},\"queryType\":1,\"resourceType\":\"microsoft.resourcegraph/resources\"}],\"style\":\"pills\",\"queryType\":1,\"resourceType\":\"microsoft.resourcegraph/resources\"},\"conditionalVisibility\":{\"parameterName\":\"ResourceId\",\"comparison\":\"isNotEqualTo\"},\"name\":\"WafPolicyResolver\"},{\"type\":1,\"content\":{\"json\":\"i **Scope precedence:** When a WAF policy is assigned at both the gateway level (*Global*) and an individual listener, the **listener-level policy takes precedence**. WAF logs will only record hits against the listener scope; the Global row will show 0 hits in that case.\\n\\n### Selected WAF Policy: **{wafPolicyName}**\\n| | |\\n|---|---|\\n| **Resource Group** | {wafPolicyRG} |\"},\"conditionalVisibility\":{\"parameterName\":\"ResourceId\",\"comparison\":\"isNotEqualTo\"},\"name\":\"ScopePrecedenceNote\"},{\"type\":11,\"content\":{\"version\":\"LinkItem/1.0\",\"style\":\"tabs\",\"links\":[{\"id\":\"tab-auto\",\"cellValue\":\"SelectedTab\",\"linkTarget\":\"parameter\",\"linkLabel\":\">> Auto-Tuning\",\"subTarget\":\"auto-exclusion\",\"style\":\"link\",\"preText\":\"\"},{\"id\":\"tab-lookup\",\"cellValue\":\"SelectedTab\",\"linkTarget\":\"parameter\",\"linkLabel\":\">> Quick Lookup\",\"subTarget\":\"quick-lookup\",\"style\":\"link\"},{\"id\":\"tab-overview\",\"cellValue\":\"SelectedTab\",\"linkTarget\":\"parameter\",\"linkLabel\":\" Overview\",\"subTarget\":\"overview\",\"style\":\"link\"}]},\"conditionalVisibility\":{\"parameterName\":\"PolicyScope\",\"comparison\":\"isNotEqualTo\"},\"name\":\"TabNavigation\"},{\"type\":12,\"content\":{\"version\":\"NotebookGroup/1.0\",\"groupType\":\"editable\",\"items\":[{\"type\":1,\"content\":{\"json\":\"## Detected Tuning Candidates\\nThis view uses **anomaly scoring awareness** and an **evidence-based FP Confidence score**. It traces blocked transactions back to contributing `Matched` rules, groups candidates by (Rule, MatchVariable, Selector), then scores each pattern using transaction evidence, breadth, recurrence, source/URI concentration, selector quality, and mitigation safety.\\n\\nMandatory blocking-evaluation rules (949/959/980) are automatically filtered out since they cannot be excluded.\\n\\n> Select a row to see the impact preview, then click **Create Exclusion** or **Disable Rule** after reviewing the evidence.\"},\"name\":\"AutoHeader\"},{\"type\":3,\"content\":{\"version\":\"KqlItem/1.0\",\"query\":\"resources\\r\\n| where type =~ ''microsoft.network/applicationgatewaywebapplicationfirewallpolicies''\\r\\n| where id =~ ''{wafPolicyId}''\\r\\n| mv-expand exclusion = properties.managedRules.exclusions\\r\\n| extend MatchVariable = tostring(exclusion.matchVariable),\\r\\n Operator = tostring(exclusion.selectorMatchOperator),\\r\\n Selector = tostring(exclusion.selector),\\r\\n Scope = iff(array_length(exclusion.exclusionManagedRuleSets) > 0, ''Per-Rule'', ''Global'')\\r\\n| mv-expand ruleSet = exclusion.exclusionManagedRuleSets\\r\\n| mv-expand ruleGroup = ruleSet.ruleGroups\\r\\n| mv-expand rule = ruleGroup.rules\\r\\n| project MatchVariable, Operator, Selector, Scope,\\r\\n RuleSetType = tostring(ruleSet.ruleSetType),\\r\\n RuleSetVersion = tostring(ruleSet.ruleSetVersion),\\r\\n RuleGroupName = tostring(ruleGroup.ruleGroupName),\\r\\n RuleId = tostring(rule.ruleId)\",\"size\":1,\"title\":\" Existing exclusions on this WAF policy (already configured)\",\"noDataMessage\":\"No exclusions configured yet on this WAF policy.\",\"queryType\":1,\"resourceType\":\"microsoft.resourcegraph/resources\",\"crossComponentResources\":[\"{subscription}\"],\"visualization\":\"table\",\"gridSettings\":{\"formatters\":[{\"columnMatch\":\"Scope\",\"formatter\":18,\"formatOptions\":{\"thresholdsOptions\":\"icons\",\"thresholdsGrid\":[{\"operator\":\"==\",\"thresholdValue\":\"Global\",\"representation\":\"warning\",\"text\":\"{0}{1}\"},{\"operator\":\"Default\",\"representation\":\"success\",\"text\":\"{0}{1}\"}]}}],\"filter\":true,\"labelSettings\":[{\"columnId\":\"MatchVariable\",\"label\":\"Match Variable\"},{\"columnId\":\"Operator\",\"label\":\"Operator\"},{\"columnId\":\"Selector\",\"label\":\"Selector\"},{\"columnId\":\"Scope\",\"label\":\"Scope\"},{\"columnId\":\"RuleSetType\",\"label\":\"Rule Set\"},{\"columnId\":\"RuleGroupName\",\"label\":\"Rule Group\"},{\"columnId\":\"RuleId\",\"label\":\"Rule ID\"}]}},\"name\":\"ExistingExclusions\"},{\"type\":3,\"content\":{\"version\":\"KqlItem/1.0\",\"query\":\"// Evidence-based FP Confidence: trace blocked transactions to contributing Matched rules, then score breadth, recurrence, concentration, selector quality, and mitigation safety\\nlet blockedTxns = AzureDiagnostics\\n| where Category == \\\"ApplicationGatewayFirewallLog\\\"\\n| where TimeGenerated {detectionTime}\\n| where ResourceId == ''{ResourceId}'' and policyScopeName_s == ''{PolicyScope}''\\n| extend TxnId = coalesce(tostring(column_ifexists(\\\"transactionId_g\\\", \\\"\\\")), tostring(column_ifexists(\\\"transactionId_s\\\", \\\"\\\")))\\n| where action_s == \\\"Blocked\\\" and isnotempty(TxnId)\\n| distinct TxnId;\\nlet parsed = AzureDiagnostics\\n| where Category == \\\"ApplicationGatewayFirewallLog\\\"\\n| where TimeGenerated {detectionTime}\\n| where ResourceId == ''{ResourceId}'' and policyScopeName_s == ''{PolicyScope}''\\n| extend TxnId = coalesce(tostring(column_ifexists(\\\"transactionId_g\\\", \\\"\\\")), tostring(column_ifexists(\\\"transactionId_s\\\", \\\"\\\")))\\n| where (action_s == \\\"Matched\\\" and TxnId in (blockedTxns))\\n or (action_s in ({actionFilter}) and ruleId_s != \\\"0\\\")\\n// Filter out mandatory blocking-evaluation rules (949/959/980) they cannot be excluded or disabled\\n| where not(ruleId_s startswith \\\"949\\\") and not(ruleId_s startswith \\\"980\\\") and not(ruleId_s startswith \\\"959\\\")\\n| where ruleId_s != \\\"0\\\"\\n| where isnotempty(details_message_s) or isnotempty(details_data_s)\\n// Parse match variable and selector from BOTH details_message_s and details_data_s\\n| extend MatchInfo = coalesce(details_message_s, \\\"\\\")\\n| extend DataInfo = coalesce(details_data_s, \\\"\\\")\\n| extend LogMatchVariable = coalesce(extract(@''at\\\\s+(\\\\w+)[:\\\\.\\\\s]'', 1, MatchInfo), extract(@''\\\\[(\\\\w+):'', 1, DataInfo), \\\"\\\")\\n| extend MatchVariable = case(\\n MatchInfo has \\\"ARGS_NAMES\\\", \\\"RequestArgKeys\\\",\\n MatchInfo has \\\"REQUEST_COOKIES_NAMES\\\", \\\"RequestCookieKeys\\\",\\n MatchInfo has \\\"REQUEST_HEADERS_NAMES\\\", \\\"RequestHeaderKeys\\\",\\n MatchInfo matches regex @''ARGS_GET[:\\\\.]'', \\\"RequestArgValues\\\",\\n MatchInfo matches regex @''ARGS_POST[:\\\\.]'', \\\"RequestArgValues\\\",\\n MatchInfo matches regex @''ARGS[:\\\\.]'', \\\"RequestArgValues\\\",\\n MatchInfo matches regex @''REQUEST_HEADERS[:\\\\.]'', \\\"RequestHeaderValues\\\",\\n MatchInfo matches regex @''REQUEST_COOKIES[:\\\\.]'', \\\"RequestCookieValues\\\",\\n MatchInfo has \\\"REQUEST_BODY\\\", \\\"RequestArgValues\\\",\\n MatchInfo has \\\"REQUEST_URI\\\", \\\"DisableRule\\\",\\n MatchInfo has \\\"REQUEST_BASENAME\\\", \\\"DisableRule\\\",\\n MatchInfo has \\\"REQUEST_FILENAME\\\", \\\"DisableRule\\\",\\n MatchInfo has \\\"MULTIPART_STRICT_ERROR\\\", \\\"DisableRule\\\",\\n MatchInfo has \\\"REQUEST_METHOD\\\", \\\"DisableRule\\\",\\n MatchInfo has \\\"REQUEST_PROTOCOL\\\", \\\"DisableRule\\\",\\n MatchInfo has \\\"XML:\\\", \\\"DisableRule\\\",\\n DataInfo matches regex @''\\\\[ARGS_NAMES:'', \\\"RequestArgKeys\\\",\\n DataInfo matches regex @''\\\\[REQUEST_COOKIES_NAMES:'', \\\"RequestCookieKeys\\\",\\n DataInfo matches regex @''\\\\[REQUEST_HEADERS_NAMES:'', \\\"RequestHeaderKeys\\\",\\n DataInfo matches regex @''\\\\[ARGS_GET:'', \\\"RequestArgValues\\\",\\n DataInfo matches regex @''\\\\[ARGS_POST:'', \\\"RequestArgValues\\\",\\n DataInfo matches regex @''\\\\[ARGS:'', \\\"RequestArgValues\\\",\\n DataInfo matches regex @''\\\\[REQUEST_HEADERS:'', \\\"RequestHeaderValues\\\",\\n DataInfo matches regex @''\\\\[REQUEST_COOKIES:'', \\\"RequestCookieValues\\\",\\n DataInfo has \\\"REQUEST_BODY\\\", \\\"RequestArgValues\\\",\\n DataInfo has \\\"REQUEST_URI\\\", \\\"DisableRule\\\",\\n DataInfo has \\\"REQUEST_BASENAME\\\", \\\"DisableRule\\\",\\n DataInfo has \\\"REQUEST_FILENAME\\\", \\\"DisableRule\\\",\\n DataInfo has \\\"XML:\\\", \\\"DisableRule\\\",\\n \\\"\\\")\\n| extend Selector = case(\\n isnotempty(extract(@''ARGS_NAMES:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo)), extract(@''ARGS_NAMES:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo),\\n isnotempty(extract(@''ARGS_GET:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo)), extract(@''ARGS_GET:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo),\\n isnotempty(extract(@''ARGS_POST:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo)), extract(@''ARGS_POST:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo),\\n isnotempty(extract(@''ARGS:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo)), extract(@''ARGS:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo),\\n isnotempty(extract(@''REQUEST_HEADERS_NAMES:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo)), extract(@''REQUEST_HEADERS_NAMES:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo),\\n isnotempty(extract(@''REQUEST_HEADERS:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo)), extract(@''REQUEST_HEADERS:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo),\\n isnotempty(extract(@''REQUEST_COOKIES_NAMES:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo)), extract(@''REQUEST_COOKIES_NAMES:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo),\\n isnotempty(extract(@''REQUEST_COOKIES:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo)), extract(@''REQUEST_COOKIES:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo),\\n isnotempty(extract(@''REQUEST_BODY:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo)), extract(@''REQUEST_BODY:([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo),\\n isnotempty(extract(@''\\\\[ARGS_NAMES:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo)), extract(@''\\\\[ARGS_NAMES:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo),\\n isnotempty(extract(@''\\\\[ARGS_GET:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo)), extract(@''\\\\[ARGS_GET:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo),\\n isnotempty(extract(@''\\\\[ARGS_POST:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo)), extract(@''\\\\[ARGS_POST:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo),\\n isnotempty(extract(@''\\\\[ARGS:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo)), extract(@''\\\\[ARGS:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo),\\n isnotempty(extract(@''\\\\[REQUEST_HEADERS_NAMES:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo)), extract(@''\\\\[REQUEST_HEADERS_NAMES:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo),\\n isnotempty(extract(@''\\\\[REQUEST_HEADERS:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo)), extract(@''\\\\[REQUEST_HEADERS:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo),\\n isnotempty(extract(@''\\\\[REQUEST_COOKIES_NAMES:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo)), extract(@''\\\\[REQUEST_COOKIES_NAMES:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo),\\n isnotempty(extract(@''\\\\[REQUEST_COOKIES:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo)), extract(@''\\\\[REQUEST_COOKIES:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo),\\n isnotempty(extract(@''\\\\[REQUEST_BODY:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo)), extract(@''\\\\[REQUEST_BODY:([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo),\\n \\\"\\\")\\n| extend ActionType = iff(MatchVariable == \\\"DisableRule\\\", \\\"disableRule\\\", \\\"createExclusion\\\")\\n| where isnotempty(MatchVariable) and (MatchVariable == \\\"DisableRule\\\" or isnotempty(Selector))\\n// Filter out selectors that are clearly attack payloads (XSS, injection)\\n| where MatchVariable == \\\"DisableRule\\\" or (Selector !contains \\\"<\\\" and Selector !contains \\\">\\\" and Selector !contains \\\"script\\\" and Selector !contains \\\"alert(\\\" and Selector !contains \\\";\\\" and Selector !contains \\\"/\\\")\\n| extend NormRuleSetType = case(ruleSetType_s has \\\"OWASP\\\", \\\"OWASP\\\", ruleSetType_s)\\n| extend IsBlockedTxn = TxnId in (blockedTxns);\\nlet base = parsed\\n| summarize\\n HitCount = count(),\\n UniqueTransactions = dcount(TxnId),\\n BlockedTransactions = dcountif(TxnId, IsBlockedTxn),\\n MatchedTransactions = dcountif(TxnId, action_s == \\\"Matched\\\"),\\n DetectedTransactions = dcountif(TxnId, action_s == \\\"Detected\\\"),\\n URIs = dcount(requestUri_s),\\n IPs = dcount(clientIp_s),\\n Hosts = dcount(hostname_s),\\n ActiveHours = dcount(bin(TimeGenerated, 1h)),\\n ActiveDays = dcount(startofday(TimeGenerated)),\\n SampleURIs = make_set(requestUri_s, 5),\\n SampleData = make_set(details_data_s, 3),\\n WindowStart = min(TimeGenerated),\\n WindowEnd = max(TimeGenerated),\\n RuleGroup = take_any(ruleGroup_s),\\n RuleSetVersion = take_any(ruleSetVersion_s),\\n NormRuleSetType = take_any(NormRuleSetType),\\n SampleMsg = take_any(Message),\\n LogMatchVariable = take_any(LogMatchVariable),\\n ActionType = take_any(ActionType)\\n by ruleId_s, MatchVariable, Selector;\\nlet ipConcentration = parsed\\n| summarize IPHits = count() by ruleId_s, MatchVariable, Selector, clientIp_s\\n| summarize TopIPHits = max(IPHits) by ruleId_s, MatchVariable, Selector;\\nlet uriConcentration = parsed\\n| summarize URIHits = count() by ruleId_s, MatchVariable, Selector, requestUri_s\\n| summarize TopURIHits = max(URIHits) by ruleId_s, MatchVariable, Selector;\\nbase\\n| join kind=leftouter ipConcentration on ruleId_s, MatchVariable, Selector\\n| join kind=leftouter uriConcentration on ruleId_s, MatchVariable, Selector\\n| extend TopIPShare = iff(HitCount == 0, 0.0, round(todouble(TopIPHits) / todouble(HitCount), 2)),\\n TopURIShare = iff(HitCount == 0, 0.0, round(todouble(TopURIHits) / todouble(HitCount), 2))\\n| extend WindowHours = max_of(1, datetime_diff(''hour'', WindowEnd, WindowStart)),\\n WindowDays = max_of(1, datetime_diff(''day'', WindowEnd, WindowStart))\\n| extend HoursRatio = round(todouble(ActiveHours) / todouble(WindowHours), 2),\\n DaysRatio = round(todouble(ActiveDays) / todouble(WindowDays), 2),\\n DailyRate = round(todouble(UniqueTransactions) / todouble(max_of(1, ActiveDays)), 1)\\n| extend TraceScore = case(BlockedTransactions > 0 and MatchedTransactions > 0, 15, BlockedTransactions > 0, 10, DetectedTransactions > 0, 5, 0)\\n| extend DailyURIs = round(todouble(URIs) / todouble(max_of(1, ActiveDays)), 1),\\n DailyIPs = round(todouble(IPs) / todouble(max_of(1, ActiveDays)), 1)\\n| extend BreadthScore = case(DailyURIs > 10 and DailyIPs > 3, 25, DailyURIs > 5 or DailyIPs > 2, 18, DailyURIs > 2, 10, DailyURIs > 1, 5, 0)\\n| extend RecurrenceScore = case(HoursRatio > 0.50 and DaysRatio >= 0.50, 10, HoursRatio > 0.25, 7, HoursRatio > 0.10, 3, 0)\\n| extend ConcentrationScore = case(TopIPShare <= 0.20 and TopURIShare <= 0.30, 20, TopIPShare <= 0.35 and TopURIShare <= 0.50, 14, TopIPShare <= 0.60 and TopURIShare <= 0.75, 7, 0)\\n| extend SelectorScore = case(MatchVariable == \\\"DisableRule\\\", 1, isnotempty(Selector) and strlen(Selector) <= 80, 5, isnotempty(Selector), 3, 0)\\n| extend MitigationScore = iff(ActionType == \\\"createExclusion\\\", 5, 1)\\n| extend VolumeScore = case(DailyRate > 50, 20, DailyRate > 20, 15, DailyRate > 10, 10, DailyRate > 3, 5, 0)\\n| extend ConfidenceScoreRaw = TraceScore + BreadthScore + RecurrenceScore + ConcentrationScore + SelectorScore + MitigationScore + VolumeScore\\n| extend ConfidenceScore = iff(ActionType == \\\"disableRule\\\" and ConfidenceScoreRaw > 79, 79, ConfidenceScoreRaw)\\n| extend Confidence = case(\\n ConfidenceScore >= 85, \\\"[VH] Very High\\\",\\n ConfidenceScore >= 70, \\\"[H] High\\\",\\n ConfidenceScore >= 50, \\\"[M] Medium\\\",\\n \\\"[L] Low\\\")\\n| extend ConfidenceReason = strcat(\\n \\\"Trace \\\", TraceScore, \\\"/15; Breadth \\\", BreadthScore, \\\"/25; Recurrence \\\", RecurrenceScore, \\\"/10; Concentration \\\", ConcentrationScore, \\\"/20; Selector \\\", SelectorScore, \\\"/5; Mitigation \\\", MitigationScore, \\\"/5; Volume \\\", VolumeScore, \\\"/20\\\")\\n| extend Blocks = HitCount\\n| extend Coverage = Confidence\\n| extend CreateAction = iff(ActionType == \\\"disableRule\\\", \\\"[X] Disable Rule\\\", \\\"[+] Create Exclusion\\\")\\n| project ruleId_s, MatchVariable, Selector, RuleGroup, NormRuleSetType, RuleSetVersion, ActionType, CreateAction, Confidence, FPScore = ConfidenceScore, ScoreBreakdown = ConfidenceReason, HitCount, Blocks, UniqueTransactions, BlockedTransactions, MatchedTransactions, DetectedTransactions, UriCount = URIs, ClientIPCount = IPs, Hosts, ActiveHours, ActiveDays, TopClientIPShare = TopIPShare, TopRequestURIShare = TopURIShare, SampleMsg, SampleUrls = SampleURIs, SampleData, LogMatchVariable\\n| order by FPScore desc, HitCount desc\",\"size\":0,\"showAnalytics\":true,\"title\":\"Step 2 Tuning candidates ranked by FP Confidence (click a row to preview/apply)\",\"exportedParameters\":[{\"fieldName\":\"ruleId_s\",\"parameterName\":\"SelectedRuleId\",\"parameterType\":1},{\"fieldName\":\"MatchVariable\",\"parameterName\":\"SelectedMatchVar\",\"parameterType\":1},{\"fieldName\":\"Selector\",\"parameterName\":\"SelectedSelector\",\"parameterType\":1},{\"fieldName\":\"RuleGroup\",\"parameterName\":\"SelectedRuleGroup\",\"parameterType\":1},{\"fieldName\":\"NormRuleSetType\",\"parameterName\":\"SelectedRuleSetType\",\"parameterType\":1},{\"fieldName\":\"RuleSetVersion\",\"parameterName\":\"SelectedRuleSetVersion\",\"parameterType\":1},{\"fieldName\":\"HitCount\",\"parameterName\":\"SelectedBlocks\",\"parameterType\":1},{\"fieldName\":\"UriCount\",\"parameterName\":\"SelectedURIs\",\"parameterType\":1},{\"fieldName\":\"SampleMsg\",\"parameterName\":\"SelectedMsg\",\"parameterType\":1},{\"fieldName\":\"LogMatchVariable\",\"parameterName\":\"SelectedLogMatchVar\",\"parameterType\":1},{\"fieldName\":\"ActionType\",\"parameterName\":\"SelectedActionType\",\"parameterType\":1}],\"queryType\":0,\"resourceType\":\"microsoft.operationalinsights/workspaces\",\"crossComponentResources\":[\"{workspace}\"],\"visualization\":\"table\",\"gridSettings\":{\"formatters\":[{\"columnMatch\":\"ruleId_s\",\"formatter\":0,\"formatOptions\":{\"customColumnWidthSetting\":\"8ch\"}},{\"columnMatch\":\"Blocks\",\"formatter\":8,\"formatOptions\":{\"palette\":\"greenRed\"}},{\"columnMatch\":\"UriCount\",\"formatter\":8,\"formatOptions\":{\"palette\":\"blue\"}},{\"columnMatch\":\"ClientIPCount\",\"formatter\":8,\"formatOptions\":{\"palette\":\"blue\"}},{\"columnMatch\":\"Hosts\",\"formatter\":5},{\"columnMatch\":\"SampleUrls\",\"formatter\":5},{\"columnMatch\":\"SampleData\",\"formatter\":5},{\"columnMatch\":\"RuleGroup\",\"formatter\":5},{\"columnMatch\":\"RuleSetVersion\",\"formatter\":5},{\"columnMatch\":\"NormRuleSetType\",\"formatter\":5},{\"columnMatch\":\"SampleMsg\",\"formatter\":5},{\"columnMatch\":\"Confidence\",\"formatter\":18,\"formatOptions\":{\"thresholdsOptions\":\"colors\",\"thresholdsGrid\":[{\"operator\":\"contains\",\"thresholdValue\":\"Very High\",\"representation\":\"redBright\",\"text\":\"{0}{1}\"},{\"operator\":\"contains\",\"thresholdValue\":\"High\",\"representation\":\"orange\",\"text\":\"{0}{1}\"},{\"operator\":\"contains\",\"thresholdValue\":\"Medium\",\"representation\":\"yellow\",\"text\":\"{0}{1}\"},{\"operator\":\"Default\",\"representation\":\"green\",\"text\":\"{0}{1}\"}]}},{\"columnMatch\":\"CreateAction\",\"formatter\":7,\"formatOptions\":{\"linkTarget\":\"ArmAction\",\"linkLabel\":\"\",\"linkIsContextBlade\":true,\"armActionContext\":{\"path\":\"{logicApp}/triggers/manual/run?api-version=2016-10-01\",\"headers\":[{\"key\":\"Content-Type\",\"value\":\"application/json\"}],\"params\":[],\"body\":\"{\\n \\\"action\\\": \\\"{SelectedActionType}\\\",\\n \\\"resourceGroupName\\\": \\\"{wafPolicyRG}\\\",\\n \\\"wafPolicyName\\\": \\\"{wafPolicyName}\\\",\\n \\\"ruleId\\\": \\\"{SelectedRuleId}\\\",\\n \\\"ruleGroupName\\\": \\\"{SelectedRuleGroup}\\\",\\n \\\"ruleSetType\\\": \\\"{SelectedRuleSetType}\\\",\\n \\\"ruleSetVersion\\\": \\\"{SelectedRuleSetVersion}\\\",\\n \\\"matchVariable\\\": \\\"{SelectedMatchVar}\\\",\\n \\\"selectorMatchOperator\\\": \\\"Equals\\\",\\n \\\"selector\\\": \\\"{SelectedSelector}\\\",\\n \\\"description\\\": \\\"Auto-created from WAF Triage Workbook: Rule {SelectedRuleId} {SelectedActionType} ({SelectedBlocks} hits, {SelectedURIs} URIs)\\\"\\n}\",\"httpMethod\":\"POST\",\"title\":\"WAF Policy Change\",\"description\":\"**Action:** {SelectedActionType}\\n\\nThis will apply a change to WAF policy **{wafPolicyName}**:\\n\\n| Setting | Value |\\n|---------|-------|\\n| Action | {SelectedActionType} |\\n| Rule ID | {SelectedRuleId} |\\n| Rule Group | {SelectedRuleGroup} |\\n| Match Variable | {SelectedMatchVar} |\\n| Selector | {SelectedSelector} |\\n| Log Variable | {SelectedLogMatchVar} |\\n\\n**Impact:** Fixes {SelectedBlocks} hits across {SelectedURIs} URIs\\n\\n This modifies your WAF policy. The change applies immediately.\",\"actionName\":\"CreateWafExclusion\",\"runLabel\":\"Confirm & Apply\"}}}],\"filter\":true,\"sortBy\":[{\"itemKey\":\"FPScore\",\"sortOrder\":2}],\"labelSettings\":[{\"columnId\":\"ruleId_s\",\"label\":\"Rule ID\"},{\"columnId\":\"MatchVariable\",\"label\":\"Match Variable\"},{\"columnId\":\"Selector\",\"label\":\"Selector\"},{\"columnId\":\"Blocks\",\"label\":\"Hit Count\"},{\"columnId\":\"UriCount\",\"label\":\"Unique URIs\"},{\"columnId\":\"ClientIPCount\",\"label\":\"Unique IPs\"},{\"columnId\":\"CreateAction\",\"label\":\"Action\"},{\"columnId\":\"Confidence\",\"label\":\"FP Confidence\"},{\"columnId\":\"FPScore\",\"label\":\"Score\"},{\"columnId\":\"UniqueTransactions\",\"label\":\"Transactions\"},{\"columnId\":\"BlockedTransactions\",\"label\":\"Blocked Txns\"},{\"columnId\":\"MatchedTransactions\",\"label\":\"Matched Txns\"},{\"columnId\":\"ActiveHours\",\"label\":\"Active Hours\"},{\"columnId\":\"TopClientIPShare\",\"label\":\"Top IP Share\"},{\"columnId\":\"TopRequestURIShare\",\"label\":\"Top URI Share\"},{\"columnId\":\"ScoreBreakdown\",\"label\":\"Confidence Reason\"}]},\"sortBy\":[{\"itemKey\":\"FPScore\",\"sortOrder\":2}]},\"name\":\"ExclusionCandidates\"},{\"type\":1,\"content\":{\"json\":\"---\\n## Impact Preview for Rule {SelectedRuleId}\\n**Pattern:** `{SelectedMatchVar}` with selector `{SelectedSelector}`\\n\\n**Message:** {SelectedMsg}\\n\\nThe table below shows sample WAF events that match this candidate. Review before applying any change.\"},\"conditionalVisibility\":{\"parameterName\":\"SelectedRuleId\",\"comparison\":\"isNotEqualTo\"},\"name\":\"ImpactPreviewHeader\"},{\"type\":3,\"content\":{\"version\":\"KqlItem/1.0\",\"query\":\"AzureDiagnostics\\r\\n| where Category == \\\"ApplicationGatewayFirewallLog\\\"\\r\\n| where TimeGenerated {detectionTime}\\r\\n| where ResourceId == ''{ResourceId}'' and policyScopeName_s == ''{PolicyScope}''\\r\\n| where ruleId_s == ''{SelectedRuleId}''\\r\\n| where (\\\"{SelectedSelector}\\\" == \\\"\\\" or details_message_s contains \\\"{SelectedSelector}\\\" or details_data_s contains \\\"{SelectedSelector}\\\")\\r\\n| project TimeGenerated, hostname_s, requestUri_s, clientIp_s, action_s, details_data_s, details_message_s\\r\\n| order by TimeGenerated desc\\r\\n| take 30\",\"size\":0,\"title\":\"Blocked requests that would be fixed by this change (sample of up to 30)\",\"queryType\":0,\"resourceType\":\"microsoft.operationalinsights/workspaces\",\"crossComponentResources\":[\"{workspace}\"],\"visualization\":\"table\",\"gridSettings\":{\"formatters\":[{\"columnMatch\":\"action_s\",\"formatter\":18,\"formatOptions\":{\"thresholdsOptions\":\"icons\",\"thresholdsGrid\":[{\"operator\":\"==\",\"thresholdValue\":\"Blocked\",\"representation\":\"4\",\"text\":\"{0}{1}\"},{\"operator\":\"==\",\"thresholdValue\":\"Matched\",\"representation\":\"2\",\"text\":\"{0}{1}\"},{\"operator\":\"Default\",\"thresholdValue\":\"\",\"representation\":\"more\",\"text\":\"{0}{1}\"}]}}],\"filter\":true,\"labelSettings\":[{\"columnId\":\"TimeGenerated\",\"label\":\"Time\"},{\"columnId\":\"hostname_s\",\"label\":\"Host\"},{\"columnId\":\"requestUri_s\",\"label\":\"URI\"},{\"columnId\":\"clientIp_s\",\"label\":\"Client IP\"},{\"columnId\":\"action_s\",\"label\":\"Action\"},{\"columnId\":\"details_data_s\",\"label\":\"Matched Data\"},{\"columnId\":\"details_message_s\",\"label\":\"Details\"}]}},\"conditionalVisibility\":{\"parameterName\":\"SelectedRuleId\",\"comparison\":\"isNotEqualTo\"},\"name\":\"ImpactPreviewGrid\"},{\"type\":1,\"content\":{\"json\":\"---\\n## Apply WAF Change\\n\\nReview the details below, then click the button to apply the change automatically.\\n\\n| Setting | Value |\\n|---------|-------|\\n| **Action** | `{SelectedActionType}` |\\n| **WAF Policy** | `{wafPolicyName}` (RG: `{wafPolicyRG}`) |\\n| **Rule ID** | `{SelectedRuleId}` |\\n| **Rule Group** | `{SelectedRuleGroup}` |\\n| **Rule Set** | `{SelectedRuleSetType}` v`{SelectedRuleSetVersion}` |\\n| **Log Variable** | `{SelectedLogMatchVar}` |\\n| **Match Variable** | `{SelectedMatchVar}` |\\n| **Selector** | `{SelectedSelector}` |\\n| **Evidence** | **{SelectedBlocks}** hits across **{SelectedURIs}** URIs |\"},\"conditionalVisibility\":{\"parameterName\":\"SelectedRuleId\",\"comparison\":\"isNotEqualTo\"},\"name\":\"CreateExclusionReview\"},{\"type\":11,\"content\":{\"version\":\"LinkItem/1.0\",\"style\":\"nav\",\"links\":[{\"id\":\"create-exclusion-btn\",\"linkTarget\":\"ArmAction\",\"linkLabel\":\">> Apply Change for Rule {SelectedRuleId}\",\"style\":\"primary\",\"linkIsContextBlade\":true,\"armActionContext\":{\"path\":\"{logicApp}/triggers/manual/run?api-version=2016-10-01\",\"headers\":[{\"key\":\"Content-Type\",\"value\":\"application/json\"}],\"params\":[],\"body\":\"{\\n \\\"action\\\": \\\"{SelectedActionType}\\\",\\n \\\"resourceGroupName\\\": \\\"{wafPolicyRG}\\\",\\n \\\"wafPolicyName\\\": \\\"{wafPolicyName}\\\",\\n \\\"ruleId\\\": \\\"{SelectedRuleId}\\\",\\n \\\"ruleGroupName\\\": \\\"{SelectedRuleGroup}\\\",\\n \\\"ruleSetType\\\": \\\"{SelectedRuleSetType}\\\",\\n \\\"ruleSetVersion\\\": \\\"{SelectedRuleSetVersion}\\\",\\n \\\"matchVariable\\\": \\\"{SelectedMatchVar}\\\",\\n \\\"selectorMatchOperator\\\": \\\"Equals\\\",\\n \\\"selector\\\": \\\"{SelectedSelector}\\\",\\n \\\"description\\\": \\\"Auto-created from WAF Triage Workbook: Rule {SelectedRuleId} {SelectedActionType} ({SelectedBlocks} hits, {SelectedURIs} URIs)\\\"\\n}\",\"httpMethod\":\"POST\",\"title\":\"WAF Policy Change\",\"description\":\"**Action:** {SelectedActionType}\\n\\nThis will apply a change to WAF policy **{wafPolicyName}**:\\n\\n| Setting | Value |\\n|---------|-------|\\n| Action | {SelectedActionType} |\\n| Rule | {SelectedRuleId} |\\n| Log Variable | {SelectedLogMatchVar} |\\n| Match Variable | {SelectedMatchVar} |\\n| Selector | {SelectedSelector} |\\n\\n**Impact:** Fixes {SelectedBlocks} hits across {SelectedURIs} URIs\\n\\nThe Logic App will trigger a Runbook that applies the change to your WAF policy. This takes effect immediately.\",\"actionName\":\"CreateWafExclusion\",\"runLabel\":\"Confirm & Apply\"}}]},\"conditionalVisibility\":{\"parameterName\":\"SelectedRuleId\",\"comparison\":\"isNotEqualTo\"},\"name\":\"CreateExclusionButton\"},{\"type\":1,\"content\":{\"json\":\"---\\n### i How it works\\n1. Click **Create Exclusion** or **Disable Rule** on any pattern row (or select a row and use the button below)\\n2. A confirmation dialog shows you exactly what will be changed\\n3. Click **Confirm & Apply** to trigger the automation\\n4. The Logic App starts a Runbook that applies the change to your WAF policy\\n5. The change takes effect immediately no App Gateway restart needed\\n\\n**Actions:**\\n- ** Create Exclusion** adds a per-rule exclusion for the specific match variable and selector (e.g., exclude `Host` header from rule 920350)\\n- **[X] Disable Rule** disables the entire rule when the match variable cannot be excluded (e.g., REQUEST_URI, REQUEST_BODY matches)\\n\\n**Check status:** Open the [Logic App](https://portal.azure.com/#resource{logicApp}/logicApp) to see execution history.\\n\\n---\\n### Anomaly Scoring\\nAzure WAF uses anomaly scoring: individual rules with `action=Matched` increment a score, and when the total exceeds the threshold, mandatory rules (949/980) issue the `Blocked` action. This workbook automatically traces blocked transactions back to the contributing Matched rules, so you see the **actual rules to exclude** rather than the un-excludable blocking rules.\\n\\n---\\n### Global Parameters\\nSome false positives can also be fixed by adjusting WAF policy settings:\\n- **Disable request body inspection** if request bodies are trusted\\n- **Increase max request body limit** for apps with large POST payloads\\n- **Increase file upload limit** for apps allowing large file uploads\\n\\nThese are set under WAF Policy Policy Settings.\"},\"name\":\"HowItWorks\"}],\"exportParameters\":true},\"conditionalVisibility\":{\"parameterName\":\"SelectedTab\",\"comparison\":\"isEqualTo\",\"value\":\"auto-exclusion\"},\"name\":\"AutoExclusionTab\"},{\"conditionalVisibility\":{\"parameterName\":\"SelectedTab\",\"value\":\"quick-lookup\",\"comparison\":\"isEqualTo\"},\"type\":12,\"content\":{\"groupType\":\"editable\",\"version\":\"NotebookGroup/1.0\",\"exportParameters\":true,\"items\":[{\"type\":9,\"content\":{\"style\":\"pills\",\"resourceType\":\"microsoft.operationalinsights/workspaces\",\"version\":\"KqlParameterItem/1.0\",\"queryType\":0,\"parameters\":[{\"type\":1,\"description\":\"Paste a WAF transaction ID from logs or a support ticket to look up the exact request and create an exclusion.\",\"version\":\"KqlParameterItem/1.0\",\"value\":\"\",\"name\":\"LookupTxnId\",\"id\":\"p-txn-lookup\",\"label\":\"Transaction ID\",\"isRequired\":false}]},\"name\":\"LookupParameters\"},{\"type\":1,\"content\":{\"json\":\"## Quick Transaction Lookup\\nPaste a **Transaction ID** from WAF logs or a support ticket above. This shows all WAF rule events for that specific request so you can review the match details and create an exclusion with one click.\\\\n\\\\n> **Important:** The exclusion will be applied to the WAF policy selected in **Step 1** above. Make sure you selected the correct Application Gateway and listener scope before applying a fix.\\n\\n> **Tip:** Find the transaction ID in WAF firewall logs (`transactionId` field) or in the Azure portal under Application Gateway > WAF logs.\"},\"name\":\"LookupHeader\"},{\"conditionalVisibility\":{\"parameterName\":\"LookupTxnId\",\"comparison\":\"isNotEqualTo\"},\"type\":3,\"content\":{\"size\":0,\"query\":\"AzureDiagnostics\\n| where Category == \\\"ApplicationGatewayFirewallLog\\\"\\n| where TimeGenerated {detectionTime}\\n| where \\\"{LookupTxnId}\\\" != \\\"\\\"\\n| extend TxnId = coalesce(tostring(column_ifexists(\\\"transactionId_g\\\", \\\"\\\")), tostring(column_ifexists(\\\"transactionId_s\\\", \\\"\\\")))\\n| where TxnId == \\\"{LookupTxnId}\\\"\\n| where ruleId_s != \\\"0\\\"\\n| where not(ruleId_s startswith \\\"949\\\") and not(ruleId_s startswith \\\"980\\\") and not(ruleId_s startswith \\\"959\\\")\\n| extend MatchInfo = coalesce(details_message_s, \\\"\\\")\\n| extend DataInfo = coalesce(details_data_s, \\\"\\\")\\n| extend MatchVariable = case(\\n MatchInfo has \\\"ARGS_NAMES\\\", \\\"RequestArgKeys\\\",\\n MatchInfo has \\\"REQUEST_COOKIES_NAMES\\\", \\\"RequestCookieKeys\\\",\\n MatchInfo has \\\"REQUEST_HEADERS_NAMES\\\", \\\"RequestHeaderKeys\\\",\\n MatchInfo matches regex @''ARGS_GET[:\\\\.]'', \\\"RequestArgValues\\\",\\n MatchInfo matches regex @''ARGS_POST[:\\\\.]'', \\\"RequestArgValues\\\",\\n MatchInfo matches regex @''ARGS[:\\\\.]'', \\\"RequestArgValues\\\",\\n MatchInfo matches regex @''REQUEST_HEADERS[:\\\\.]'', \\\"RequestHeaderValues\\\",\\n MatchInfo matches regex @''REQUEST_COOKIES[:\\\\.]'', \\\"RequestCookieValues\\\",\\n MatchInfo has \\\"REQUEST_BODY\\\", \\\"RequestArgValues\\\",\\n MatchInfo has \\\"REQUEST_URI\\\", \\\"DisableRule\\\",\\n MatchInfo has \\\"REQUEST_BASENAME\\\", \\\"DisableRule\\\",\\n MatchInfo has \\\"REQUEST_FILENAME\\\", \\\"DisableRule\\\",\\n MatchInfo has \\\"MULTIPART_STRICT_ERROR\\\", \\\"DisableRule\\\",\\n MatchInfo has \\\"REQUEST_METHOD\\\", \\\"DisableRule\\\",\\n MatchInfo has \\\"REQUEST_PROTOCOL\\\", \\\"DisableRule\\\",\\n MatchInfo has \\\"XML:\\\", \\\"DisableRule\\\",\\n DataInfo matches regex @''\\\\[ARGS:'', \\\"RequestArgValues\\\",\\n DataInfo matches regex @''\\\\[REQUEST_HEADERS:'', \\\"RequestHeaderValues\\\",\\n DataInfo matches regex @''\\\\[REQUEST_COOKIES:'', \\\"RequestCookieValues\\\",\\n \\\"\\\")\\n| extend Selector = case(\\n isnotempty(extract(@''(?:ARGS|ARGS_GET|ARGS_POST|ARGS_NAMES|REQUEST_HEADERS|REQUEST_HEADERS_NAMES|REQUEST_COOKIES|REQUEST_COOKIES_NAMES|REQUEST_BODY):([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo)), extract(@''(?:ARGS|ARGS_GET|ARGS_POST|ARGS_NAMES|REQUEST_HEADERS|REQUEST_HEADERS_NAMES|REQUEST_COOKIES|REQUEST_COOKIES_NAMES|REQUEST_BODY):([^\\\\s\\\\.\\\\):]+)'', 1, MatchInfo),\\n isnotempty(extract(@''\\\\[(?:ARGS|REQUEST_HEADERS|REQUEST_COOKIES):([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo)), extract(@''\\\\[(?:ARGS|REQUEST_HEADERS|REQUEST_COOKIES):([^\\\\]:\\\\s\\\\]]+)'', 1, DataInfo),\\n \\\"\\\")\\n| extend ActionType = iff(MatchVariable == \\\"DisableRule\\\", \\\"disableRule\\\", \\\"createExclusion\\\")\\n// Filter out selectors that are clearly attack payloads\\n| where MatchVariable == \\\"DisableRule\\\" or isempty(Selector) or (Selector !contains \\\"<\\\" and Selector !contains \\\">\\\" and Selector !contains \\\"script\\\" and Selector !contains \\\"alert(\\\" and Selector !contains \\\";\\\" and Selector !contains \\\"/\\\")\\n| extend NormRuleSetType = case(ruleSetType_s has \\\"OWASP\\\", \\\"OWASP\\\", ruleSetType_s)\\n| extend LogMatchVariable = coalesce(extract(@''at\\\\s+(\\\\w+)[:\\\\.\\\\s]'', 1, MatchInfo), extract(@''\\\\[(\\\\w+):'', 1, DataInfo), \\\"\\\")\\n| project TimeGenerated, ruleId_s, ruleGroup_s, ruleSetVersion_s, NormRuleSetType, action_s, MatchVariable, Selector, ActionType, LogMatchVariable, requestUri_s, clientIp_s, hostname_s, details_message_s, details_data_s\\n| order by ruleId_s asc\",\"crossComponentResources\":[\"{workspace}\"],\"resourceType\":\"microsoft.operationalinsights/workspaces\",\"visualization\":\"table\",\"gridSettings\":{\"formatters\":[{\"formatter\":18,\"formatOptions\":{\"thresholdsGrid\":[{\"operator\":\"==\",\"representation\":\"4\",\"text\":\"{0}{1}\",\"thresholdValue\":\"Blocked\"},{\"operator\":\"==\",\"representation\":\"2\",\"text\":\"{0}{1}\",\"thresholdValue\":\"Matched\"},{\"operator\":\"Default\",\"representation\":\"more\",\"text\":\"{0}{1}\"}],\"thresholdsOptions\":\"icons\"},\"columnMatch\":\"action_s\"},{\"columnMatch\":\"details_message_s\",\"formatter\":5},{\"columnMatch\":\"details_data_s\",\"formatter\":5},{\"columnMatch\":\"LogMatchVariable\",\"formatter\":5},{\"columnMatch\":\"NormRuleSetType\",\"formatter\":5},{\"columnMatch\":\"ruleSetVersion_s\",\"formatter\":5}],\"labelSettings\":[{\"columnId\":\"TimeGenerated\",\"label\":\"Time\"},{\"columnId\":\"ruleId_s\",\"label\":\"Rule ID\"},{\"columnId\":\"ruleGroup_s\",\"label\":\"Rule Group\"},{\"columnId\":\"action_s\",\"label\":\"Action\"},{\"columnId\":\"MatchVariable\",\"label\":\"Match Variable\"},{\"columnId\":\"Selector\",\"label\":\"Selector\"},{\"columnId\":\"ActionType\",\"label\":\"Remediation\"},{\"columnId\":\"requestUri_s\",\"label\":\"URI\"},{\"columnId\":\"clientIp_s\",\"label\":\"Client IP\"},{\"columnId\":\"hostname_s\",\"label\":\"Host\"}],\"filter\":true},\"version\":\"KqlItem/1.0\",\"queryType\":0,\"showAnalytics\":true,\"noDataMessage\":\"No WAF events found for this transaction ID. Check the ID and time range.\",\"exportedParameters\":[{\"parameterName\":\"LookupRuleId\",\"fieldName\":\"ruleId_s\",\"parameterType\":1},{\"parameterName\":\"LookupMatchVar\",\"fieldName\":\"MatchVariable\",\"parameterType\":1},{\"parameterName\":\"LookupSelector\",\"fieldName\":\"Selector\",\"parameterType\":1},{\"parameterName\":\"LookupRuleGroup\",\"fieldName\":\"ruleGroup_s\",\"parameterType\":1},{\"parameterName\":\"LookupRuleSetType\",\"fieldName\":\"NormRuleSetType\",\"parameterType\":1},{\"parameterName\":\"LookupRuleSetVersion\",\"fieldName\":\"ruleSetVersion_s\",\"parameterType\":1},{\"parameterName\":\"LookupActionType\",\"fieldName\":\"ActionType\",\"parameterType\":1},{\"parameterName\":\"LookupLogMatchVar\",\"fieldName\":\"LogMatchVariable\",\"parameterType\":1}],\"title\":\"WAF events for transaction {LookupTxnId}\"},\"name\":\"LookupResults\"},{\"conditionalVisibility\":{\"parameterName\":\"LookupRuleId\",\"comparison\":\"isNotEqualTo\"},\"type\":1,\"content\":{\"json\":\"---\\n## Apply Fix\\nSelect a **Matched** rule row above (not the Blocked row those are mandatory blocking rules that can''t be excluded). The details below show what will be changed.\\n\\n| Setting | Value |\\n|---------|-------|\\n| **Action** | `{LookupActionType}` |\\n| **Rule ID** | `{LookupRuleId}` |\\n| **Rule Group** | `{LookupRuleGroup}` |\\n| **Match Variable** | `{LookupMatchVar}` |\\n| **Selector** | `{LookupSelector}` |\\n| **Log Variable** | `{LookupLogMatchVar}` |\"},\"name\":\"LookupReview\"},{\"conditionalVisibility\":{\"parameterName\":\"LookupRuleId\",\"comparison\":\"isNotEqualTo\"},\"type\":11,\"content\":{\"style\":\"nav\",\"version\":\"LinkItem/1.0\",\"links\":[{\"linkLabel\":\">> Apply Fix for Rule {LookupRuleId}\",\"armActionContext\":{\"runLabel\":\"Confirm & Apply\",\"actionName\":\"CreateWafExclusion\",\"body\":\"{\\n \\\"action\\\": \\\"{LookupActionType}\\\",\\n \\\"resourceGroupName\\\": \\\"{wafPolicyRG}\\\",\\n \\\"wafPolicyName\\\": \\\"{wafPolicyName}\\\",\\n \\\"ruleId\\\": \\\"{LookupRuleId}\\\",\\n \\\"ruleGroupName\\\": \\\"{LookupRuleGroup}\\\",\\n \\\"ruleSetType\\\": \\\"{LookupRuleSetType}\\\",\\n \\\"ruleSetVersion\\\": \\\"{LookupRuleSetVersion}\\\",\\n \\\"matchVariable\\\": \\\"{LookupMatchVar}\\\",\\n \\\"selectorMatchOperator\\\": \\\"Equals\\\",\\n \\\"selector\\\": \\\"{LookupSelector}\\\",\\n \\\"description\\\": \\\"Created from Quick Lookup - Transaction {LookupTxnId}\\\"\\n}\",\"headers\":[{\"key\":\"Content-Type\",\"value\":\"application/json\"}],\"params\":[],\"path\":\"{logicApp}/triggers/manual/run?api-version=2016-10-01\",\"httpMethod\":\"POST\",\"description\":\"**Action:** {LookupActionType}\\n\\nThis will apply a change to WAF policy **{wafPolicyName}**:\\n\\n| Setting | Value |\\n|---------|-------|\\n| Action | {LookupActionType} |\\n| Rule ID | {LookupRuleId} |\\n| Rule Group | {LookupRuleGroup} |\\n| Match Variable | {LookupMatchVar} |\\n| Selector | {LookupSelector} |\\n\\n This modifies your WAF policy. The change applies immediately.\",\"title\":\"WAF Policy Change\"},\"linkIsContextBlade\":true,\"id\":\"lookup-apply-btn\",\"linkTarget\":\"ArmAction\",\"style\":\"primary\"}]},\"name\":\"LookupApplyButton\"}]},\"name\":\"QuickLookupTab\"},{\"type\":12,\"content\":{\"version\":\"NotebookGroup/1.0\",\"groupType\":\"editable\",\"items\":[{\"type\":3,\"content\":{\"version\":\"KqlItem/1.0\",\"query\":\"AzureDiagnostics\\r\\n| where Category == \\\"ApplicationGatewayFirewallLog\\\"\\r\\n| where TimeGenerated {detectionTime}\\r\\n| where ResourceId == ''{ResourceId}'' and policyScopeName_s == ''{PolicyScope}''\\r\\n| summarize\\r\\n Total = count(),\\r\\n Blocked = countif(action_s == \\\"Blocked\\\"),\\r\\n Matched = countif(action_s == \\\"Matched\\\"),\\r\\n UniqueRules = dcount(ruleId_s),\\r\\n UniqueURIs = dcount(requestUri_s),\\r\\n UniqueIPs = dcount(clientIp_s)\\r\\n| extend metrics = pack_array(\\r\\n pack(\\\"Label\\\", \\\" Total Events\\\", \\\"Value\\\", Total, \\\"Order\\\", 1),\\r\\n pack(\\\"Label\\\", \\\" Blocked\\\", \\\"Value\\\", Blocked, \\\"Order\\\", 2),\\r\\n pack(\\\"Label\\\", \\\"[M] Matched\\\", \\\"Value\\\", Matched, \\\"Order\\\", 3),\\r\\n pack(\\\"Label\\\", \\\" Unique Rules\\\", \\\"Value\\\", UniqueRules, \\\"Order\\\", 4),\\r\\n pack(\\\"Label\\\", \\\" Unique URIs\\\", \\\"Value\\\", UniqueURIs, \\\"Order\\\", 5),\\r\\n pack(\\\"Label\\\", \\\" Unique IPs\\\", \\\"Value\\\", UniqueIPs, \\\"Order\\\", 6))\\r\\n| mv-expand metric = metrics\\r\\n| evaluate bag_unpack(metric)\\r\\n| sort by tolong(Order) asc\",\"size\":4,\"title\":\"Summary\",\"queryType\":0,\"resourceType\":\"microsoft.operationalinsights/workspaces\",\"crossComponentResources\":[\"{workspace}\"],\"visualization\":\"tiles\",\"tileSettings\":{\"titleContent\":{\"columnMatch\":\"Label\",\"formatter\":1},\"leftContent\":{\"columnMatch\":\"Value\",\"formatter\":12,\"formatOptions\":{\"palette\":\"auto\"},\"numberFormat\":{\"unit\":17,\"options\":{\"style\":\"decimal\",\"maximumFractionDigits\":0}}},\"showBorder\":true,\"sortCriteriaField\":\"Order\",\"sortOrderField\":1}},\"customWidth\":\"100\",\"name\":\"SummaryTiles\"},{\"type\":3,\"content\":{\"version\":\"KqlItem/1.0\",\"query\":\"AzureDiagnostics\\r\\n| where Category == \\\"ApplicationGatewayFirewallLog\\\"\\r\\n| where TimeGenerated {detectionTime}\\r\\n| where ResourceId == ''{ResourceId}'' and policyScopeName_s == ''{PolicyScope}''\\r\\n| summarize Count = count() by bin(TimeGenerated, 1h), action_s\\r\\n| render timechart\",\"size\":0,\"title\":\"WAF events over time by action\",\"queryType\":0,\"resourceType\":\"microsoft.operationalinsights/workspaces\",\"crossComponentResources\":[\"{workspace}\"],\"visualization\":\"timechart\"},\"customWidth\":\"50\",\"name\":\"TimeChart\"},{\"type\":3,\"content\":{\"version\":\"KqlItem/1.0\",\"query\":\"AzureDiagnostics\\r\\n| where Category == \\\"ApplicationGatewayFirewallLog\\\"\\r\\n| where TimeGenerated {detectionTime}\\r\\n| where ResourceId == ''{ResourceId}'' and policyScopeName_s == ''{PolicyScope}''\\r\\n| where action_s == \\\"Blocked\\\"\\r\\n| summarize Count = count() by ruleId_s, ruleGroup_s\\r\\n| order by Count desc\\r\\n| take 15\",\"size\":0,\"title\":\"Top 15 blocking rules\",\"queryType\":0,\"resourceType\":\"microsoft.operationalinsights/workspaces\",\"crossComponentResources\":[\"{workspace}\"],\"visualization\":\"barchart\",\"chartSettings\":{\"xAxis\":\"ruleId_s\",\"yAxis\":[\"Count\"]}},\"customWidth\":\"50\",\"name\":\"TopRulesChart\"},{\"type\":3,\"content\":{\"version\":\"KqlItem/1.0\",\"query\":\"AzureDiagnostics\\r\\n| where Category == \\\"ApplicationGatewayFirewallLog\\\"\\r\\n| where TimeGenerated {detectionTime}\\r\\n| where ResourceId == ''{ResourceId}'' and policyScopeName_s == ''{PolicyScope}''\\r\\n| where action_s == \\\"Blocked\\\"\\r\\n| summarize Blocks = count(), UniqueRules = dcount(ruleId_s), UniqueURIs = dcount(requestUri_s) by clientIp_s\\r\\n| order by Blocks desc\\r\\n| take 20\",\"size\":0,\"title\":\"Top blocked client IPs\",\"queryType\":0,\"resourceType\":\"microsoft.operationalinsights/workspaces\",\"crossComponentResources\":[\"{workspace}\"],\"visualization\":\"table\",\"gridSettings\":{\"formatters\":[{\"columnMatch\":\"Blocks\",\"formatter\":8,\"formatOptions\":{\"palette\":\"greenRed\"}}],\"filter\":true,\"labelSettings\":[{\"columnId\":\"clientIp_s\",\"label\":\"Client IP\"},{\"columnId\":\"Blocks\",\"label\":\"Block Count\"},{\"columnId\":\"UniqueRules\",\"label\":\"Unique Rules\"},{\"columnId\":\"UniqueURIs\",\"label\":\"Unique URIs\"}]}},\"customWidth\":\"50\",\"name\":\"TopIPs\"},{\"type\":3,\"content\":{\"version\":\"KqlItem/1.0\",\"query\":\"AzureDiagnostics\\r\\n| where Category == \\\"ApplicationGatewayFirewallLog\\\"\\r\\n| where TimeGenerated {detectionTime}\\r\\n| where ResourceId == ''{ResourceId}'' and policyScopeName_s == ''{PolicyScope}''\\r\\n| where action_s == \\\"Blocked\\\"\\r\\n| summarize Blocks = count(), UniqueRules = dcount(ruleId_s), Rules = make_set(ruleId_s, 10) by requestUri_s\\r\\n| order by Blocks desc\\r\\n| take 20\",\"size\":0,\"title\":\"Top blocked URIs\",\"queryType\":0,\"resourceType\":\"microsoft.operationalinsights/workspaces\",\"crossComponentResources\":[\"{workspace}\"],\"visualization\":\"table\",\"gridSettings\":{\"formatters\":[{\"columnMatch\":\"Blocks\",\"formatter\":8,\"formatOptions\":{\"palette\":\"greenRed\"}}],\"filter\":true,\"labelSettings\":[{\"columnId\":\"requestUri_s\",\"label\":\"URI\"},{\"columnId\":\"Blocks\",\"label\":\"Block Count\"},{\"columnId\":\"UniqueRules\",\"label\":\"Unique Rules\"},{\"columnId\":\"Rules\",\"label\":\"Rule IDs\"}]}},\"customWidth\":\"50\",\"name\":\"TopURIs\"}],\"exportParameters\":true},\"conditionalVisibility\":{\"parameterName\":\"SelectedTab\",\"comparison\":\"isEqualTo\",\"value\":\"overview\"},\"name\":\"OverviewTab\"},{\"type\":1,\"content\":{\"json\":\"---\\n*WAF False Positive Auto-Triage vNext Full match-variable coverage with automated exclusion creation & rule disabling via Logic App + Runbook*\"},\"name\":\"Footer\"}],\"fallbackResourceIds\":[],\"defaultResourceIds\":[\"value::all\"],\"isLocked\":false,\"$schema\":\"https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json\"}', '__LOGIC_APP_RESOURCE_ID__', resourceId('Microsoft.Logic/workflows', parameters('logicAppName')))]", + "version": "Notebook/1.0", + "sourceId": "[variables('workbookSourceId')]", + "category": "workbook" + } + } + ], + "outputs": { + "workbookId": { + "type": "string", + "value": "[resourceId('Microsoft.Insights/workbooks', variables('workbookId'))]" + }, + "logicAppResourceId": { + "type": "string", + "value": "[resourceId('Microsoft.Logic/workflows', parameters('logicAppName'))]" + }, + "automationAccountPrincipalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Automation/automationAccounts', parameters('automationAccountName')), '2023-11-01', 'full').identity.principalId]" + } + } +} diff --git a/Azure WAF/WAF Triage Solution/runbooks/New-WafExclusion.ps1 b/Azure WAF/WAF Triage Solution/runbooks/New-WafExclusion.ps1 new file mode 100644 index 00000000..a2662b27 --- /dev/null +++ b/Azure WAF/WAF Triage Solution/runbooks/New-WafExclusion.ps1 @@ -0,0 +1,593 @@ +<# +.SYNOPSIS + Azure Automation Runbook to create WAF exclusions or disable rules for Application Gateway WAF policies. + +.DESCRIPTION + This runbook modifies Azure Application Gateway WAF policies. + It can be triggered manually, via webhook, or from Azure Logic Apps. + + Supported actions: + - createExclusion (default): Creates per-rule, per-group, or global exclusions + - disableRule: Disables a specific managed rule in the WAF policy + + Match Variables (for createExclusion): + - RequestHeaderValues, RequestHeaderNames, RequestHeaderKeys + - RequestCookieValues, RequestCookieNames, RequestCookieKeys + - RequestArgValues, RequestArgNames, RequestArgKeys + +.PARAMETER Action + The action to perform: 'createExclusion' (default) or 'disableRule'. + - createExclusion: Requires MatchVariable, SelectorMatchOperator, Selector + - disableRule: Requires RuleId, RuleGroupName, RuleSetType, RuleSetVersion + +.PARAMETER ResourceGroupName + The resource group containing the WAF policy. + +.PARAMETER WafPolicyName + The name of the WAF policy to update. + +.PARAMETER RuleId + The specific rule ID to create exclusion for (optional - if not provided, creates global exclusion). + +.PARAMETER RuleGroupName + The rule group name (e.g., 'REQUEST-942-APPLICATION-ATTACK-SQLI'). + +.PARAMETER RuleSetType + The rule set type: 'OWASP', 'Microsoft_DefaultRuleSet', or 'Microsoft_BotManagerRuleSet'. + +.PARAMETER RuleSetVersion + The rule set version (e.g., '3.2', '2.1', '1.0'). + +.PARAMETER MatchVariable + The match variable for the exclusion. + Valid values: RequestHeaderValues, RequestHeaderNames, RequestHeaderKeys, + RequestCookieValues, RequestCookieNames, RequestCookieKeys, + RequestArgValues, RequestArgNames, RequestArgKeys + +.PARAMETER SelectorMatchOperator + The selector match operator. + Valid values: Equals, StartsWith, EndsWith, Contains, EqualsAny + +.PARAMETER Selector + The selector value (the specific header/cookie/argument name to exclude). + +.PARAMETER Description + Optional description for logging purposes. + +.EXAMPLE + # Create a per-rule exclusion + .\New-WafExclusion.ps1 ` + -ResourceGroupName "rg-waf" ` + -WafPolicyName "waf-policy-prod" ` + -RuleId "942110" ` + -RuleGroupName "REQUEST-942-APPLICATION-ATTACK-SQLI" ` + -RuleSetType "OWASP" ` + -RuleSetVersion "3.2" ` + -MatchVariable "RequestArgValues" ` + -SelectorMatchOperator "Equals" ` + -Selector "comment" + +.EXAMPLE + # Disable a specific rule + .\New-WafExclusion.ps1 ` + -Action "disableRule" ` + -ResourceGroupName "rg-waf" ` + -WafPolicyName "waf-policy-prod" ` + -RuleId "920350" ` + -RuleGroupName "REQUEST-920-PROTOCOL-ENFORCEMENT" ` + -RuleSetType "OWASP" ` + -RuleSetVersion "3.2" + +.NOTES + Version: 2.0 + Author: WAF Triage Solution + Last Modified: 2025-06-30 +#> + +param( + [Parameter(Mandatory = $true)] + [string]$ResourceGroupName, + + [Parameter(Mandatory = $true)] + [string]$WafPolicyName, + + [Parameter(Mandatory = $false)] + [string]$RuleId, + + [Parameter(Mandatory = $false)] + [string]$RuleGroupName, + + [Parameter(Mandatory = $true)] + [ValidateSet('OWASP', 'OWASP_CRS', 'Microsoft_DefaultRuleSet', 'Microsoft_BotManagerRuleSet')] + [string]$RuleSetType = 'OWASP', + + [Parameter(Mandatory = $true)] + [string]$RuleSetVersion, + + [Parameter(Mandatory = $false)] + [ValidateSet( + 'RequestHeaderValues', 'RequestHeaderNames', 'RequestHeaderKeys', + 'RequestCookieValues', 'RequestCookieNames', 'RequestCookieKeys', + 'RequestArgValues', 'RequestArgNames', 'RequestArgKeys', + 'DisableRule', '' + )] + [string]$MatchVariable, + + [Parameter(Mandatory = $false)] + [ValidateSet('Equals', 'StartsWith', 'EndsWith', 'Contains', 'EqualsAny')] + [string]$SelectorMatchOperator, + + [Parameter(Mandatory = $false)] + [string]$Selector, + + [Parameter(Mandatory = $false)] + [string]$Description = "Created by WAF Triage Automation", + + [Parameter(Mandatory = $false)] + [ValidateSet('createExclusion', 'disableRule')] + [string]$Action = "createExclusion" +) + +#region Helper Functions + +function Write-Log { + param([string]$Message, [string]$Level = "INFO") + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + Write-Output "[$timestamp] [$Level] $Message" +} + +function Get-RuleGroupFromRuleId { + <# + .SYNOPSIS + Maps a rule ID to its corresponding rule group name. + Supports both OWASP CRS (full prefix) and DRS (short name) formats. + #> + param( + [string]$RuleId, + [string]$RuleSetType = 'OWASP' + ) + + # OWASP CRS uses full prefix format: REQUEST-920-PROTOCOL-ENFORCEMENT + $owaspGroupMap = @{ + '920' = 'REQUEST-920-PROTOCOL-ENFORCEMENT' + '921' = 'REQUEST-921-PROTOCOL-ATTACK' + '930' = 'REQUEST-930-APPLICATION-ATTACK-LFI' + '931' = 'REQUEST-931-APPLICATION-ATTACK-RFI' + '932' = 'REQUEST-932-APPLICATION-ATTACK-RCE' + '933' = 'REQUEST-933-APPLICATION-ATTACK-PHP' + '934' = 'REQUEST-934-APPLICATION-ATTACK-GENERIC' + '941' = 'REQUEST-941-APPLICATION-ATTACK-XSS' + '942' = 'REQUEST-942-APPLICATION-ATTACK-SQLI' + '943' = 'REQUEST-943-APPLICATION-ATTACK-SESSION-FIXATION' + '944' = 'REQUEST-944-APPLICATION-ATTACK-JAVA' + '949' = 'REQUEST-949-BLOCKING-EVALUATION' + '959' = 'REQUEST-959-BLOCKING-EVALUATION' + } + + # Microsoft_DefaultRuleSet (DRS) uses short group names + $drsGroupMap = @{ + '920' = 'PROTOCOL-ENFORCEMENT' + '921' = 'PROTOCOL-ATTACK' + '930' = 'LFI' + '931' = 'RFI' + '932' = 'RCE' + '933' = 'PHP' + '934' = 'General' + '941' = 'XSS' + '942' = 'SQLI' + '943' = 'FIX' + '944' = 'JAVA' + '949' = 'General' + '959' = 'General' + '913' = 'General' + '200' = 'MS-ThreatIntel-WebShells' + '210' = 'MS-ThreatIntel-AppSec' + '220' = 'MS-ThreatIntel-SQLI' + '230' = 'MS-ThreatIntel-CVEs' + '990' = 'METHOD-ENFORCEMENT' + '911' = 'METHOD-ENFORCEMENT' + '612' = 'NODEJS' + } + + $prefix = $RuleId.Substring(0, 3) + + # Select the appropriate map based on RuleSetType + if ($RuleSetType -eq 'Microsoft_DefaultRuleSet') { + if ($drsGroupMap.ContainsKey($prefix)) { + return $drsGroupMap[$prefix] + } + } + + # OWASP CRS map (also used as fallback for DRS) + if ($owaspGroupMap.ContainsKey($prefix)) { + return $owaspGroupMap[$prefix] + } + + # Default fallback - return provided group name or throw error + return $null +} + +function Test-ExclusionExists { + <# + .SYNOPSIS + Checks if an identical exclusion already exists in the WAF policy. + #> + param( + [object]$WafPolicy, + [string]$MatchVariable, + [string]$SelectorMatchOperator, + [string]$Selector, + [string]$RuleId + ) + + foreach ($exclusion in $WafPolicy.ManagedRules.Exclusions) { + if ($exclusion.MatchVariable -eq $MatchVariable -and + $exclusion.SelectorMatchOperator -eq $SelectorMatchOperator -and + $exclusion.Selector -eq $Selector) { + + # Check if it's for the same rule + if ($RuleId) { + foreach ($ruleSet in $exclusion.ExclusionManagedRuleSets) { + foreach ($ruleGroup in $ruleSet.RuleGroups) { + foreach ($rule in $ruleGroup.Rules) { + if ($rule.RuleId -eq $RuleId) { + return $true + } + } + } + } + } else { + # Global exclusion check - if no ExclusionManagedRuleSets, it's global + if (-not $exclusion.ExclusionManagedRuleSets -or $exclusion.ExclusionManagedRuleSets.Count -eq 0) { + return $true + } + } + } + } + + return $false +} + +#endregion + +#region Main Execution + +try { + Write-Log "Starting WAF policy change — Action: $Action" + Write-Log "Parameters:" + Write-Log " Action: $Action" + Write-Log " Resource Group: $ResourceGroupName" + Write-Log " WAF Policy: $WafPolicyName" + Write-Log " Rule ID: $(if ($RuleId) { $RuleId } else { 'Global (all rules)' })" + Write-Log " Rule Group: $(if ($RuleGroupName) { $RuleGroupName } else { 'Auto-detect' })" + Write-Log " Rule Set: $RuleSetType $RuleSetVersion" + if ($Action -eq 'createExclusion') { + Write-Log " Match Variable: $MatchVariable" + Write-Log " Selector Operator: $SelectorMatchOperator" + Write-Log " Selector: $Selector" + } + + # Validate required parameters based on action + if ($Action -eq 'createExclusion') { + if (-not $MatchVariable -or -not $SelectorMatchOperator -or -not $Selector) { + throw "createExclusion requires MatchVariable, SelectorMatchOperator, and Selector parameters." + } + } + elseif ($Action -eq 'disableRule') { + if (-not $RuleId) { + throw "disableRule requires RuleId parameter." + } + } + + # Connect to Azure using Managed Identity (for Automation Account) + Write-Log "Connecting to Azure..." + + try { + # Try Managed Identity first (for Azure Automation) + $null = Connect-AzAccount -Identity -ErrorAction Stop + Write-Log "Connected using Managed Identity" + } + catch { + # Fallback - assume already authenticated (for local testing) + Write-Log "Managed Identity not available, assuming already authenticated" -Level "WARN" + } + + # Get the WAF policy + Write-Log "Retrieving WAF policy..." + $wafPolicy = Get-AzApplicationGatewayFirewallPolicy ` + -Name $WafPolicyName ` + -ResourceGroupName $ResourceGroupName ` + -ErrorAction Stop + + if (-not $wafPolicy) { + throw "WAF Policy '$WafPolicyName' not found in resource group '$ResourceGroupName'" + } + + Write-Log "WAF Policy found: $($wafPolicy.Id)" + + # Normalize RuleSetType - WAF logs report 'OWASP_CRS' or 'OWASP CRS' but the cmdlet expects 'OWASP' + if ($RuleSetType -match 'OWASP') { + $RuleSetType = 'OWASP' + Write-Log "Normalized RuleSetType to: $RuleSetType" + } + + # Normalize RuleGroupName based on rule set type + if ($RuleSetType -eq 'Microsoft_DefaultRuleSet' -and $RuleId) { + # DRS uses short group names (SQLI, XSS, LFI, etc.) - always auto-detect from RuleId + $autoGroup = Get-RuleGroupFromRuleId -RuleId $RuleId -RuleSetType $RuleSetType + if ($autoGroup) { + if ($RuleGroupName -and $RuleGroupName -ne $autoGroup) { + Write-Log "Overriding RuleGroupName '$RuleGroupName' with correct DRS name '$autoGroup'" + } + $RuleGroupName = $autoGroup + } + } + elseif ($RuleGroupName -and $RuleGroupName -notmatch '^REQUEST-\d+' -and $RuleGroupName -notmatch '^RESPONSE-\d+') { + # OWASP CRS: logs may report short form - try to find full group name + $fullGroupName = $null + foreach ($rs in $wafPolicy.ManagedRules.ManagedRuleSets) { + foreach ($rg in $rs.RuleGroupOverrides) { + if ($rg.RuleGroupName -like "*$RuleGroupName*") { + $fullGroupName = $rg.RuleGroupName + break + } + } + if ($fullGroupName) { break } + } + if ($fullGroupName) { + Write-Log "Normalized RuleGroupName from '$RuleGroupName' to '$fullGroupName'" + $RuleGroupName = $fullGroupName + } else { + Write-Log "Could not normalize RuleGroupName '$RuleGroupName' from overrides, trying auto-detect" -Level "WARN" + # Fall through to auto-detect below + $RuleGroupName = $null + } + } + + # Auto-detect rule group if not provided but RuleId is specified + if ($RuleId -and -not $RuleGroupName) { + $RuleGroupName = Get-RuleGroupFromRuleId -RuleId $RuleId -RuleSetType $RuleSetType + if (-not $RuleGroupName) { + throw "Could not auto-detect rule group for Rule ID '$RuleId'. Please provide RuleGroupName parameter." + } + Write-Log "Auto-detected Rule Group: $RuleGroupName" + } + + #region Disable Rule Action + if ($Action -eq 'disableRule') { + Write-Log "Action: Disable Rule $RuleId in group $RuleGroupName" + + # Find the target managed rule set + $targetRuleSet = $wafPolicy.ManagedRules.ManagedRuleSets | Where-Object { + $_.RuleSetType -eq $RuleSetType -and $_.RuleSetVersion -eq $RuleSetVersion + } + if (-not $targetRuleSet) { + throw "Rule set '$RuleSetType' version '$RuleSetVersion' not found in WAF policy '$WafPolicyName'" + } + + # Check if rule is already disabled + $alreadyDisabled = $false + foreach ($rgo in $targetRuleSet.RuleGroupOverrides) { + if ($rgo.RuleGroupName -eq $RuleGroupName) { + foreach ($ro in $rgo.Rules) { + if ($ro.RuleId -eq $RuleId -and $ro.State -eq 'Disabled') { + $alreadyDisabled = $true + break + } + } + } + } + + if ($alreadyDisabled) { + Write-Log "Rule $RuleId is already disabled in group $RuleGroupName. Skipping." -Level "WARN" + Write-Output (@{ + Status = "Skipped" + Message = "Rule $RuleId is already disabled" + PolicyName = $WafPolicyName + RuleId = $RuleId + } | ConvertTo-Json) + return + } + + # Create rule override with Disabled state + $ruleOverride = New-AzApplicationGatewayFirewallPolicyManagedRuleOverride ` + -RuleId $RuleId ` + -State Disabled + + # Find existing group override or create new one + $existingGroupOverride = $targetRuleSet.RuleGroupOverrides | Where-Object { $_.RuleGroupName -eq $RuleGroupName } + + if ($existingGroupOverride) { + # Add to existing rules list (avoid duplicates) + $rules = [System.Collections.Generic.List[Microsoft.Azure.Commands.Network.Models.PSApplicationGatewayFirewallPolicyManagedRuleOverride]]::new() + foreach ($r in $existingGroupOverride.Rules) { + if ($r.RuleId -ne $RuleId) { + $rules.Add($r) + } + } + $rules.Add($ruleOverride) + $existingGroupOverride.Rules = $rules + Write-Log "Added rule override to existing group override '$RuleGroupName' ($($rules.Count) rules total)" + } + else { + # Create new group override + $groupOverride = New-AzApplicationGatewayFirewallPolicyManagedRuleGroupOverride ` + -RuleGroupName $RuleGroupName ` + -Rule $ruleOverride + + $overrides = [System.Collections.Generic.List[Microsoft.Azure.Commands.Network.Models.PSApplicationGatewayFirewallPolicyManagedRuleGroupOverride]]::new() + if ($targetRuleSet.RuleGroupOverrides) { + foreach ($rgo in $targetRuleSet.RuleGroupOverrides) { + $overrides.Add($rgo) + } + } + $overrides.Add($groupOverride) + $targetRuleSet.RuleGroupOverrides = $overrides + Write-Log "Created new group override '$RuleGroupName' with disabled rule $RuleId" + } + + # Save the policy + Write-Log "Updating WAF policy to disable rule $RuleId..." + $updatedPolicy = Set-AzApplicationGatewayFirewallPolicy ` + -InputObject $wafPolicy ` + -ErrorAction Stop + + Write-Log "Rule $RuleId disabled successfully!" -Level "SUCCESS" + + $result = @{ + Status = "Success" + Message = "Rule $RuleId disabled successfully" + Action = "disableRule" + PolicyName = $WafPolicyName + PolicyId = $updatedPolicy.Id + RuleDetails = @{ + RuleId = $RuleId + RuleGroupName = $RuleGroupName + RuleSetType = $RuleSetType + RuleSetVersion = $RuleSetVersion + State = "Disabled" + } + Timestamp = (Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC") + } + + Write-Output ($result | ConvertTo-Json -Depth 5) + return + } + #endregion + + # Check if exclusion already exists + Write-Log "Checking for existing exclusions..." + if (Test-ExclusionExists -WafPolicy $wafPolicy -MatchVariable $MatchVariable ` + -SelectorMatchOperator $SelectorMatchOperator -Selector $Selector -RuleId $RuleId) { + Write-Log "An identical exclusion already exists. Skipping creation." -Level "WARN" + Write-Output @{ + Status = "Skipped" + Message = "Exclusion already exists" + PolicyName = $WafPolicyName + RuleId = $RuleId + } | ConvertTo-Json + return + } + + # Create the exclusion entry + Write-Log "Creating exclusion entry..." + + if ($RuleId) { + # Per-rule exclusion + Write-Log "Creating per-rule exclusion for Rule ID: $RuleId" + + # Create rule entry + $ruleEntry = New-AzApplicationGatewayFirewallPolicyExclusionManagedRule ` + -RuleId $RuleId + + # Create rule group entry + $ruleGroupEntry = New-AzApplicationGatewayFirewallPolicyExclusionManagedRuleGroup ` + -RuleGroupName $RuleGroupName ` + -Rule $ruleEntry + + # Create managed rule set entry + $exclusionManagedRuleSet = New-AzApplicationGatewayFirewallPolicyExclusionManagedRuleSet ` + -RuleSetType $RuleSetType ` + -RuleSetVersion $RuleSetVersion ` + -RuleGroup $ruleGroupEntry + + # Create exclusion entry with managed rule set + $exclusionEntry = New-AzApplicationGatewayFirewallPolicyExclusion ` + -MatchVariable $MatchVariable ` + -SelectorMatchOperator $SelectorMatchOperator ` + -Selector $Selector ` + -ExclusionManagedRuleSet $exclusionManagedRuleSet + } + elseif ($RuleGroupName) { + # Per-rule-group exclusion + Write-Log "Creating per-rule-group exclusion for group: $RuleGroupName" + + # Create rule group entry without specific rules + $ruleGroupEntry = New-AzApplicationGatewayFirewallPolicyExclusionManagedRuleGroup ` + -RuleGroupName $RuleGroupName + + # Create managed rule set entry + $exclusionManagedRuleSet = New-AzApplicationGatewayFirewallPolicyExclusionManagedRuleSet ` + -RuleSetType $RuleSetType ` + -RuleSetVersion $RuleSetVersion ` + -RuleGroup $ruleGroupEntry + + # Create exclusion entry with managed rule set + $exclusionEntry = New-AzApplicationGatewayFirewallPolicyExclusion ` + -MatchVariable $MatchVariable ` + -SelectorMatchOperator $SelectorMatchOperator ` + -Selector $Selector ` + -ExclusionManagedRuleSet $exclusionManagedRuleSet + } + else { + # Global exclusion + Write-Log "Creating global exclusion (applies to all rules)" + + $exclusionEntry = New-AzApplicationGatewayFirewallPolicyExclusion ` + -MatchVariable $MatchVariable ` + -SelectorMatchOperator $SelectorMatchOperator ` + -Selector $Selector + } + + # Add exclusion to policy + Write-Log "Adding exclusion to WAF policy..." + + # Build a properly-typed exclusions list (fixes System.Object[] cast error) + $typedExclusions = [System.Collections.Generic.List[Microsoft.Azure.Commands.Network.Models.PSApplicationGatewayFirewallPolicyExclusion]]::new() + + # Copy any existing exclusions + if ($wafPolicy.ManagedRules.Exclusions -and $wafPolicy.ManagedRules.Exclusions.Count -gt 0) { + foreach ($existing in $wafPolicy.ManagedRules.Exclusions) { + $typedExclusions.Add($existing) + } + } + + # Add the new exclusion + $typedExclusions.Add($exclusionEntry) + $wafPolicy.ManagedRules.Exclusions = $typedExclusions + + # Update the WAF policy + Write-Log "Updating WAF policy..." + $updatedPolicy = Set-AzApplicationGatewayFirewallPolicy ` + -InputObject $wafPolicy ` + -ErrorAction Stop + + Write-Log "WAF Exclusion created successfully!" -Level "SUCCESS" + + # Output result + $result = @{ + Status = "Success" + Message = "Exclusion created successfully" + PolicyName = $WafPolicyName + PolicyId = $updatedPolicy.Id + ExclusionDetails = @{ + MatchVariable = $MatchVariable + SelectorMatchOperator = $SelectorMatchOperator + Selector = $Selector + RuleId = $RuleId + RuleGroupName = $RuleGroupName + RuleSetType = $RuleSetType + RuleSetVersion = $RuleSetVersion + } + Timestamp = (Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC") + } + + Write-Output ($result | ConvertTo-Json -Depth 5) + +} +catch { + Write-Log "ERROR: $($_.Exception.Message)" -Level "ERROR" + Write-Log "Stack Trace: $($_.ScriptStackTrace)" -Level "ERROR" + + $errorResult = @{ + Status = "Failed" + Message = $_.Exception.Message + PolicyName = $WafPolicyName + Timestamp = (Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC") + } + + Write-Output ($errorResult | ConvertTo-Json) + throw +} + +#endregion diff --git a/Azure WAF/WAF Triage Solution/workbook/waf-triage-workbook.json b/Azure WAF/WAF Triage Solution/workbook/waf-triage-workbook.json new file mode 100644 index 00000000..78e314c0 --- /dev/null +++ b/Azure WAF/WAF Triage Solution/workbook/waf-triage-workbook.json @@ -0,0 +1,1324 @@ +{ + "version": "Notebook/1.0", + "items": [ + { + "type": 1, + "content": { + "json": "# WAF False Positive Auto-Tuning (vNext)\n---\nThis workbook uses an **evidence-based FP Confidence score** to identify WAF tuning candidates from diagnostic logs. It traces blocked transactions back to contributing `Matched` rules, groups recurring selector-level patterns, scores confidence using breadth/recurrence/concentration signals, and lets operators apply reviewed changes from the workbook." + }, + "name": "Title" + }, + { + "type": 9, + "content": { + "version": "KqlParameterItem/1.0", + "parameters": [ + { + "id": "p-sub-001", + "version": "KqlParameterItem/1.0", + "name": "subscription", + "label": "Subscription", + "type": 6, + "isRequired": true, + "multiSelect": true, + "quote": "'", + "delimiter": ",", + "value": [ + "all" + ], + "typeSettings": { + "additionalResourceOptions": [], + "includeAll": false + } + }, + { + "id": "p-ws-002", + "version": "KqlParameterItem/1.0", + "name": "workspace", + "label": "Workspace", + "type": 5, + "isRequired": true, + "query": "where type =~ 'microsoft.operationalinsights/workspaces'\r\n| summarize by id, name\r\n| project id", + "crossComponentResources": [ + "{subscription}" + ], + "value": "", + "typeSettings": { + "additionalResourceOptions": [] + }, + "queryType": 1, + "resourceType": "microsoft.resourcegraph/resources" + }, + { + "id": "p-dt-003", + "version": "KqlParameterItem/1.0", + "name": "detectionTime", + "label": "Time Range", + "type": 4, + "isRequired": true, + "value": { + "durationMs": 86400000 + }, + "typeSettings": { + "selectableValues": [ + { + "durationMs": 3600000 + }, + { + "durationMs": 14400000 + }, + { + "durationMs": 43200000 + }, + { + "durationMs": 86400000 + }, + { + "durationMs": 259200000 + }, + { + "durationMs": 604800000 + }, + { + "durationMs": 1209600000 + }, + { + "durationMs": 2592000000 + } + ] + } + }, + { + "id": "p-la-005", + "version": "KqlParameterItem/1.0", + "name": "logicApp", + "label": "Logic App", + "type": 1, + "isRequired": true, + "value": "__LOGIC_APP_RESOURCE_ID__", + "isHiddenWhenLocked": true + }, + { + "id": "p-act-006", + "version": "KqlParameterItem/1.0", + "name": "actionFilter", + "label": "Actions", + "type": 2, + "isRequired": true, + "multiSelect": true, + "quote": "'", + "delimiter": ",", + "jsonData": "[{\"value\":\"Blocked\",\"label\":\"Blocked\",\"selected\":true},{\"value\":\"Matched\",\"label\":\"Matched\",\"selected\":true},{\"value\":\"Detected\",\"label\":\"Detected\",\"selected\":true}]", + "value": [ + "Blocked", + "Matched", + "Detected" + ] + }, + { + "id": "p-tab-default", + "version": "KqlParameterItem/1.0", + "name": "SelectedTab", + "type": 1, + "isRequired": false, + "value": "auto-exclusion", + "isHiddenWhenLocked": true + } + ], + "style": "pills", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces" + }, + "name": "Parameters" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "AzureDiagnostics\n| where Category == \"ApplicationGatewayFirewallLog\"\n| where TimeGenerated {detectionTime}\n| where action_s in ({actionFilter})\n| extend Id = new_guid()\n| project-reorder Id, ResourceId, policyScopeName_s, action_s\n| evaluate pivot(action_s, count(), ResourceId, policyScopeName_s)", + "size": 1, + "title": "Step 1 — Select listener scope (click a row)", + "exportedParameters": [ + { + "fieldName": "policyScopeName_s", + "parameterName": "PolicyScope", + "parameterType": 1 + }, + { + "fieldName": "ResourceId", + "parameterName": "ResourceId", + "parameterType": 1 + } + ], + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "crossComponentResources": [ + "{workspace}" + ], + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "columnMatch": "$gen_group", + "formatter": 13, + "formatOptions": { + "linkTarget": null, + "showIcon": true + } + }, + { + "columnMatch": "ResourceId", + "formatter": 5 + }, + { + "columnMatch": "Blocked", + "formatter": 18, + "formatOptions": { + "thresholdsOptions": "colors", + "thresholdsGrid": [ + { + "operator": "Default", + "representation": "redBright", + "text": "{0}{1}" + } + ] + } + }, + { + "columnMatch": "Detected", + "formatter": 18, + "formatOptions": { + "thresholdsOptions": "colors", + "thresholdsGrid": [ + { + "operator": "Default", + "representation": "yellow", + "text": "{0}{1}" + } + ] + } + }, + { + "columnMatch": "Matched", + "formatter": 18, + "formatOptions": { + "thresholdsOptions": "colors", + "thresholdsGrid": [ + { + "operator": "Default", + "representation": "blue", + "text": "{0}{1}" + } + ] + } + } + ], + "rowLimit": 500, + "hierarchySettings": { + "treeType": 1, + "groupBy": [ + "ResourceId" + ] + }, + "labelSettings": [ + { + "columnId": "ResourceId", + "label": "App Gateway" + }, + { + "columnId": "policyScopeName_s", + "label": "Policy Scope" + } + ] + } + }, + "name": "ScopeSelection" + }, + { + "type": 9, + "content": { + "version": "KqlParameterItem/1.0", + "parameters": [ + { + "id": "p-wafname-008", + "version": "KqlParameterItem/1.0", + "name": "wafPolicyName", + "label": "WAF Policy", + "type": 2, + "isRequired": false, + "query": "resources\r\n| where type =~ 'microsoft.network/applicationgateways'\r\n| where id =~ '{ResourceId}'\r\n| extend gwPol = tostring(properties.firewallPolicy.id)\r\n| mv-expand listener = properties.httpListeners\r\n| extend listenerName = tostring(listener.name), listenerPol = tostring(listener.properties.firewallPolicy.id)\r\n| summarize gwPol = take_any(gwPol), listenerPol = max(iff(listenerName =~ '{PolicyScope}', listenerPol, ''))\r\n| extend resolvedPol = iff(isnotempty(listenerPol), listenerPol, gwPol)\r\n| project value = tostring(split(resolvedPol, '/')[8]), label = tostring(split(resolvedPol, '/')[8]), selected = true", + "crossComponentResources": [ + "{subscription}" + ], + "isHiddenWhenLocked": true, + "typeSettings": { + "additionalResourceOptions": [] + }, + "queryType": 1, + "resourceType": "microsoft.resourcegraph/resources" + }, + { + "id": "p-wafrg-009", + "version": "KqlParameterItem/1.0", + "name": "wafPolicyRG", + "type": 2, + "isRequired": false, + "query": "resources\r\n| where type =~ 'microsoft.network/applicationgateways'\r\n| where id =~ '{ResourceId}'\r\n| extend gwPol = tostring(properties.firewallPolicy.id)\r\n| mv-expand listener = properties.httpListeners\r\n| extend listenerName = tostring(listener.name), listenerPol = tostring(listener.properties.firewallPolicy.id)\r\n| summarize gwPol = take_any(gwPol), listenerPol = max(iff(listenerName =~ '{PolicyScope}', listenerPol, ''))\r\n| extend resolvedPol = iff(isnotempty(listenerPol), listenerPol, gwPol)\r\n| project value = tostring(split(resolvedPol, '/')[4]), label = tostring(split(resolvedPol, '/')[4]), selected = true", + "crossComponentResources": [ + "{subscription}" + ], + "isHiddenWhenLocked": true, + "typeSettings": { + "additionalResourceOptions": [] + }, + "queryType": 1, + "resourceType": "microsoft.resourcegraph/resources" + }, + { + "id": "p-wafid-010", + "version": "KqlParameterItem/1.0", + "name": "wafPolicyId", + "type": 2, + "isRequired": false, + "query": "resources\r\n| where type =~ 'microsoft.network/applicationgateways'\r\n| where id =~ '{ResourceId}'\r\n| extend gwPol = tostring(properties.firewallPolicy.id)\r\n| mv-expand listener = properties.httpListeners\r\n| extend listenerName = tostring(listener.name), listenerPol = tostring(listener.properties.firewallPolicy.id)\r\n| summarize gwPol = take_any(gwPol), listenerPol = max(iff(listenerName =~ '{PolicyScope}', listenerPol, ''))\r\n| extend resolvedPol = iff(isnotempty(listenerPol), listenerPol, gwPol)\r\n| project value = resolvedPol, label = resolvedPol, selected = true", + "crossComponentResources": [ + "{subscription}" + ], + "isHiddenWhenLocked": true, + "typeSettings": { + "additionalResourceOptions": [] + }, + "queryType": 1, + "resourceType": "microsoft.resourcegraph/resources" + } + ], + "style": "pills", + "queryType": 1, + "resourceType": "microsoft.resourcegraph/resources" + }, + "conditionalVisibility": { + "parameterName": "ResourceId", + "comparison": "isNotEqualTo" + }, + "name": "WafPolicyResolver" + }, + { + "type": 1, + "content": { + "json": "ℹ️ **Scope precedence:** When a WAF policy is assigned at both the gateway level (*Global*) and an individual listener, the **listener-level policy takes precedence**. WAF logs will only record hits against the listener scope; the Global row will show 0 hits in that case.\n\n### 🛡️ Selected WAF Policy: **{wafPolicyName}**\n| | |\n|---|---|\n| **Resource Group** | {wafPolicyRG} |" + }, + "conditionalVisibility": { + "parameterName": "ResourceId", + "comparison": "isNotEqualTo" + }, + "name": "ScopePrecedenceNote" + }, + { + "type": 11, + "content": { + "version": "LinkItem/1.0", + "style": "tabs", + "links": [ + { + "id": "tab-auto", + "cellValue": "SelectedTab", + "linkTarget": "parameter", + "linkLabel": "⚡ Auto-Tuning", + "subTarget": "auto-exclusion", + "style": "link", + "preText": "" + }, + { + "id": "tab-lookup", + "cellValue": "SelectedTab", + "linkTarget": "parameter", + "linkLabel": "🔎 Quick Lookup", + "subTarget": "quick-lookup", + "style": "link" + }, + { + "id": "tab-overview", + "cellValue": "SelectedTab", + "linkTarget": "parameter", + "linkLabel": "📊 Overview", + "subTarget": "overview", + "style": "link" + } + ] + }, + "conditionalVisibility": { + "parameterName": "PolicyScope", + "comparison": "isNotEqualTo" + }, + "name": "TabNavigation" + }, + { + "type": 12, + "content": { + "version": "NotebookGroup/1.0", + "groupType": "editable", + "items": [ + { + "type": 1, + "content": { + "json": "## Detected Tuning Candidates\nThis view uses **anomaly scoring awareness** and an **evidence-based FP Confidence score**. It traces blocked transactions back to contributing `Matched` rules, groups candidates by (Rule, MatchVariable, Selector), then scores each pattern using transaction evidence, breadth, recurrence, source/URI concentration, selector quality, and mitigation safety.\n\nMandatory blocking-evaluation rules (949/959/980) are automatically filtered out since they cannot be excluded.\n\n> Select a row to see the impact preview, then click **Create Exclusion** or **Disable Rule** after reviewing the evidence." + }, + "name": "AutoHeader" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "resources\r\n| where type =~ 'microsoft.network/applicationgatewaywebapplicationfirewallpolicies'\r\n| where id =~ '{wafPolicyId}'\r\n| mv-expand exclusion = properties.managedRules.exclusions\r\n| extend MatchVariable = tostring(exclusion.matchVariable),\r\n Operator = tostring(exclusion.selectorMatchOperator),\r\n Selector = tostring(exclusion.selector),\r\n Scope = iff(array_length(exclusion.exclusionManagedRuleSets) > 0, 'Per-Rule', 'Global')\r\n| mv-expand ruleSet = exclusion.exclusionManagedRuleSets\r\n| mv-expand ruleGroup = ruleSet.ruleGroups\r\n| mv-expand rule = ruleGroup.rules\r\n| project MatchVariable, Operator, Selector, Scope,\r\n RuleSetType = tostring(ruleSet.ruleSetType),\r\n RuleSetVersion = tostring(ruleSet.ruleSetVersion),\r\n RuleGroupName = tostring(ruleGroup.ruleGroupName),\r\n RuleId = tostring(rule.ruleId)", + "size": 1, + "title": "✅ Existing exclusions on this WAF policy (already configured)", + "noDataMessage": "No exclusions configured yet on this WAF policy.", + "queryType": 1, + "resourceType": "microsoft.resourcegraph/resources", + "crossComponentResources": [ + "{subscription}" + ], + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "columnMatch": "Scope", + "formatter": 18, + "formatOptions": { + "thresholdsOptions": "icons", + "thresholdsGrid": [ + { + "operator": "==", + "thresholdValue": "Global", + "representation": "warning", + "text": "{0}{1}" + }, + { + "operator": "Default", + "representation": "success", + "text": "{0}{1}" + } + ] + } + } + ], + "filter": true, + "labelSettings": [ + { + "columnId": "MatchVariable", + "label": "Match Variable" + }, + { + "columnId": "Operator", + "label": "Operator" + }, + { + "columnId": "Selector", + "label": "Selector" + }, + { + "columnId": "Scope", + "label": "Scope" + }, + { + "columnId": "RuleSetType", + "label": "Rule Set" + }, + { + "columnId": "RuleGroupName", + "label": "Rule Group" + }, + { + "columnId": "RuleId", + "label": "Rule ID" + } + ] + } + }, + "name": "ExistingExclusions" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "// Evidence-based FP Confidence: trace blocked transactions to contributing Matched rules, then score breadth, recurrence, concentration, selector quality, and mitigation safety\nlet blockedTxns = AzureDiagnostics\n| where Category == \"ApplicationGatewayFirewallLog\"\n| where TimeGenerated {detectionTime}\n| where ResourceId == '{ResourceId}' and policyScopeName_s == '{PolicyScope}'\n| extend TxnId = coalesce(tostring(column_ifexists(\"transactionId_g\", \"\")), tostring(column_ifexists(\"transactionId_s\", \"\")))\n| where action_s == \"Blocked\" and isnotempty(TxnId)\n| distinct TxnId;\nlet parsed = AzureDiagnostics\n| where Category == \"ApplicationGatewayFirewallLog\"\n| where TimeGenerated {detectionTime}\n| where ResourceId == '{ResourceId}' and policyScopeName_s == '{PolicyScope}'\n| extend TxnId = coalesce(tostring(column_ifexists(\"transactionId_g\", \"\")), tostring(column_ifexists(\"transactionId_s\", \"\")))\n| where (action_s == \"Matched\" and TxnId in (blockedTxns))\n or (action_s in ({actionFilter}) and ruleId_s != \"0\")\n// Filter out mandatory blocking-evaluation rules (949/959/980) — they cannot be excluded or disabled\n| where not(ruleId_s startswith \"949\") and not(ruleId_s startswith \"980\") and not(ruleId_s startswith \"959\")\n| where ruleId_s != \"0\"\n| where isnotempty(details_message_s) or isnotempty(details_data_s)\n// Parse match variable and selector from BOTH details_message_s and details_data_s\n| extend MatchInfo = coalesce(details_message_s, \"\")\n| extend DataInfo = coalesce(details_data_s, \"\")\n| extend LogMatchVariable = coalesce(extract(@'at\\s+(\\w+)[:\\.\\s]', 1, MatchInfo), extract(@'\\[(\\w+):', 1, DataInfo), \"\")\n| extend MatchVariable = case(\n MatchInfo has \"ARGS_NAMES\", \"RequestArgKeys\",\n MatchInfo has \"REQUEST_COOKIES_NAMES\", \"RequestCookieKeys\",\n MatchInfo has \"REQUEST_HEADERS_NAMES\", \"RequestHeaderKeys\",\n MatchInfo matches regex @'ARGS_GET[:\\.]', \"RequestArgValues\",\n MatchInfo matches regex @'ARGS_POST[:\\.]', \"RequestArgValues\",\n MatchInfo matches regex @'ARGS[:\\.]', \"RequestArgValues\",\n MatchInfo matches regex @'REQUEST_HEADERS[:\\.]', \"RequestHeaderValues\",\n MatchInfo matches regex @'REQUEST_COOKIES[:\\.]', \"RequestCookieValues\",\n MatchInfo has \"REQUEST_BODY\", \"RequestArgValues\",\n MatchInfo has \"REQUEST_URI\", \"DisableRule\",\n MatchInfo has \"REQUEST_BASENAME\", \"DisableRule\",\n MatchInfo has \"REQUEST_FILENAME\", \"DisableRule\",\n MatchInfo has \"MULTIPART_STRICT_ERROR\", \"DisableRule\",\n MatchInfo has \"REQUEST_METHOD\", \"DisableRule\",\n MatchInfo has \"REQUEST_PROTOCOL\", \"DisableRule\",\n MatchInfo has \"XML:\", \"DisableRule\",\n DataInfo matches regex @'\\[ARGS_NAMES:', \"RequestArgKeys\",\n DataInfo matches regex @'\\[REQUEST_COOKIES_NAMES:', \"RequestCookieKeys\",\n DataInfo matches regex @'\\[REQUEST_HEADERS_NAMES:', \"RequestHeaderKeys\",\n DataInfo matches regex @'\\[ARGS_GET:', \"RequestArgValues\",\n DataInfo matches regex @'\\[ARGS_POST:', \"RequestArgValues\",\n DataInfo matches regex @'\\[ARGS:', \"RequestArgValues\",\n DataInfo matches regex @'\\[REQUEST_HEADERS:', \"RequestHeaderValues\",\n DataInfo matches regex @'\\[REQUEST_COOKIES:', \"RequestCookieValues\",\n DataInfo has \"REQUEST_BODY\", \"RequestArgValues\",\n DataInfo has \"REQUEST_URI\", \"DisableRule\",\n DataInfo has \"REQUEST_BASENAME\", \"DisableRule\",\n DataInfo has \"REQUEST_FILENAME\", \"DisableRule\",\n DataInfo has \"XML:\", \"DisableRule\",\n \"\")\n| extend Selector = case(\n isnotempty(extract(@'ARGS_NAMES:([^\\s\\.\\):]+)', 1, MatchInfo)), extract(@'ARGS_NAMES:([^\\s\\.\\):]+)', 1, MatchInfo),\n isnotempty(extract(@'ARGS_GET:([^\\s\\.\\):]+)', 1, MatchInfo)), extract(@'ARGS_GET:([^\\s\\.\\):]+)', 1, MatchInfo),\n isnotempty(extract(@'ARGS_POST:([^\\s\\.\\):]+)', 1, MatchInfo)), extract(@'ARGS_POST:([^\\s\\.\\):]+)', 1, MatchInfo),\n isnotempty(extract(@'ARGS:([^\\s\\.\\):]+)', 1, MatchInfo)), extract(@'ARGS:([^\\s\\.\\):]+)', 1, MatchInfo),\n isnotempty(extract(@'REQUEST_HEADERS_NAMES:([^\\s\\.\\):]+)', 1, MatchInfo)), extract(@'REQUEST_HEADERS_NAMES:([^\\s\\.\\):]+)', 1, MatchInfo),\n isnotempty(extract(@'REQUEST_HEADERS:([^\\s\\.\\):]+)', 1, MatchInfo)), extract(@'REQUEST_HEADERS:([^\\s\\.\\):]+)', 1, MatchInfo),\n isnotempty(extract(@'REQUEST_COOKIES_NAMES:([^\\s\\.\\):]+)', 1, MatchInfo)), extract(@'REQUEST_COOKIES_NAMES:([^\\s\\.\\):]+)', 1, MatchInfo),\n isnotempty(extract(@'REQUEST_COOKIES:([^\\s\\.\\):]+)', 1, MatchInfo)), extract(@'REQUEST_COOKIES:([^\\s\\.\\):]+)', 1, MatchInfo),\n isnotempty(extract(@'REQUEST_BODY:([^\\s\\.\\):]+)', 1, MatchInfo)), extract(@'REQUEST_BODY:([^\\s\\.\\):]+)', 1, MatchInfo),\n isnotempty(extract(@'\\[ARGS_NAMES:([^\\]:\\s\\]]+)', 1, DataInfo)), extract(@'\\[ARGS_NAMES:([^\\]:\\s\\]]+)', 1, DataInfo),\n isnotempty(extract(@'\\[ARGS_GET:([^\\]:\\s\\]]+)', 1, DataInfo)), extract(@'\\[ARGS_GET:([^\\]:\\s\\]]+)', 1, DataInfo),\n isnotempty(extract(@'\\[ARGS_POST:([^\\]:\\s\\]]+)', 1, DataInfo)), extract(@'\\[ARGS_POST:([^\\]:\\s\\]]+)', 1, DataInfo),\n isnotempty(extract(@'\\[ARGS:([^\\]:\\s\\]]+)', 1, DataInfo)), extract(@'\\[ARGS:([^\\]:\\s\\]]+)', 1, DataInfo),\n isnotempty(extract(@'\\[REQUEST_HEADERS_NAMES:([^\\]:\\s\\]]+)', 1, DataInfo)), extract(@'\\[REQUEST_HEADERS_NAMES:([^\\]:\\s\\]]+)', 1, DataInfo),\n isnotempty(extract(@'\\[REQUEST_HEADERS:([^\\]:\\s\\]]+)', 1, DataInfo)), extract(@'\\[REQUEST_HEADERS:([^\\]:\\s\\]]+)', 1, DataInfo),\n isnotempty(extract(@'\\[REQUEST_COOKIES_NAMES:([^\\]:\\s\\]]+)', 1, DataInfo)), extract(@'\\[REQUEST_COOKIES_NAMES:([^\\]:\\s\\]]+)', 1, DataInfo),\n isnotempty(extract(@'\\[REQUEST_COOKIES:([^\\]:\\s\\]]+)', 1, DataInfo)), extract(@'\\[REQUEST_COOKIES:([^\\]:\\s\\]]+)', 1, DataInfo),\n isnotempty(extract(@'\\[REQUEST_BODY:([^\\]:\\s\\]]+)', 1, DataInfo)), extract(@'\\[REQUEST_BODY:([^\\]:\\s\\]]+)', 1, DataInfo),\n \"\")\n| extend ActionType = iff(MatchVariable == \"DisableRule\", \"disableRule\", \"createExclusion\")\n| where isnotempty(MatchVariable) and (MatchVariable == \"DisableRule\" or isnotempty(Selector))\n// Filter out selectors that are clearly attack payloads (XSS, injection)\n| where MatchVariable == \"DisableRule\" or (Selector !contains \"<\" and Selector !contains \">\" and Selector !contains \"script\" and Selector !contains \"alert(\" and Selector !contains \";\" and Selector !contains \"/\")\n| extend NormRuleSetType = case(ruleSetType_s has \"OWASP\", \"OWASP\", ruleSetType_s)\n| extend IsBlockedTxn = TxnId in (blockedTxns);\nlet base = parsed\n| summarize\n HitCount = count(),\n UniqueTransactions = dcount(TxnId),\n BlockedTransactions = dcountif(TxnId, IsBlockedTxn),\n MatchedTransactions = dcountif(TxnId, action_s == \"Matched\"),\n DetectedTransactions = dcountif(TxnId, action_s == \"Detected\"),\n URIs = dcount(requestUri_s),\n IPs = dcount(clientIp_s),\n Hosts = dcount(hostname_s),\n ActiveHours = dcount(bin(TimeGenerated, 1h)),\n ActiveDays = dcount(startofday(TimeGenerated)),\n SampleURIs = make_set(requestUri_s, 5),\n SampleData = make_set(details_data_s, 3),\n WindowStart = min(TimeGenerated),\n WindowEnd = max(TimeGenerated),\n RuleGroup = take_any(ruleGroup_s),\n RuleSetVersion = take_any(ruleSetVersion_s),\n NormRuleSetType = take_any(NormRuleSetType),\n SampleMsg = take_any(Message),\n LogMatchVariable = take_any(LogMatchVariable),\n ActionType = take_any(ActionType)\n by ruleId_s, MatchVariable, Selector;\nlet ipConcentration = parsed\n| summarize IPHits = count() by ruleId_s, MatchVariable, Selector, clientIp_s\n| summarize TopIPHits = max(IPHits) by ruleId_s, MatchVariable, Selector;\nlet uriConcentration = parsed\n| summarize URIHits = count() by ruleId_s, MatchVariable, Selector, requestUri_s\n| summarize TopURIHits = max(URIHits) by ruleId_s, MatchVariable, Selector;\nbase\n| join kind=leftouter ipConcentration on ruleId_s, MatchVariable, Selector\n| join kind=leftouter uriConcentration on ruleId_s, MatchVariable, Selector\n| extend TopIPShare = iff(HitCount == 0, 0.0, round(todouble(TopIPHits) / todouble(HitCount), 2)),\n TopURIShare = iff(HitCount == 0, 0.0, round(todouble(TopURIHits) / todouble(HitCount), 2))\n| extend WindowHours = max_of(1, datetime_diff('hour', WindowEnd, WindowStart)),\n WindowDays = max_of(1, datetime_diff('day', WindowEnd, WindowStart))\n| extend HoursRatio = round(todouble(ActiveHours) / todouble(WindowHours), 2),\n DaysRatio = round(todouble(ActiveDays) / todouble(WindowDays), 2),\n DailyRate = round(todouble(UniqueTransactions) / todouble(max_of(1, ActiveDays)), 1)\n| extend TraceScore = case(BlockedTransactions > 0 and MatchedTransactions > 0, 15, BlockedTransactions > 0, 10, DetectedTransactions > 0, 5, 0)\n| extend DailyURIs = round(todouble(URIs) / todouble(max_of(1, ActiveDays)), 1),\n DailyIPs = round(todouble(IPs) / todouble(max_of(1, ActiveDays)), 1)\n| extend BreadthScore = case(DailyURIs > 10 and DailyIPs > 3, 25, DailyURIs > 5 or DailyIPs > 2, 18, DailyURIs > 2, 10, DailyURIs > 1, 5, 0)\n| extend RecurrenceScore = case(HoursRatio > 0.50 and DaysRatio >= 0.50, 10, HoursRatio > 0.25, 7, HoursRatio > 0.10, 3, 0)\n| extend ConcentrationScore = case(TopIPShare <= 0.20 and TopURIShare <= 0.30, 20, TopIPShare <= 0.35 and TopURIShare <= 0.50, 14, TopIPShare <= 0.60 and TopURIShare <= 0.75, 7, 0)\n| extend SelectorScore = case(MatchVariable == \"DisableRule\", 1, isnotempty(Selector) and strlen(Selector) <= 80, 5, isnotempty(Selector), 3, 0)\n| extend MitigationScore = iff(ActionType == \"createExclusion\", 5, 1)\n| extend VolumeScore = case(DailyRate > 50, 20, DailyRate > 20, 15, DailyRate > 10, 10, DailyRate > 3, 5, 0)\n| extend ConfidenceScoreRaw = TraceScore + BreadthScore + RecurrenceScore + ConcentrationScore + SelectorScore + MitigationScore + VolumeScore\n| extend ConfidenceScore = iff(ActionType == \"disableRule\" and ConfidenceScoreRaw > 79, 79, ConfidenceScoreRaw)\n| extend Confidence = case(\n ConfidenceScore >= 85, \"⭐ Very High\",\n ConfidenceScore >= 70, \"🟠 High\",\n ConfidenceScore >= 50, \"🟡 Medium\",\n \"⚪ Low\")\n| extend ConfidenceReason = strcat(\n \"Trace \", TraceScore, \"/15; Breadth \", BreadthScore, \"/25; Recurrence \", RecurrenceScore, \"/10; Concentration \", ConcentrationScore, \"/20; Selector \", SelectorScore, \"/5; Mitigation \", MitigationScore, \"/5; Volume \", VolumeScore, \"/20\")\n| extend Blocks = HitCount\n| extend Coverage = Confidence\n| extend CreateAction = iff(ActionType == \"disableRule\", \"🚫 Disable Rule\", \"➕ Create Exclusion\")\n| project ruleId_s, MatchVariable, Selector, RuleGroup, NormRuleSetType, RuleSetVersion, ActionType, CreateAction, Confidence, FPScore = ConfidenceScore, ScoreBreakdown = ConfidenceReason, HitCount, Blocks, UniqueTransactions, BlockedTransactions, MatchedTransactions, DetectedTransactions, UriCount = URIs, ClientIPCount = IPs, Hosts, ActiveHours, ActiveDays, TopClientIPShare = TopIPShare, TopRequestURIShare = TopURIShare, SampleMsg, SampleUrls = SampleURIs, SampleData, LogMatchVariable\n| order by FPScore desc, HitCount desc", + "size": 0, + "showAnalytics": true, + "title": "Step 2 — Tuning candidates ranked by FP Confidence (click a row to preview/apply)", + "exportedParameters": [ + { + "fieldName": "ruleId_s", + "parameterName": "SelectedRuleId", + "parameterType": 1 + }, + { + "fieldName": "MatchVariable", + "parameterName": "SelectedMatchVar", + "parameterType": 1 + }, + { + "fieldName": "Selector", + "parameterName": "SelectedSelector", + "parameterType": 1 + }, + { + "fieldName": "RuleGroup", + "parameterName": "SelectedRuleGroup", + "parameterType": 1 + }, + { + "fieldName": "NormRuleSetType", + "parameterName": "SelectedRuleSetType", + "parameterType": 1 + }, + { + "fieldName": "RuleSetVersion", + "parameterName": "SelectedRuleSetVersion", + "parameterType": 1 + }, + { + "fieldName": "HitCount", + "parameterName": "SelectedBlocks", + "parameterType": 1 + }, + { + "fieldName": "UriCount", + "parameterName": "SelectedURIs", + "parameterType": 1 + }, + { + "fieldName": "SampleMsg", + "parameterName": "SelectedMsg", + "parameterType": 1 + }, + { + "fieldName": "LogMatchVariable", + "parameterName": "SelectedLogMatchVar", + "parameterType": 1 + }, + { + "fieldName": "ActionType", + "parameterName": "SelectedActionType", + "parameterType": 1 + } + ], + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "crossComponentResources": [ + "{workspace}" + ], + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "columnMatch": "ruleId_s", + "formatter": 0, + "formatOptions": { + "customColumnWidthSetting": "8ch" + } + }, + { + "columnMatch": "Blocks", + "formatter": 8, + "formatOptions": { + "palette": "greenRed" + } + }, + { + "columnMatch": "UriCount", + "formatter": 8, + "formatOptions": { + "palette": "blue" + } + }, + { + "columnMatch": "ClientIPCount", + "formatter": 8, + "formatOptions": { + "palette": "blue" + } + }, + { + "columnMatch": "Hosts", + "formatter": 5 + }, + { + "columnMatch": "SampleUrls", + "formatter": 5 + }, + { + "columnMatch": "SampleData", + "formatter": 5 + }, + { + "columnMatch": "RuleGroup", + "formatter": 5 + }, + { + "columnMatch": "RuleSetVersion", + "formatter": 5 + }, + { + "columnMatch": "NormRuleSetType", + "formatter": 5 + }, + { + "columnMatch": "SampleMsg", + "formatter": 5 + }, + { + "columnMatch": "Confidence", + "formatter": 18, + "formatOptions": { + "thresholdsOptions": "colors", + "thresholdsGrid": [ + { + "operator": "contains", + "thresholdValue": "Very High", + "representation": "redBright", + "text": "{0}{1}" + }, + { + "operator": "contains", + "thresholdValue": "High", + "representation": "orange", + "text": "{0}{1}" + }, + { + "operator": "contains", + "thresholdValue": "Medium", + "representation": "yellow", + "text": "{0}{1}" + }, + { + "operator": "Default", + "representation": "green", + "text": "{0}{1}" + } + ] + } + }, + { + "columnMatch": "CreateAction", + "formatter": 7, + "formatOptions": { + "linkTarget": "ArmAction", + "linkLabel": "", + "linkIsContextBlade": true, + "armActionContext": { + "path": "{logicApp}/triggers/manual/run?api-version=2016-10-01", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "params": [], + "body": "{\n \"action\": \"{SelectedActionType}\",\n \"resourceGroupName\": \"{wafPolicyRG}\",\n \"wafPolicyName\": \"{wafPolicyName}\",\n \"ruleId\": \"{SelectedRuleId}\",\n \"ruleGroupName\": \"{SelectedRuleGroup}\",\n \"ruleSetType\": \"{SelectedRuleSetType}\",\n \"ruleSetVersion\": \"{SelectedRuleSetVersion}\",\n \"matchVariable\": \"{SelectedMatchVar}\",\n \"selectorMatchOperator\": \"Equals\",\n \"selector\": \"{SelectedSelector}\",\n \"description\": \"Auto-created from WAF Triage Workbook: Rule {SelectedRuleId} {SelectedActionType} ({SelectedBlocks} hits, {SelectedURIs} URIs)\"\n}", + "httpMethod": "POST", + "title": "WAF Policy Change", + "description": "**Action:** {SelectedActionType}\n\nThis will apply a change to WAF policy **{wafPolicyName}**:\n\n| Setting | Value |\n|---------|-------|\n| Action | {SelectedActionType} |\n| Rule ID | {SelectedRuleId} |\n| Rule Group | {SelectedRuleGroup} |\n| Match Variable | {SelectedMatchVar} |\n| Selector | {SelectedSelector} |\n| Log Variable | {SelectedLogMatchVar} |\n\n**Impact:** Fixes {SelectedBlocks} hits across {SelectedURIs} URIs\n\n⚠️ This modifies your WAF policy. The change applies immediately.", + "actionName": "CreateWafExclusion", + "runLabel": "Confirm & Apply" + } + } + } + ], + "filter": true, + "sortBy": [ + { + "itemKey": "FPScore", + "sortOrder": 2 + } + ], + "labelSettings": [ + { + "columnId": "ruleId_s", + "label": "Rule ID" + }, + { + "columnId": "MatchVariable", + "label": "Match Variable" + }, + { + "columnId": "Selector", + "label": "Selector" + }, + { + "columnId": "Blocks", + "label": "Hit Count" + }, + { + "columnId": "UriCount", + "label": "Unique URIs" + }, + { + "columnId": "ClientIPCount", + "label": "Unique IPs" + }, + { + "columnId": "CreateAction", + "label": "Action" + }, + { + "columnId": "Confidence", + "label": "FP Confidence" + }, + { + "columnId": "FPScore", + "label": "Score" + }, + { + "columnId": "UniqueTransactions", + "label": "Transactions" + }, + { + "columnId": "BlockedTransactions", + "label": "Blocked Txns" + }, + { + "columnId": "MatchedTransactions", + "label": "Matched Txns" + }, + { + "columnId": "ActiveHours", + "label": "Active Hours" + }, + { + "columnId": "TopClientIPShare", + "label": "Top IP Share" + }, + { + "columnId": "TopRequestURIShare", + "label": "Top URI Share" + }, + { + "columnId": "ScoreBreakdown", + "label": "Confidence Reason" + } + ] + }, + "sortBy": [ + { + "itemKey": "FPScore", + "sortOrder": 2 + } + ] + }, + "name": "ExclusionCandidates" + }, + { + "type": 1, + "content": { + "json": "---\n## 🔍 Impact Preview for Rule {SelectedRuleId}\n**Pattern:** `{SelectedMatchVar}` with selector `{SelectedSelector}`\n\n**Message:** {SelectedMsg}\n\nThe table below shows sample WAF events that match this candidate. Review before applying any change." + }, + "conditionalVisibility": { + "parameterName": "SelectedRuleId", + "comparison": "isNotEqualTo" + }, + "name": "ImpactPreviewHeader" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "AzureDiagnostics\r\n| where Category == \"ApplicationGatewayFirewallLog\"\r\n| where TimeGenerated {detectionTime}\r\n| where ResourceId == '{ResourceId}' and policyScopeName_s == '{PolicyScope}'\r\n| where ruleId_s == '{SelectedRuleId}'\r\n| where (\"{SelectedSelector}\" == \"\" or details_message_s contains \"{SelectedSelector}\" or details_data_s contains \"{SelectedSelector}\")\r\n| project TimeGenerated, hostname_s, requestUri_s, clientIp_s, action_s, details_data_s, details_message_s\r\n| order by TimeGenerated desc\r\n| take 30", + "size": 0, + "title": "Blocked requests that would be fixed by this change (sample of up to 30)", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "crossComponentResources": [ + "{workspace}" + ], + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "columnMatch": "action_s", + "formatter": 18, + "formatOptions": { + "thresholdsOptions": "icons", + "thresholdsGrid": [ + { + "operator": "==", + "thresholdValue": "Blocked", + "representation": "4", + "text": "{0}{1}" + }, + { + "operator": "==", + "thresholdValue": "Matched", + "representation": "2", + "text": "{0}{1}" + }, + { + "operator": "Default", + "thresholdValue": null, + "representation": "more", + "text": "{0}{1}" + } + ] + } + } + ], + "filter": true, + "labelSettings": [ + { + "columnId": "TimeGenerated", + "label": "Time" + }, + { + "columnId": "hostname_s", + "label": "Host" + }, + { + "columnId": "requestUri_s", + "label": "URI" + }, + { + "columnId": "clientIp_s", + "label": "Client IP" + }, + { + "columnId": "action_s", + "label": "Action" + }, + { + "columnId": "details_data_s", + "label": "Matched Data" + }, + { + "columnId": "details_message_s", + "label": "Details" + } + ] + } + }, + "conditionalVisibility": { + "parameterName": "SelectedRuleId", + "comparison": "isNotEqualTo" + }, + "name": "ImpactPreviewGrid" + }, + { + "type": 1, + "content": { + "json": "---\n## ✅ Apply WAF Change\n\nReview the details below, then click the button to apply the change automatically.\n\n| Setting | Value |\n|---------|-------|\n| **Action** | `{SelectedActionType}` |\n| **WAF Policy** | `{wafPolicyName}` (RG: `{wafPolicyRG}`) |\n| **Rule ID** | `{SelectedRuleId}` |\n| **Rule Group** | `{SelectedRuleGroup}` |\n| **Rule Set** | `{SelectedRuleSetType}` v`{SelectedRuleSetVersion}` |\n| **Log Variable** | `{SelectedLogMatchVar}` |\n| **Match Variable** | `{SelectedMatchVar}` |\n| **Selector** | `{SelectedSelector}` |\n| **Evidence** | **{SelectedBlocks}** hits across **{SelectedURIs}** URIs |" + }, + "conditionalVisibility": { + "parameterName": "SelectedRuleId", + "comparison": "isNotEqualTo" + }, + "name": "CreateExclusionReview" + }, + { + "type": 11, + "content": { + "version": "LinkItem/1.0", + "style": "nav", + "links": [ + { + "id": "create-exclusion-btn", + "linkTarget": "ArmAction", + "linkLabel": "⚡ Apply Change for Rule {SelectedRuleId}", + "style": "primary", + "linkIsContextBlade": true, + "armActionContext": { + "path": "{logicApp}/triggers/manual/run?api-version=2016-10-01", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "params": [], + "body": "{\n \"action\": \"{SelectedActionType}\",\n \"resourceGroupName\": \"{wafPolicyRG}\",\n \"wafPolicyName\": \"{wafPolicyName}\",\n \"ruleId\": \"{SelectedRuleId}\",\n \"ruleGroupName\": \"{SelectedRuleGroup}\",\n \"ruleSetType\": \"{SelectedRuleSetType}\",\n \"ruleSetVersion\": \"{SelectedRuleSetVersion}\",\n \"matchVariable\": \"{SelectedMatchVar}\",\n \"selectorMatchOperator\": \"Equals\",\n \"selector\": \"{SelectedSelector}\",\n \"description\": \"Auto-created from WAF Triage Workbook: Rule {SelectedRuleId} {SelectedActionType} ({SelectedBlocks} hits, {SelectedURIs} URIs)\"\n}", + "httpMethod": "POST", + "title": "WAF Policy Change", + "description": "**Action:** {SelectedActionType}\n\nThis will apply a change to WAF policy **{wafPolicyName}**:\n\n| Setting | Value |\n|---------|-------|\n| Action | {SelectedActionType} |\n| Rule | {SelectedRuleId} |\n| Log Variable | {SelectedLogMatchVar} |\n| Match Variable | {SelectedMatchVar} |\n| Selector | {SelectedSelector} |\n\n**Impact:** Fixes {SelectedBlocks} hits across {SelectedURIs} URIs\n\nThe Logic App will trigger a Runbook that applies the change to your WAF policy. This takes effect immediately.", + "actionName": "CreateWafExclusion", + "runLabel": "Confirm & Apply" + } + } + ] + }, + "conditionalVisibility": { + "parameterName": "SelectedRuleId", + "comparison": "isNotEqualTo" + }, + "name": "CreateExclusionButton" + }, + { + "type": 1, + "content": { + "json": "---\n### ℹ️ How it works\n1. Click **Create Exclusion** or **Disable Rule** on any pattern row (or select a row and use the button below)\n2. A confirmation dialog shows you exactly what will be changed\n3. Click **Confirm & Apply** to trigger the automation\n4. The Logic App starts a Runbook that applies the change to your WAF policy\n5. The change takes effect immediately — no App Gateway restart needed\n\n**Actions:**\n- **⊕ Create Exclusion** — adds a per-rule exclusion for the specific match variable and selector (e.g., exclude `Host` header from rule 920350)\n- **🚫 Disable Rule** — disables the entire rule when the match variable cannot be excluded (e.g., REQUEST_URI, REQUEST_BODY matches)\n\n**Check status:** Open the [Logic App](https://portal.azure.com/#resource{logicApp}/logicApp) to see execution history.\n\n---\n### 💡 Anomaly Scoring\nAzure WAF uses anomaly scoring: individual rules with `action=Matched` increment a score, and when the total exceeds the threshold, mandatory rules (949/980) issue the `Blocked` action. This workbook automatically traces blocked transactions back to the contributing Matched rules, so you see the **actual rules to exclude** rather than the un-excludable blocking rules.\n\n---\n### ⚙️ Global Parameters\nSome false positives can also be fixed by adjusting WAF policy settings:\n- **Disable request body inspection** — if request bodies are trusted\n- **Increase max request body limit** — for apps with large POST payloads\n- **Increase file upload limit** — for apps allowing large file uploads\n\nThese are set under WAF Policy → Policy Settings." + }, + "name": "HowItWorks" + } + ], + "exportParameters": true + }, + "conditionalVisibility": { + "parameterName": "SelectedTab", + "comparison": "isEqualTo", + "value": "auto-exclusion" + }, + "name": "AutoExclusionTab" + }, + { + "conditionalVisibility": { + "parameterName": "SelectedTab", + "value": "quick-lookup", + "comparison": "isEqualTo" + }, + "type": 12, + "content": { + "groupType": "editable", + "version": "NotebookGroup/1.0", + "exportParameters": true, + "items": [ + { + "type": 9, + "content": { + "style": "pills", + "resourceType": "microsoft.operationalinsights/workspaces", + "version": "KqlParameterItem/1.0", + "queryType": 0, + "parameters": [ + { + "type": 1, + "description": "Paste a WAF transaction ID from logs or a support ticket to look up the exact request and create an exclusion.", + "version": "KqlParameterItem/1.0", + "value": "", + "name": "LookupTxnId", + "id": "p-txn-lookup", + "label": "Transaction ID", + "isRequired": false + } + ] + }, + "name": "LookupParameters" + }, + { + "type": 1, + "content": { + "json": "## Quick Transaction Lookup\nPaste a **Transaction ID** from WAF logs or a support ticket above. This shows all WAF rule events for that specific request so you can review the match details and create an exclusion with one click.\\n\\n> **Important:** The exclusion will be applied to the WAF policy selected in **Step 1** above. Make sure you selected the correct Application Gateway and listener scope before applying a fix.\n\n> **Tip:** Find the transaction ID in WAF firewall logs (`transactionId` field) or in the Azure portal under Application Gateway > WAF logs." + }, + "name": "LookupHeader" + }, + { + "conditionalVisibility": { + "parameterName": "LookupTxnId", + "comparison": "isNotEqualTo" + }, + "type": 3, + "content": { + "size": 0, + "query": "AzureDiagnostics\n| where Category == \"ApplicationGatewayFirewallLog\"\n| where TimeGenerated {detectionTime}\n| where \"{LookupTxnId}\" != \"\"\n| extend TxnId = coalesce(tostring(column_ifexists(\"transactionId_g\", \"\")), tostring(column_ifexists(\"transactionId_s\", \"\")))\n| where TxnId == \"{LookupTxnId}\"\n| where ruleId_s != \"0\"\n| where not(ruleId_s startswith \"949\") and not(ruleId_s startswith \"980\") and not(ruleId_s startswith \"959\")\n| extend MatchInfo = coalesce(details_message_s, \"\")\n| extend DataInfo = coalesce(details_data_s, \"\")\n| extend MatchVariable = case(\n MatchInfo has \"ARGS_NAMES\", \"RequestArgKeys\",\n MatchInfo has \"REQUEST_COOKIES_NAMES\", \"RequestCookieKeys\",\n MatchInfo has \"REQUEST_HEADERS_NAMES\", \"RequestHeaderKeys\",\n MatchInfo matches regex @'ARGS_GET[:\\.]', \"RequestArgValues\",\n MatchInfo matches regex @'ARGS_POST[:\\.]', \"RequestArgValues\",\n MatchInfo matches regex @'ARGS[:\\.]', \"RequestArgValues\",\n MatchInfo matches regex @'REQUEST_HEADERS[:\\.]', \"RequestHeaderValues\",\n MatchInfo matches regex @'REQUEST_COOKIES[:\\.]', \"RequestCookieValues\",\n MatchInfo has \"REQUEST_BODY\", \"RequestArgValues\",\n MatchInfo has \"REQUEST_URI\", \"DisableRule\",\n MatchInfo has \"REQUEST_BASENAME\", \"DisableRule\",\n MatchInfo has \"REQUEST_FILENAME\", \"DisableRule\",\n MatchInfo has \"MULTIPART_STRICT_ERROR\", \"DisableRule\",\n MatchInfo has \"REQUEST_METHOD\", \"DisableRule\",\n MatchInfo has \"REQUEST_PROTOCOL\", \"DisableRule\",\n MatchInfo has \"XML:\", \"DisableRule\",\n DataInfo matches regex @'\\[ARGS:', \"RequestArgValues\",\n DataInfo matches regex @'\\[REQUEST_HEADERS:', \"RequestHeaderValues\",\n DataInfo matches regex @'\\[REQUEST_COOKIES:', \"RequestCookieValues\",\n \"\")\n| extend Selector = case(\n isnotempty(extract(@'(?:ARGS|ARGS_GET|ARGS_POST|ARGS_NAMES|REQUEST_HEADERS|REQUEST_HEADERS_NAMES|REQUEST_COOKIES|REQUEST_COOKIES_NAMES|REQUEST_BODY):([^\\s\\.\\):]+)', 1, MatchInfo)), extract(@'(?:ARGS|ARGS_GET|ARGS_POST|ARGS_NAMES|REQUEST_HEADERS|REQUEST_HEADERS_NAMES|REQUEST_COOKIES|REQUEST_COOKIES_NAMES|REQUEST_BODY):([^\\s\\.\\):]+)', 1, MatchInfo),\n isnotempty(extract(@'\\[(?:ARGS|REQUEST_HEADERS|REQUEST_COOKIES):([^\\]:\\s\\]]+)', 1, DataInfo)), extract(@'\\[(?:ARGS|REQUEST_HEADERS|REQUEST_COOKIES):([^\\]:\\s\\]]+)', 1, DataInfo),\n \"\")\n| extend ActionType = iff(MatchVariable == \"DisableRule\", \"disableRule\", \"createExclusion\")\n// Filter out selectors that are clearly attack payloads\n| where MatchVariable == \"DisableRule\" or isempty(Selector) or (Selector !contains \"<\" and Selector !contains \">\" and Selector !contains \"script\" and Selector !contains \"alert(\" and Selector !contains \";\" and Selector !contains \"/\")\n| extend NormRuleSetType = case(ruleSetType_s has \"OWASP\", \"OWASP\", ruleSetType_s)\n| extend LogMatchVariable = coalesce(extract(@'at\\s+(\\w+)[:\\.\\s]', 1, MatchInfo), extract(@'\\[(\\w+):', 1, DataInfo), \"\")\n| project TimeGenerated, ruleId_s, ruleGroup_s, ruleSetVersion_s, NormRuleSetType, action_s, MatchVariable, Selector, ActionType, LogMatchVariable, requestUri_s, clientIp_s, hostname_s, details_message_s, details_data_s\n| order by ruleId_s asc", + "crossComponentResources": [ + "{workspace}" + ], + "resourceType": "microsoft.operationalinsights/workspaces", + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "formatter": 18, + "formatOptions": { + "thresholdsGrid": [ + { + "operator": "==", + "representation": "4", + "text": "{0}{1}", + "thresholdValue": "Blocked" + }, + { + "operator": "==", + "representation": "2", + "text": "{0}{1}", + "thresholdValue": "Matched" + }, + { + "operator": "Default", + "representation": "more", + "text": "{0}{1}" + } + ], + "thresholdsOptions": "icons" + }, + "columnMatch": "action_s" + }, + { + "columnMatch": "details_message_s", + "formatter": 5 + }, + { + "columnMatch": "details_data_s", + "formatter": 5 + }, + { + "columnMatch": "LogMatchVariable", + "formatter": 5 + }, + { + "columnMatch": "NormRuleSetType", + "formatter": 5 + }, + { + "columnMatch": "ruleSetVersion_s", + "formatter": 5 + } + ], + "labelSettings": [ + { + "columnId": "TimeGenerated", + "label": "Time" + }, + { + "columnId": "ruleId_s", + "label": "Rule ID" + }, + { + "columnId": "ruleGroup_s", + "label": "Rule Group" + }, + { + "columnId": "action_s", + "label": "Action" + }, + { + "columnId": "MatchVariable", + "label": "Match Variable" + }, + { + "columnId": "Selector", + "label": "Selector" + }, + { + "columnId": "ActionType", + "label": "Remediation" + }, + { + "columnId": "requestUri_s", + "label": "URI" + }, + { + "columnId": "clientIp_s", + "label": "Client IP" + }, + { + "columnId": "hostname_s", + "label": "Host" + } + ], + "filter": true + }, + "version": "KqlItem/1.0", + "queryType": 0, + "showAnalytics": true, + "noDataMessage": "No WAF events found for this transaction ID. Check the ID and time range.", + "exportedParameters": [ + { + "parameterName": "LookupRuleId", + "fieldName": "ruleId_s", + "parameterType": 1 + }, + { + "parameterName": "LookupMatchVar", + "fieldName": "MatchVariable", + "parameterType": 1 + }, + { + "parameterName": "LookupSelector", + "fieldName": "Selector", + "parameterType": 1 + }, + { + "parameterName": "LookupRuleGroup", + "fieldName": "ruleGroup_s", + "parameterType": 1 + }, + { + "parameterName": "LookupRuleSetType", + "fieldName": "NormRuleSetType", + "parameterType": 1 + }, + { + "parameterName": "LookupRuleSetVersion", + "fieldName": "ruleSetVersion_s", + "parameterType": 1 + }, + { + "parameterName": "LookupActionType", + "fieldName": "ActionType", + "parameterType": 1 + }, + { + "parameterName": "LookupLogMatchVar", + "fieldName": "LogMatchVariable", + "parameterType": 1 + } + ], + "title": "WAF events for transaction {LookupTxnId}" + }, + "name": "LookupResults" + }, + { + "conditionalVisibility": { + "parameterName": "LookupRuleId", + "comparison": "isNotEqualTo" + }, + "type": 1, + "content": { + "json": "---\n## Apply Fix\nSelect a **Matched** rule row above (not the Blocked row — those are mandatory blocking rules that can't be excluded). The details below show what will be changed.\n\n| Setting | Value |\n|---------|-------|\n| **Action** | `{LookupActionType}` |\n| **Rule ID** | `{LookupRuleId}` |\n| **Rule Group** | `{LookupRuleGroup}` |\n| **Match Variable** | `{LookupMatchVar}` |\n| **Selector** | `{LookupSelector}` |\n| **Log Variable** | `{LookupLogMatchVar}` |" + }, + "name": "LookupReview" + }, + { + "conditionalVisibility": { + "parameterName": "LookupRuleId", + "comparison": "isNotEqualTo" + }, + "type": 11, + "content": { + "style": "nav", + "version": "LinkItem/1.0", + "links": [ + { + "linkLabel": "⚡ Apply Fix for Rule {LookupRuleId}", + "armActionContext": { + "runLabel": "Confirm & Apply", + "actionName": "CreateWafExclusion", + "body": "{\n \"action\": \"{LookupActionType}\",\n \"resourceGroupName\": \"{wafPolicyRG}\",\n \"wafPolicyName\": \"{wafPolicyName}\",\n \"ruleId\": \"{LookupRuleId}\",\n \"ruleGroupName\": \"{LookupRuleGroup}\",\n \"ruleSetType\": \"{LookupRuleSetType}\",\n \"ruleSetVersion\": \"{LookupRuleSetVersion}\",\n \"matchVariable\": \"{LookupMatchVar}\",\n \"selectorMatchOperator\": \"Equals\",\n \"selector\": \"{LookupSelector}\",\n \"description\": \"Created from Quick Lookup - Transaction {LookupTxnId}\"\n}", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "params": [], + "path": "{logicApp}/triggers/manual/run?api-version=2016-10-01", + "httpMethod": "POST", + "description": "**Action:** {LookupActionType}\n\nThis will apply a change to WAF policy **{wafPolicyName}**:\n\n| Setting | Value |\n|---------|-------|\n| Action | {LookupActionType} |\n| Rule ID | {LookupRuleId} |\n| Rule Group | {LookupRuleGroup} |\n| Match Variable | {LookupMatchVar} |\n| Selector | {LookupSelector} |\n\n⚠️ This modifies your WAF policy. The change applies immediately.", + "title": "WAF Policy Change" + }, + "linkIsContextBlade": true, + "id": "lookup-apply-btn", + "linkTarget": "ArmAction", + "style": "primary" + } + ] + }, + "name": "LookupApplyButton" + } + ] + }, + "name": "QuickLookupTab" + }, + { + "type": 12, + "content": { + "version": "NotebookGroup/1.0", + "groupType": "editable", + "items": [ + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "AzureDiagnostics\r\n| where Category == \"ApplicationGatewayFirewallLog\"\r\n| where TimeGenerated {detectionTime}\r\n| where ResourceId == '{ResourceId}' and policyScopeName_s == '{PolicyScope}'\r\n| summarize\r\n Total = count(),\r\n Blocked = countif(action_s == \"Blocked\"),\r\n Matched = countif(action_s == \"Matched\"),\r\n UniqueRules = dcount(ruleId_s),\r\n UniqueURIs = dcount(requestUri_s),\r\n UniqueIPs = dcount(clientIp_s)\r\n| extend metrics = pack_array(\r\n pack(\"Label\", \"📊 Total Events\", \"Value\", Total, \"Order\", 1),\r\n pack(\"Label\", \"🔴 Blocked\", \"Value\", Blocked, \"Order\", 2),\r\n pack(\"Label\", \"🟡 Matched\", \"Value\", Matched, \"Order\", 3),\r\n pack(\"Label\", \"📋 Unique Rules\", \"Value\", UniqueRules, \"Order\", 4),\r\n pack(\"Label\", \"🌐 Unique URIs\", \"Value\", UniqueURIs, \"Order\", 5),\r\n pack(\"Label\", \"👤 Unique IPs\", \"Value\", UniqueIPs, \"Order\", 6))\r\n| mv-expand metric = metrics\r\n| evaluate bag_unpack(metric)\r\n| sort by tolong(Order) asc", + "size": 4, + "title": "Summary", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "crossComponentResources": [ + "{workspace}" + ], + "visualization": "tiles", + "tileSettings": { + "titleContent": { + "columnMatch": "Label", + "formatter": 1 + }, + "leftContent": { + "columnMatch": "Value", + "formatter": 12, + "formatOptions": { + "palette": "auto" + }, + "numberFormat": { + "unit": 17, + "options": { + "style": "decimal", + "maximumFractionDigits": 0 + } + } + }, + "showBorder": true, + "sortCriteriaField": "Order", + "sortOrderField": 1 + } + }, + "customWidth": "100", + "name": "SummaryTiles" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "AzureDiagnostics\r\n| where Category == \"ApplicationGatewayFirewallLog\"\r\n| where TimeGenerated {detectionTime}\r\n| where ResourceId == '{ResourceId}' and policyScopeName_s == '{PolicyScope}'\r\n| summarize Count = count() by bin(TimeGenerated, 1h), action_s\r\n| render timechart", + "size": 0, + "title": "WAF events over time by action", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "crossComponentResources": [ + "{workspace}" + ], + "visualization": "timechart" + }, + "customWidth": "50", + "name": "TimeChart" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "AzureDiagnostics\r\n| where Category == \"ApplicationGatewayFirewallLog\"\r\n| where TimeGenerated {detectionTime}\r\n| where ResourceId == '{ResourceId}' and policyScopeName_s == '{PolicyScope}'\r\n| where action_s == \"Blocked\"\r\n| summarize Count = count() by ruleId_s, ruleGroup_s\r\n| order by Count desc\r\n| take 15", + "size": 0, + "title": "Top 15 blocking rules", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "crossComponentResources": [ + "{workspace}" + ], + "visualization": "barchart", + "chartSettings": { + "xAxis": "ruleId_s", + "yAxis": [ + "Count" + ] + } + }, + "customWidth": "50", + "name": "TopRulesChart" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "AzureDiagnostics\r\n| where Category == \"ApplicationGatewayFirewallLog\"\r\n| where TimeGenerated {detectionTime}\r\n| where ResourceId == '{ResourceId}' and policyScopeName_s == '{PolicyScope}'\r\n| where action_s == \"Blocked\"\r\n| summarize Blocks = count(), UniqueRules = dcount(ruleId_s), UniqueURIs = dcount(requestUri_s) by clientIp_s\r\n| order by Blocks desc\r\n| take 20", + "size": 0, + "title": "Top blocked client IPs", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "crossComponentResources": [ + "{workspace}" + ], + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "columnMatch": "Blocks", + "formatter": 8, + "formatOptions": { + "palette": "greenRed" + } + } + ], + "filter": true, + "labelSettings": [ + { + "columnId": "clientIp_s", + "label": "Client IP" + }, + { + "columnId": "Blocks", + "label": "Block Count" + }, + { + "columnId": "UniqueRules", + "label": "Unique Rules" + }, + { + "columnId": "UniqueURIs", + "label": "Unique URIs" + } + ] + } + }, + "customWidth": "50", + "name": "TopIPs" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "AzureDiagnostics\r\n| where Category == \"ApplicationGatewayFirewallLog\"\r\n| where TimeGenerated {detectionTime}\r\n| where ResourceId == '{ResourceId}' and policyScopeName_s == '{PolicyScope}'\r\n| where action_s == \"Blocked\"\r\n| summarize Blocks = count(), UniqueRules = dcount(ruleId_s), Rules = make_set(ruleId_s, 10) by requestUri_s\r\n| order by Blocks desc\r\n| take 20", + "size": 0, + "title": "Top blocked URIs", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "crossComponentResources": [ + "{workspace}" + ], + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "columnMatch": "Blocks", + "formatter": 8, + "formatOptions": { + "palette": "greenRed" + } + } + ], + "filter": true, + "labelSettings": [ + { + "columnId": "requestUri_s", + "label": "URI" + }, + { + "columnId": "Blocks", + "label": "Block Count" + }, + { + "columnId": "UniqueRules", + "label": "Unique Rules" + }, + { + "columnId": "Rules", + "label": "Rule IDs" + } + ] + } + }, + "customWidth": "50", + "name": "TopURIs" + } + ], + "exportParameters": true + }, + "conditionalVisibility": { + "parameterName": "SelectedTab", + "comparison": "isEqualTo", + "value": "overview" + }, + "name": "OverviewTab" + }, + { + "type": 1, + "content": { + "json": "---\n*WAF False Positive Auto-Triage vNext — Full match-variable coverage with automated exclusion creation & rule disabling via Logic App + Runbook*" + }, + "name": "Footer" + } + ], + "fallbackResourceIds": [], + "defaultResourceIds": [ + "value::all" + ], + "isLocked": false, + "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" +} + +