Goal
You need a custom policy, for example "no NSG rule may allow SSH from the internet" or "storage firewall rules may not contain 0.0.0.0/0". The first version usually has one of four bugs:
- It uses a property path that isn't an alias, so the condition never evaluates.
- It uses
[*]and gets "all" when it meant "any", or the reverse. - It checks the parent resource and misses the same setting created as a child resource.
- It's assigned with
denybefore anyone has seen what it matches.
This guide covers each one with commands you can run.
Step 1: find the alias, not the ARM property path
Policy conditions use aliases, not raw JSON paths. Before you write a condition, list the aliases for the resource type:
az provider show --namespace Microsoft.Network --expand "resourceTypes/aliases" \
--query "resourceTypes[?resourceType=='networkSecurityGroups'].aliases[].name" -o tsv \
| grep -i securityRules
PowerShell:
(Get-AzPolicyAlias -NamespaceMatch 'Microsoft.Network' -ResourceTypeMatch 'networkSecurityGroups').Aliases |
Where-Object Name -like '*securityRules*' | Select-Object Name
Points that catch people:
- An alias ending in
[*]points at each element of an array. The same alias without[*]points at the array as a whole. - If a property has no alias, policy can't see it. A condition on a made-up path doesn't fail. It just never matches.
- For
modify, the alias must be modifiable. Check the alias metadata:
az provider show --namespace Microsoft.Storage --expand "resourceTypes/aliases" \
--query "resourceTypes[?resourceType=='storageAccounts'].aliases[?defaultMetadata.attributes=='Modifiable'].name | []" -o tsv
To check what a property looks like on a real resource, read the resource JSON. Policy evaluates the request payload, which has the same shape:
az network nsg show -g rg-app -n nsg-web --query "securityRules[0]" -o jsonc
Step 2: get [*] semantics right
A plain condition on a [*] alias is true only if it holds for every element:
| Condition | Meaning |
|---|---|
"field": "...securityRules[*].access", "equals": "Allow" | Every rule is Allow |
"field": "...securityRules[*].access", "notEquals": "Allow" | No rule is Allow |
So you can write "none of" directly. But you can't use plain conditions to say "any single rule is Allow and inbound and port 22 and from Internet". Three separate [*] conditions can each be met by a different rule, which gives false positives. A rule can also fail to match when you expected it to.
Use a count expression with where. All conditions inside where are evaluated against the same array element:
{
"if": {
"allOf": [
{ "field": "type", "equals": "Microsoft.Network/networkSecurityGroups" },
{
"count": {
"field": "Microsoft.Network/networkSecurityGroups/securityRules[*]",
"where": {
"allOf": [
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].direction", "equals": "Inbound" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].access", "equals": "Allow" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].sourceAddressPrefix", "in": ["*", "Internet", "0.0.0.0/0"] },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].destinationPortRange", "in": ["22", "*"] }
]
}
},
"greater": 0
}
]
},
"then": { "effect": "[parameters('effect')]" }
}
Tip
This rule deliberately leaves out
sourceAddressPrefixes(plural),destinationPortRangesand ranges such as20-30. A real policy has to cover those as well, or you'll get false negatives. Range matching isn't possible with basic operators. Decide whether to reject ranges outright or to allow-list specific ports.
count also supports a value count over a parameter array. That's useful when you want "every element of the resource array must be in this allowed list":
{
"count": {
"field": "Microsoft.Storage/storageAccounts/networkAcls.ipRules[*]",
"where": {
"field": "Microsoft.Storage/storageAccounts/networkAcls.ipRules[*].value",
"in": ["0.0.0.0/0", "0.0.0.0/1", "128.0.0.0/1"]
}
},
"greater": 0
}
Field values are case-insensitive for most operators. equals on strings ignores case, but match and like work on patterns, so test them against real values.
Step 3: cover the child resource type
NSG rules can be created two ways:
- Inline in the NSG body: the resource type is
Microsoft.Network/networkSecurityGroups. - As a separate resource:
Microsoft.Network/networkSecurityGroups/securityRules, which is whataz network nsg rule createand many Terraform/Bicep modules do.
A policy that checks only the parent never sees the second path. Add an anyOf branch for the child type, which uses its own non-array aliases:
{
"allOf": [
{ "field": "type", "equals": "Microsoft.Network/networkSecurityGroups/securityRules" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/direction", "equals": "Inbound" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/access", "equals": "Allow" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefix", "in": ["*", "Internet", "0.0.0.0/0"] },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange", "in": ["22", "*"] }
]
}
The same split appears elsewhere: subnets inside a VNet versus virtualNetworks/subnets, SQL firewall rules, and storage blob services versus the account. When you write a policy, check both the inline and the child alias lists.
Step 4: pick the mode
Indexed: only resource types that support tags and location. Use it for tag and location policies.All: every type, including child resources such assecurityRules. The NSG policy above needsAll. InIndexedmode the child branch is silently never evaluated.
Step 5: create, assign in audit, read the results
Wrap the rule with a parameter for the effect so the same definition can go from audit to deny:
cat > params.json <<'EOF'
{
"effect": {
"type": "String",
"allowedValues": ["Audit", "Deny", "Disabled"],
"defaultValue": "Audit"
}
}
EOF
az policy definition create \
--name deny-nsg-ssh-from-internet \
--display-name "NSG rules must not allow SSH from the internet" \
--mode All \
--rules rules.json \
--params params.json \
--management-group mg-platform
Assign it at a test scope in audit, and use DoNotEnforce as an extra safety:
az policy assignment create \
--name nsg-ssh-audit \
--policy "/providers/Microsoft.Management/managementGroups/mg-platform/providers/Microsoft.Authorization/policyDefinitions/deny-nsg-ssh-from-internet" \
--scope "/subscriptions/<sub-id>/resourceGroups/rg-policy-test" \
--params '{"effect":{"value":"Audit"}}'
az policy state trigger-scan --resource-group rg-policy-test
A compliance scan can take a while on large scopes. Then read what it matched:
az policy state list --resource-group rg-policy-test \
--filter "policyAssignmentName eq 'nsg-ssh-audit'" \
--query "[].{resource:resourceId, state:complianceState}" -o table
Build a test fixture with known-good and known-bad resources: an inline rule, a child rule, a * port, a range, a sourceAddressPrefixes list. Confirm each one comes out as expected before you widen the scope.
Step 6: test deny without enforcing
Once the audit results look right, change the effect to Deny but keep --enforcement-mode DoNotEnforce on a wider assignment. Deny-effect results still appear in compliance without blocking anything. When you switch enforcement to Default, check that a bad request actually fails:
az network nsg rule create -g rg-policy-test --nsg-name nsg-test -n bad-ssh \
--priority 100 --direction Inbound --access Allow --protocol Tcp \
--source-address-prefixes Internet --destination-port-ranges 22
# Expect: RequestDisallowedByPolicy
Warning
denyonly acts on create and update requests. Resources that already break the rule keep working, but the next update to them is blocked, even an unrelated change such as a tag or a new rule. Clean up existing violations before you enforce, or teams will hit the block in the middle of an incident.
Prevent it next time
- Store definitions as JSON in source control and deploy them with your pipeline, not the portal.
- For every custom policy, keep a fixture of resources that must match and must not match, and run the audit check in CI against a sandbox subscription.
- Use
countwithwherewhenever two or more conditions describe the same array element. - When you write the rule, check both the inline alias and the child resource type.
- Go from audit to deny with
DoNotEnforce, then enforce. Track compliance per assignment before each step.