What Nobody Is Using in Your Azure Subscriptions, and What It Costs A developer released zombiescan-azure, an open-source Python CLI that scans every Azure subscription visible to `az login` for 30 classes of idle or orphaned resources across Compute, Networking, Storage, SQL, Key Vault, Container Registry, App Service, Container Apps, AI Services, Machine Learning, Log Analytics and AKS. The tool prices each finding via the Azure Retail Prices API and prints a cleanup plan without executing it, reusing tokens from `az account get-access-token` rather than storing credentials. It ships as a Claude Code plugin with an MCP server over the same engine and follows earlier scanners for AWS and Google Cloud. This article provides a step by step guide to building an Azure waste scanner from source, running it across every subscription your az login can see, pricing each finding from the Azure Retail Prices API, and drafting the cleanup. A suite of Python checks is built to cover Compute, Networking, Storage, SQL, Key Vault, Container Registry, App Service, Container Apps, AI Services, Machine Learning, Log Analytics and AKS. https://github.com/xbill9/zombiescan-azure https://github.com/xbill9/zombiescan-azure This is the third scanner in a series. The first covered an AWS account and the second covered Google Cloud projects: The shape carries over: a CLI, a bundled price table, a cleanup plan that is printed and never run, and a Claude Code plugin with an MCP server over the same engine. What changes is everything underneath — how Azure authenticates, how it answers a list call, how it prices a disk, and which resources keep billing once nobody uses them. A managed disk survives the VM it was attached to. A public IP outlives the load balancer that held it. A NAT gateway keeps its hourly fee after the last subnet moved away, and a provisioned model deployment bills every PTU every hour whether a request arrives or not. Each one reports nothing. Cost Management shows the total. Azure Advisor surfaces candidates. A figure that drives a decision names one resource, in one subscription and resource group, and its monthly cost. zombiescan produces that figure for 30 classes of resource across two packs. az login uv on the path, and Python 3.11 or newer zombiescan-scanner-role.json , under policy/ in the repository, is a custom role holding exactly the read actions the checks use git clone https://github.com/xbill9/zombiescan-azure cd zombiescan-azure uv sync Resolved 18 packages in 0.48ms Checked 17 packages in 0.13ms uv sync reads the committed uv.lock , so the resolved set is the one the tests ran against. The dependency list is click and rich ; every Azure call goes over HTTPS with urllib from the standard library. uv run zombiescan --version zombiescan, version 0.1.0 Working from the clone keeps uv run in front of every command. Installing it puts zombiescan on the path instead. uv tool install git+https://github.com/xbill9/zombiescan-azure Both routes run the same engine. The rest of this article uses the installed form. az login zombiescan shells out to az account get-access-token and reuses the result. No key file is downloaded, no client secret is stored, and there is no azure-identity dependency. An ARM token is issued for exactly one tenant. Microsoft lets one email address be both a work or school account and a personal Microsoft account, and those are two directories with two sets of subscriptions. So the scanner reads the subscription list from az account list --all , which spans every identity the CLI has signed into, and holds one token per tenant. Every scan opens by naming each tenant, the kind of account signed into it, and each subscription: Scanning as xbill@glitnir.com Tenant Default Directory — personal Microsoft account domain xbillglitnircom.onmicrosoft.com tenant id 40482c55-d00d-4c6d-8903-643d76a74b9c signed in as xbill@glitnir.com subscription Azure subscription 1 default 3db3ce66-50b6-4d11-91ef-5950cf4039ed az account list reports a personal account and a work account as the same user with the same email. The access token tells them apart: a personal Microsoft account signs in through live.com and its token carries idp: live.com , a work or school account in its own directory carries no idp at all, and a guest from another directory names its home issuer there. 🔎 Tip: every token is fetched on the main thread before any worker starts. Concurrent az account get-access-token processes contend on the MSAL token cache in ~/.azure , and a corrupted cache costs an az login . zombiescan checks aks-idle-cluster AKS clusters running no nodes aks deallocated-vm Stopped VMs still paying for disks core disabled-key-vault-key Disabled Key Vault keys still billed core empty-ai-services-account AI Services accounts with no model deployed core empty-container-apps-environment Container Apps environments with no app core empty-container-registry Container registries holding no images core empty-resource-group Resource groups holding nothing core empty-vnet Virtual networks with nothing running in them core idle-app-service-plan App Service plans hosting no apps core idle-container-app Always-on container apps serving nothing core idle-dedicated-host Dedicated hosts running no VMs core idle-load-balancer Load balancers with no backends core idle-ml-compute ML compute running or held idle core idle-nat-gateway NAT gateways with no subnets core idle-provisioned-deployment Provisioned model deployments serving nothing core idle-workload-profile Dedicated workload profiles running no app core orphaned-nic Network interfaces with no VM core orphaned-snapshot Snapshots of deleted disks core paused-sql-database Paused SQL databases still paying for storage core stale-key-vault-secret Secrets with no new version in 90 days core unattached-disk Unattached managed disks core unbounded-log-workspace Log workspaces with no ingestion cap core unmanaged-storage-account Versioned storage accounts with no lifecycle policy core unused-availability-test Availability tests watching a deleted resource core unused-capacity-reservation Capacity reservations holding unused slots core unused-dns-zone DNS zones with no records core unused-image Managed images nothing boots from core unused-nsg Security groups protecting nothing core unused-public-ip Public IP addresses attached to nothing core unused-subnet Subnets reserving a range against nothing core Twenty-nine ship in the core pack and one in aks . AKS sits in its own pack because it carries its own resource provider, its own rate section and its own price fetcher, which is what a third-party pack has to supply. zombiescan providers Microsoft.App 3 check s Microsoft.CognitiveServices 2 check s Microsoft.Compute 6 check s Microsoft.ContainerRegistry 1 check s Microsoft.ContainerService 1 check s Microsoft.Insights 3 check s Microsoft.KeyVault 2 check s Microsoft.MachineLearningServices 1 check s Microsoft.Network 9 check s Microsoft.OperationalInsights 1 check s Microsoft.Resources 1 check s Microsoft.Sql 1 check s Microsoft.Storage 1 check s Microsoft.Web 1 check s This list decides more on Azure than on the other two clouds. ARM answers a list call against an unregistered resource provider with HTTP 200 and an empty page. A subscription that has never used App Service returns {"value": } from /providers/Microsoft.Web/serverfarms , with no error. A check that simply ran would find nothing and report a clean subscription. So every check declares the providers it reads, the engine reads each subscription's registrations once, and a check whose provider is missing is counted as skipped. The same declaration generates the read-only custom role, and the test suite fails when a check names a provider the role does not cover. zombiescan scan 1 subscription s , 30 check s — read-only No waste found across 1 subscription. Nothing to clean up. 4 of 30 subscription/check pair s were skipped: their resource provider is not registered, so there is nothing of that kind here. 'zombiescan providers' lists what each check reads. With no flag it scans the subscription az is set to. The four skipped pairs are Key Vault's two checks, App Service and SQL: this subscription has never registered those providers, so it has no resources of those kinds. Every call is a list, a get or a Resource Graph query. The scan has no code path that deletes, modifies or releases anything. zombiescan scan --all-subscriptions --all-subscriptions takes every enabled subscription in az account list --all , across every tenant the CLI has signed into, and runs az account list --refresh first to pick up subscriptions created since the last login. On this account that is one subscription in one tenant, and the sweep of 30 checks took 8.65 seconds. A second identity is one az login away. After it, the same command reaches both directories, requests for each subscription carry that tenant's token, and the header lists both tenants with their account kinds. This subscription is clean, so the rows below come from the test suite's recorded ARM responses — the same JSON each check is tested against — priced from the real bundled price table. uv run python articles/zombiescan-azure/show-fixture-findings.py 49 findings from 30 checks, recorded responses, prices generated 2026-09-23T17:19:30Z total $32,345.26/month Check Resource Monthly idle-provisioned-deployment acct-ptu/ptu-idle $21,900.00 idle-ml-compute ws-research/gpu-warm $4,467.60 idle-dedicated-host host-idle $3,084.25 idle-workload-profile env-single/d4-solo $522.62 idle-app-service-plan prod-plan $459.90 unused-capacity-reservation cr-partial $420.48 empty-container-apps-environment env-empty-dedicated $297.81 idle-workload-profile env-mixed/d4-idle $224.81 idle-ml-compute ws-research/ci-forever $213.89 unused-capacity-reservation cr-idle $140.16 aks-idle-cluster scaled-to-zero $73.00 idle-nat-gateway egress-gw-old $32.85 deallocated-vm batch-runner $24.90 idle-load-balancer api-lb $21.90 unattached-disk orphan-data $19.71 idle-container-app app-idle $11.83 orphaned-nic web-01-nic-old $3.65 unattached-disk tiny-scratch $0.60 The top of that list is where Azure's waste concentrates now: a 15-PTU regional model deployment serving nothing is $21,900 a month, a GPU cluster whose minimum keeps two idle nodes up is $4,467.60, and an empty DSv3-Type3 dedicated host is $3,084.25. A forgotten disk is $19.71. Every finding carries its subscription, resource group, location and full ARM id. No az command works without the resource group, and the ARM id is the only identifier unique across a tenant. A check runs once per subscription. Two properties of ARM make that a complete sweep. A list call at /subscriptions/