Get listed

Azure security: PIM, NSG, and no public port 22

A teal cloud window with a small coral service door open.

An Azure pentest is a look at identity, role assignments, and the network rules that still allow the world in.

A DenyAllInbound at priority 65500 loses to an allow on SSH at 300. Portal clicks create those allows. The tester who only scans public IPs will miss the identity path. The tester who reads IAM will not.

The usual mistake is treating ‘it is in Azure’ as a boundary while a storage account or NSG is still public.

This page is the Azure-specific checks a test should include, and the default that looks closed until you read the next rule.

Azure’s default DenyAllInbound sits at priority 65500. A portal rule that allows TCP 22 from * at 300 wins. That is how SSH is public on a VM you clicked through. Entra PIM overview on 22 August 2026. The Learn page, dated 23 April 2026, still lists just-in-time activation, approval, MFA on activate, and a downloadable audit history.

This page is the identity and packet path a team owns. Microsoft publishes customer engagement rules for tests against resources you own. Follow that list when you hire someone. Do not treat this page as their playbook. Pair the VM’s sshd with the Ubuntu host guide. Pair app tokens with JWT in Express and session management. Keep the secure coding checklist for the request surface.

Standing Owner is the finding

A tester who can enumerate role assignments will write down every human with Owner or User Access Administrator that never expires. That is not a novel ARM parser bug. It is a permanent privileged identity. Microsoft’s PIM page names the opposite on purpose: eligible, time-bound, approval, MFA on activate, justification, notifications, access reviews, audit history. It also says the product needs an Entra ID Governance license. If you do not have that SKU, you still remove standing Owner. You just cannot lean on the activation workflow.

Identifiers on this page stay rg-app, nsg-app, vnet-app, snet-app, vm-app. Humans are eligible for Contributor on rg-app. A break-glass account is the one standing Owner you can name, stored offline, reviewed, not a daily login.

az role assignment list --all --include-inherited \
 --query "[?roleDefinitionName=='Owner' || roleDefinitionName=='User Access Administrator']"

Every row whose principalType is User and whose assignment has no end date is a finding you can close today. Service principals that deploy from CI should be Contributor on rg-app, not Owner on the subscription. Managed identities that only read a vault do not need Owner either.

Guest users in the directory are the other standing grant testers love. A vendor who was Owner on rg-app for a two-week migrate and never lost the role is a live path. Run the same list filtered to guests, or open the Entra sign-in log for those object ids. Convert the person to eligible Contributor if they still need to change resources. If they do not, delete the assignment. PIM access reviews exist so this is a monthly job, not a memory.

PIM: eligible, then a TTL

Eligible means the person cannot use the role until they activate. Activate means MFA, a business reason, an optional approver, and a clock. Permanent active is the old standing grant with a new label. Do not use it for daily admin.

Entra directory roles and Azure resource roles are two blades. Privileged Role Administrator or Global Administrator can manage the directory side. A subscription Owner or User Access Administrator can manage the resource side. Those are not the same list. A person who is eligible for Contributor on rg-app should not also be a standing Global Administrator.

# Standing grants you can see without the PIM API.
# Eligible-only rows will not appear here as always-on access.
az role assignment list --scope /subscriptions/$SUB/resourceGroups/rg-app \
 --query "[?principalType=='User'].{name:principalName,role:roleDefinitionName}"

# Eligible directory roles the signed-in admin can list.
az rest --method GET \
 --url "https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilitySchedules"

The Graph list is the directory half. Resource eligibility lives under Microsoft.Authorization/roleEligibilityScheduleInstances at the subscription or group scope. I am naming the resource provider, not pasting a request body Configure activation in the PIM blade: maximum 8 hours for Owner-class work is a common cap, MFA required, justification required, notification on. Access reviews monthly for anyone still eligible.

PIM does not replace Conditional Access. A stolen password that can also activate Owner is still a stolen Owner. Require phishing-resistant MFA for the activation policy. Break-glass stays excluded from that policy on purpose, and that exclusion is itself a review item.

PIM for Groups is the third blade on the same Learn page. If you elevate by activating membership in grp-rg-app-contrib, the group is the thing that holds the role, and the person is eligible for the group. That is cleaner than twenty eligible user rows, and it is also a single object a tester will enumerate. Review the group owners. A group that any member can join is not least privilege.

az ad group member list --group grp-rg-app-contrib --query "[].userPrincipalName"
az role assignment list --scope /subscriptions/$SUB/resourceGroups/rg-app \
 --query "[?contains(principalName,'grp-rg-app-contrib')]"

NSG: delete Internet 22

network security groups overview. Custom rules run before the defaults. Lower priority numbers win. Default DenyAllInbound is 65500. An Allow SSH from Any at 300 is the live policy. Removing that allow is the fix. Adding a later deny does nothing if the allow already matched.

az network nsg rule list --resource-group rg-app --nsg-name nsg-app \
 --query "[?direction=='Inbound'][name,priority,access,sourceAddressPrefix,destinationPortRange]" \
 --output table

Delete every inbound allow whose destination is 22 or 3389 and whose source is *, Internet, or 0.0.0.0/0. Then write the deny you can grep:

az network nsg rule create \
 --resource-group rg-app \
 --nsg-name nsg-app \
 --name DenySshInternet \
 --priority 100 \
 --direction Inbound \
 --access Deny \
 --protocol Tcp \
 --source-address-prefixes Internet \
 --destination-port-ranges 22

az network nsg rule create \
 --resource-group rg-app \
 --nsg-name nsg-app \
 --name DenyRdpInternet \
 --priority 110 \
 --direction Inbound \
 --access Deny \
 --protocol Tcp \
 --source-address-prefixes Internet \
 --destination-port-ranges 3389

If you still need SSH from a named admin prefix, add AllowSshAdmin at 120 with that prefix only, never *. Associate nsg-app with snet-app. A NIC-level group that allows 22 from the world overrides a careful subnet group. List both.

NSG flow logs retire on 30 September 2027. Microsoft’s overview says create virtual network flow logs instead, and that new NSG flow logs are already blocked. Do not build a 2026 monitoring plan on the retiring type.

Bastion, no public NIC

Bastion overview. The page, with a 4 February 2026 date on the Learn record says VMs do not need a public IP, an agent, or extra client software. You connect over TLS on 443. Standard portal use does not need inbound 22 or 3389 on AzureBastionSubnet. The host reaches vm-app on the private IP. The VM subnet allows 22 only from that Bastion prefix.

az network nic list --resource-group rg-app \
 --query "[].{name:name,public:ipConfigurations[0].publicIPAddress.id}"

az network public-ip list --resource-group rg-app \
 --query "[].{name:name,ip:ipAddress}"

vm-app should print an empty public field. The Bastion public IP is the one exception, and it answers 443, not 22. Premium can do private-only Bastion with no public IP at all. Pick the one that matches whether you need native client and session recording. Developer is shared infrastructure for a lab, not a production control plane.

# After Bastion exists, 22 on vm-app is only from AzureBastionSubnet.
az network nsg rule create \
 --resource-group rg-app \
 --nsg-name nsg-app \
 --name AllowSshBastion \
 --priority 130 \
 --direction Inbound \
 --access Allow \
 --protocol Tcp \
 --source-address-prefixes 10.0.1.0/26 \
 --destination-port-ranges 22

Replace 10.0.1.0/26 with the real AzureBastionSubnet CIDR. Microsoft requires that subnet name. Do not put vm-app in it. If you refuse Bastion, a jump box you patch, with no public IP on vm-app and 22 only from the jump’s private address, is the same shape. A public NIC plus key-only sshd is still a public 22. The Ubuntu page locks the daemon. This page removes the path.

443 stops at Bastion. nsg-app denies 22 from Internet. PIM is a clock on the person, not a port.
INTERNET
 |
 v
 Bastion 443
 AzureBastionSubnet
 |
 v
 snet-app
 vm-app private IP only
 nsg-app
 DenySshInternet pri 100
 AllowSshBastion pri 130
 no public NIC

IDENTITY
 user eligible Contributor on rg-app
 activate PIM MFA + reason + TTL
 standing Owner humans = 0

Prove the subscription

You are not scanning Microsoft. You are proving rg-app matches the grants and rules you meant.

  1. az role assignment list shows no standing Owner on a human except the named break-glass.
  2. PIM eligibility exists for the people who actually change rg-app. Activation requires MFA and a reason.
  3. nsg-app has DenySshInternet at 100. No inbound allow on 22 or 3389 from Internet or *.
  4. vm-app has no public IP. Shells go through Bastion or a private jump.
  5. sshd on the VM still matches the Ubuntu drop-in. A public path you closed does not excuse password auth on the private NIC.
az role assignment list --all --include-inherited \
 --query "[?roleDefinitionName=='Owner' && principalType=='User']"

az network nsg rule list -g rg-app --nsg-name nsg-app \
 --query "[?destinationPortRange=='22' || contains(to_string(destinationPortRanges),'22')]"

az network nic list -g rg-app --query "[?ipConfigurations[?publicIPAddress.id!=null]].name"

Empty Owner-user rows, a 22 list that is only Bastion or a named prefix, and no public NIC on vm-app: that is a boring subscription. A later tester who still wants to fuzz your API is testing the app, which is the checklist, not an open management port.

Repeat the lists after someone clicks Create a VM. The portal still offers an inbound SSH checkbox on many Linux images. That click recreates the allow you deleted. Treat a new NIC in rg-app as a change that must pass the same three commands. If you want a gate, put the deny rules in the NSG that every app subnet must attach, and refuse a public IP on the NIC in your template. The CLI proof above is the one I can rerun.

Activity logs on the subscription are the other half of “we would have seen it.” A standing Owner who also has no alert on Microsoft.Authorization/roleAssignments/write can mint another Owner in silence. Send that operation to a workspace you actually read. PIM already emails on activate if you left notifications on. The gap is the classic role assignment API, which still works for anyone who is already Owner. That is why the human Owner list must stay empty except for break-glass.

Questions we keep getting

Does Just-in-Time VM access replace Bastion?

Defender for Cloud can open 22 for a short window to a named requestor. That is still a public or reachable 22 for that window. Prefer no public NIC and Bastion. If you keep JIT, keep DenySshInternet as the resting state and treat every open window as an audit event.

Can a Contributor activate Owner through PIM?

Only if you made them eligible for Owner. Eligible Contributor cannot mint Owner for themselves. User Access Administrator and Owner can create grants. Keep those two roles on the smallest set, eligible, with approval.

Is this Microsoft’s red-team method?

No. Microsoft’s Assume Breach writeups are about their own infrastructure. Customer tests follow the MSRC engagement rules and target resources you own. This page is the identity and network baseline those testers should bounce off.