Skip to content

Build1 publisher2 min readPublished

zombiescan prices 30 classes of unused Azure resource on read access alone

zombiescan prices 30 classes of unused Azure resource in every subscription an az login can see, using the Azure Retail Prices API. Read access covers everything it does, so a human still owns every delete.

The Engineer · Build desk

Illustration accompanying zombiescan prices 30 classes of unused Azure resource on read access alone

What happened

  • zombiescan, the third scanner in a series after versions for AWS accounts and Google Cloud projects, prices 30 classes of unused Azure resource across two check packs.
  • Every call the checks make is covered by the built-in Reader role, and the repository ships zombiescan-scanner-role.json, a custom role holding exactly the read actions used.
  • The tool shells out to az account get-access-token and reuses the result, so it needs no key file, no client secret, and no azure-identity dependency.
  • It takes its subscription list from az account list --all, which spans every identity the CLI has signed into, and holds one token per tenant.
  • The declared dependency list is click and rich, and every Azure call goes over HTTPS with urllib from the Python standard library.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • capability A monthly cost attached to one named resource in one subscription and resource group is something an engineer can act on in a ticket. Cost Management gives the total and Advisor gives candidates.
  • constraint The cleanup plan is printed and never run, so the saving stays hypothetical until a human with write permissions deletes something.
  • exposure Because one email address can hold a work directory and a personal Microsoft account with separate subscriptions, any audit scoped to the default tenant leaves a second directory's bill unexamined.

A managed disk outlives the VM it was attached to, and 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 [6]. None of the four reports anything [6]. There is no utilisation metric to alert on, so the finding has to come from a list call.

The article prints part of the check registry, and five entries are fully visible [20]. Four of them flag resources that exist and hold nothing: an AKS cluster running no nodes, a Container Apps environment with no app, an AI Services account with no model deployed, and a disabled Key Vault key still billed [20][1]. The fifth is a stopped VM still paying for its disks [20]. The orphan cases are in the prose; the visible checks are about emptiness and idleness. The article does not say what share of an Azure bill either group accounts for [22].

Authentication is where this build diverges from the AWS and Google Cloud versions of the same scanner [5]. An ARM token is issued for exactly one tenant [15]. `az account list` reports a personal Microsoft account and a work account as the same user with the same email, and the token is what separates them: a personal account signs in through live.com and its token carries `idp: live.com`, a work or school account in its own directory omits the `idp` claim, and a guest from another directory names its home issuer [18]. Every scan opens by printing each tenant, the kind of account signed into it, and each subscription [21].

Every token is fetched on the main thread before any worker starts, because concurrent `az account get-access-token` processes contend on the MSAL token cache in `~/.azure`, and a corrupted cache costs an `az login` [19].

Each finding is priced from the Azure Retail Prices API [1]. That monthly figure matches an invoice only where the account pays list for the SKU. If it pays less, the total overstates the saving, while the ranking of one resource against another survives.

Setup is a clone, `uv sync`, a signed-in CLI, `uv` on the path, and Python 3.11 or newer [9]. `uv sync` reads the committed `uv.lock`, so the resolved set is the one the tests ran against, and it resolves 18 packages, 16 more than the project declares [11][2]. The version documented is 0.1.0 [13].

What to watch

  • Whether the full 30-check registry includes orphan checks for unattached disks and idle public IPs, or only the empty-and-idle classes visible in the printed output.
  • Whether the Claude Code plugin and MCP server stay read-only; a write scope would need a role broader than Reader.
  • Whether the bundled price table gains an input for contracted rates, so a total can match a discounted invoice.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories