--- name: az-sub-orienteering description: Orienteer an Azure subscription you did not build. Use when asked to discover, inventory, assess, or map an unfamiliar or inherited subscription, when someone asks what runs in a subscription or how exposed it is, when navigating or ranking the reports of a previous sweep, or when comparing several subscriptions. Also when asked what access a sweep needs, or how to provision a least-privilege role for one. The sweep itself is read-only; needs az CLI, jq, GNU coreutils. metadata: argument-hint: "[subscription id] [--only module,...]" --- # Azure subscription orienteering Orienteering: a map good enough to prioritise with, before any depth. The sweep builds the map; the triage guide turns it into a ranked route. Every call is read-only. ## Steps 1. **Aim.** Resolve the target subscription id: the argument, else `AZURE_SUBSCRIPTION_ID`, else ask. Confirm the session reaches it: ```sh az account show --subscription "${SUBSCRIPTION_ID}" --query "{name:name, tenant:tenantId}" --output table ``` Done when the command prints the intended subscription in the intended tenant. A login prompt or wrong tenant is the user's to fix (`az login --tenant`). 2. **Sweep.** ```sh .claude/skills/az-sub-orienteering/scripts/az-sub-discovery.bash --subscription "${SUBSCRIPTION_ID}" ``` Output lands in `.local/tmp/az-sub-discovery/${SUBSCRIPTION_ID}/` at the project root; pass `--output-dir` only when the user names another place. A full run takes 2 to 15 minutes; the diagnostic-settings pass in `observability` dominates on large estates. Exit 0 means every module completed. Non-zero lists the failed modules on stderr; the other reports are still valid, and `--only mod,mod` re-runs just those. Done when `summary.md` exists and states `Modules completed N of N`, or every failed module has been re-run or written down as a gap. **Reading a summary after a partial run.** An output directory accumulates reports across runs, so `summary.md` describes _the last invocation_, not the state of the whole directory. A filtered run (`--only` or `--skip`) says so in place of the completed count, names the filter, and reports when the last unfiltered sweep finished — every report in the list carries its own `Generated` timestamp, which is the authority. A full run with no failures records itself in `.last-full-sweep`. If that line reads `none recorded`, no complete sweep has finished in this directory since the tracking was added, and the reports are of mixed vintage. Each module empties its own `raw//` directory before it runs, so raw output always describes one run. That matters most for `*.json.err` files: they are the record of which calls were refused, and a stale one from an earlier version of a module reads as a live permissions denial. 3. **Navigate.** Read [references/reading-the-findings.md](references/reading-the-findings.md) and work the reports in its order. Done when every report listed in `summary.md` has been opened and every table in the priority reports has either yielded a finding or been noted as clean. 4. **Write `findings.md`** at the output dir root, in the structure the triage guide prescribes. Relative links to `reports/*.md` are fine; the file lives beside them. Done when every finding names its report and table, carries a rank, and the unexplored-territory and follow-ups sections are filled. 5. **Report back** in chat: subscription, modules completed, the top three findings with their rank, and the path to `findings.md`. ## Interface | Flag | Effect | | ----------------------- | ---------------------------------------------------------------- | | `--subscription, -s ID` | Target. Default `AZURE_SUBSCRIPTION_ID`, else the az CLI default | | `--output-dir, -o DIR` | Where `raw/`, `reports/`, `summary.md` go | | `--only MOD[,MOD]` | Run only these modules; `inventory` is always included | | `--skip MOD[,MOD]` | Skip modules | | `--fail-fast` | Abort on first failing module (default: continue, report at end) | | `--list` | Print modules in run order and exit | | `AZSD_AZ_TIMEOUT` | Seconds per az call, default 120 | | `AZSD_LOG_LEVEL` | `debug` shows every per-resource call | ## Modules, in run order | Module | Question it answers | | --------------- | --------------------------------------------------------------------------- | | `inventory` | What is here: management group chain, RGs, every resource, providers, locks | | `identity` | Who can act: privileged and orphaned role assignments, custom roles, MIs | | `exposure` | What is reachable or weakly authenticated, across every service at once | | `network` | Topology: VNets, subnets without NSG, peerings, gateways, private link | | `governance` | Policy assignments, non-compliance, exemptions, tag usage | | `defender` | Defender plans, contacts, unhealthy assessments | | `observability` | Log sinks, activity-log export, alerting, diagnostic-settings coverage | | `activity` | Who changed what in 30 days; failed operations; RBAC changes | | `cost` | Month-to-date and last month by service, RG, location | | `advisor` | The platform's own ranked recommendations | | `compute` | VMs, VMSS, AKS, Container Apps, App Service, ACR | | `storage` | Accounts, network rules, containers | | `data` | PostgreSQL, MySQL, SQL, Cosmos DB, Redis, with firewall rules | | `keyvault` | RBAC vs access policies, soft delete, purge protection, object counts | | `messaging` | Service Bus, Event Hubs, Event Grid | | `ai` | Cognitive Services and OpenAI deployments, ML workspaces, Search | | `edge` | Load balancers, App Gateway and WAF, Front Door, APIM | | `backup` | Recovery Services and Backup vaults, protected items | Service modules print `_(no resources of type ...)_` and return immediately when the inventory holds none of their types. ## Output layout ```text / summary.md index of reports + resource types no module covers findings.md written in step 4 reports/.md one report per module raw//*.json every az response; *.json.err beside a failed call raw/inventory/all-resources.csv ``` ## Reading a failure correctly - A `.json.err` beside a data-plane call (Key Vault objects, blob containers, Log Analytics queries) is a permissions fact about the caller, not an empty resource. Report it as "could not read", never as "none". - `defender` reporting `Subscription Not Registered` means Defender for Cloud was never enabled; that is itself a finding. - Resource Graph results are capped at 1000 rows per query; a `truncated` warning on stderr means the exposure tables are a lower bound. - `cost` returning no data under Reader alone is expected; it needs Cost Management Reader, and some billing channels hide cost at subscription scope. ## Several subscriptions One run per subscription, one output dir each, then compare `summary.md` files: ```sh for sub in $(az account list --query "[].id" --output tsv); do .claude/skills/az-sub-orienteering/scripts/az-sub-discovery.bash --subscription "${sub}" || true done ``` Or over a labelled list such as `BILLING_SUBS` (`label=id` pairs): ```sh for pair in ${BILLING_SUBS}; do .claude/skills/az-sub-orienteering/scripts/az-sub-discovery.bash --subscription "${pair#*=}" || true done ``` ### Cost across subscriptions `az-billing-report.bash` is the complement to the per-subscription `cost` module: one table of every subscription in `BILLING_SUBS` by total and by service, in markdown and CSV. Run it only when the user asks for cross-subscription cost or when comparing several sweeps; it is not part of a sweep. ```sh export BILLING_SUBS="prod=${PROD_SUBSCRIPTION_ID} dev=${DEV_SUBSCRIPTION_ID}" .claude/skills/az-sub-orienteering/scripts/az-billing-report.bash # month to date .claude/skills/az-sub-orienteering/scripts/az-billing-report.bash 2026-08 # one calendar month ``` Output: `billing-report-.md` and `.csv` in `BILLING_OUT_DIR`, default `.local/tmp/az-sub-discovery/billing/`. Retail rates only; reservations and partner pricing are not reflected, so totals are for trend and chargeback, never for invoice reconciliation. Needs `bc` in addition to `az` and `jq`. ## Permissions What the suite reads, which calls are data-plane and therefore not covered by any control-plane role, and a least-privilege role to replace asking for Owner: [references/permissions.md](references/permissions.md). Read it before requesting access for a new engagement, and whenever a report section comes back empty. ### Setting the access up Only when the user asks how to provision access. A sweep does not need this; it needs credentials that already work, and offering to build a role unprompted turns a read-only task into an infrastructure change. [az-sub-orienteering-permissions.tf](assets/az-sub-orienteering-permissions.tf) is a self-contained Terraform file creating the service principal, the custom role and the built-in assignments. Two scope options, one variable apart: | `assignment_scope` | Grants | Ask the user which | | ------------------ | ---------------------------------------------- | --------------------------------------- | | `subscription` | One subscription. The default | When the engagement names subscriptions | | `management_group` | Every subscription under `management_group_id` | When it covers a whole tenant or group | Default to `subscription` and say why. The wide option reopens the security review the narrow role exists to avoid, may need a tenant-wide Entra elevation to apply at the root group, and does not make the sweep itself multi-subscription — the scripts still run one subscription per invocation. The trade-offs are in the permissions reference under "Provisioning it". Never run `terraform apply` here. Hand the user the command; creating a principal and granting it access to a client estate is theirs to approve. ## Extending To add a module, a Resource Graph query, or a jq renderer, read [references/extending.md](references/extending.md).