See your Azure estate without changing resources
How many VMs do we have, which disks need a decision, and which Arc servers need a connectivity review? Azure Resource Graph (ARG) lets you ask these questions across subscriptions. Here are ten read-only queries, with interpretation and checks before taking action.
ARG uses a subset of Kusto Query Language (KQL), but this is not a Defender Advanced Hunting guide. It explores Azure resource configuration rather than endpoint security telemetry. It is also distinct from Microsoft Graph for Microsoft 365 identities and licenses.
Prerequisites: explicit scope and read-only access
Start in Azure Portal > Resource Graph Explorer: select subscriptions, paste a query, and run it. The scripts below use PowerShell with Az.Accounts and Az.ResourceGraph installed through your approved process. Record module versions; do not update them during collection.
The identity needs Azure RBAC read access to the target resources; Reader at the necessary scope suits this workflow. Contributor or Owner is unnecessary. Results remain permission-limited: a successful collection can silently be partial. Have subscription owners confirm the expected list.
ARG is a free service subject to quotas. Azure resources and additional scheduling, storage, or alerting services can incur separate charges. These inventory queries do not require purchasing Defender or Microsoft Graph licenses.
# Setup only: install approved Az.Accounts / Az.ResourceGraph versions beforehand
Import-Module Az.Accounts
Import-Module Az.ResourceGraph
Get-Module Az.Accounts,Az.ResourceGraph | Select-Object Name,Version
$tenantId = '11111111-1111-1111-1111-111111111111' # fictional
$subscriptionIds = @('22222222-2222-2222-2222-222222222222') # fictional
Connect-AzAccount -Tenant $tenantId -ErrorAction Stop
Get-AzContext | Select-Object Account,Tenant,Subscription
Get-AzSubscription -TenantId $tenantId -ErrorAction Stop |
Select-Object Id,Name,State
# Paste ONE numbered query into this literal here-string:
$query = @'
Resources
| project id, name, type, subscriptionId
| order by id asc
'@
$page = Search-AzGraph -Query $query -Subscription $subscriptionIds -First 1000 -ErrorAction Stop
$page.DataInterpret results within their limits
Resources contains indexed properties, not an instantaneous transactional view. After a change, confirm the current value with the resource provider. Property-path casing matters; missing fields may also reflect the indexed API version. Inspect the schema and a known resource before generalizing a filter.
All ten queries use the same RBAC read requirement, without needing VM data access, storage keys, or secrets. Replace only fictional examples and tag conventions. Never put secrets in tags or exports.
1. Count resources by subscription and type
Compare this distribution with expected scope. An aggregate row is not a resource, and this count does not enumerate all service data such as files, messages, or application objects.
Resources
| summarize resourceCount=count() by subscriptionId, type
| order by subscriptionId asc, type asc2. Inventory Azure virtual machines
Use id to reconcile with the CMDB, rather than name alone. ProvisioningState describes provisioning; Succeeded does not prove Windows, Linux, or the application responds. This selection excludes VM Scale Set instances.
Resources
| where type =~ 'microsoft.compute/virtualmachines'
| project id, name, subscriptionId, resourceGroup, location,
vmSize=tostring(properties.hardwareProfile.vmSize),
osType=tostring(properties.storageProfile.osDisk.osType),
provisioningState=tostring(properties.provisioningState)
| order by id asc3. Find unattached managed disks
Unattached does not mean unnecessary: the disk may support recovery, migration, or standby. Confirm current state with Compute, ownership, backup, and dependencies before deciding. Disk size alone cannot establish exact savings.
Resources
| where type =~ 'microsoft.compute/disks'
| where tostring(properties.diskState) =~ 'Unattached'
| project id, name, subscriptionId, resourceGroup, location,
diskGB=toint(properties.diskSizeGB), sku=tostring(sku.name),
managedBy, timeCreated=todatetime(properties.timeCreated)
| order by id asc4. Inspect public IP resources
Include IP resources without an assigned address. A public IP does not prove a port is reachable: examine associations, firewalls, NSGs, routes, and service configuration. Empty association fields alone do not establish that it is orphaned.
Resources
| where type =~ 'microsoft.network/publicipaddresses'
| project id, name, subscriptionId, resourceGroup, location,
ip=tostring(properties.ipAddress), sku=tostring(sku.name),
allocation=tostring(properties.publicIPAllocationMethod),
ipConfigurationId=tostring(properties.ipConfiguration.id),
natGatewayId=tostring(properties.natGateway.id)
| order by id asc5. Find missing governance tags
Owner and Environment are fictional convention keys. Adapt casing and allowed values. Some resource types do not support tags; check applicability before declaring noncompliance. Resource-group tags are not automatically copied to contained resources.
Resources
| extend owner=tostring(tags['Owner']),
environment=tostring(tags['Environment'])
| where isempty(owner) or isempty(environment)
| project id, name, type, subscriptionId, resourceGroup, owner, environment
| order by id asc6. Add subscription display names
The left join retains resources even when no subscription display name is found. Keep subscriptionId as the stable identifier; labels can change. Incorrect normalization or multiple rows per key can multiply results.
Resources
| join kind=leftouter (
ResourceContainers
| where type =~ 'microsoft.resources/subscriptions'
| project subscriptionId, subscriptionName=name
) on subscriptionId
| project id, name, type, subscriptionId, subscriptionName, resourceGroup, location
| order by id asc7. Inventory Azure Arc-enabled servers
These rows represent Arc-registered machines, not every on-premises server. Missing fields may reflect schema or indexing. Compare agent versions with Arc documentation; do not compare version numbers as alphabetic strings.
Resources
| where type =~ 'microsoft.hybridcompute/machines'
| project id, name, subscriptionId, resourceGroup, location,
status=tostring(properties.status),
agentVersion=tostring(properties.agentVersion),
osName=tostring(properties.osName),
osVersion=tostring(properties.osVersion)
| order by id asc8. Prioritize Arc statuses for investigation
A status other than Connected triggers review: planned maintenance, retired machine, networking, proxy, or agent. It does not by itself prove an OS outage. Validate with the owner and Arc connectivity diagnostics without deleting registration.
Resources
| where type =~ 'microsoft.hybridcompute/machines'
| extend arcStatus=tostring(properties.status)
| where isempty(arcStatus) or arcStatus !~ 'Connected'
| project id, name, subscriptionId, resourceGroup, arcStatus
| order by id asc9. List storage accounts and declared controls
These properties guide review, not an exposure verdict. Distinguish public network access, anonymous blob access, authentication, and private endpoints. Resolve empty values against the resource provider rather than assuming a control is enabled or disabled.
Resources
| where type =~ 'microsoft.storage/storageaccounts'
| project id, name, subscriptionId, resourceGroup, location,
publicNetworkAccess=tostring(properties.publicNetworkAccess),
allowBlobPublicAccess=tostring(properties.allowBlobPublicAccess),
supportsHttpsTrafficOnly=tostring(properties.supportsHttpsTrafficOnly),
minimumTlsVersion=tostring(properties.minimumTlsVersion)
| order by id asc10. Find provisioning states that need review
This deliberately narrow filter does not cover every resource type or status model. Next inspect Activity Log and the relevant provider operation. No results do not establish availability, compliance, or application health.
Resources
| extend provisioningState=tostring(properties.provisioningState)
| where provisioningState in~ ('Failed', 'Canceled')
| project id, name, type, subscriptionId, resourceGroup, provisioningState
| order by id ascPagination: 1,000 rows are not a complete inventory
A response returns at most 1,000 rows. For lists, retain id and a stable order, avoid take, limit, or sample, and follow SkipToken. Aggregation such as query 1 produces groups rather than resource rows; do not blindly apply a resource-export routine to it.
The example exports common columns only; add query-specific fields to Select-Object. No token is not universal proof of completeness: some query shapes prevent paging. Also check counts and truncation indicators available in your client.
# Use a row-level query from 2–10, retaining id and order by id.
# Same query and subscription list on every page; no take/limit/sample.
$rows = [System.Collections.Generic.List[object]]::new()
$token = $null
do {
$parameters = @{
Query=$query; Subscription=$subscriptionIds; First=1000
ErrorAction='Stop'
}
if ($token) { $parameters.SkipToken = $token }
$page = Search-AzGraph @parameters
foreach ($row in $page.Data) { $rows.Add($row) }
$token = $page.SkipToken
} while ($token)
# Local output only; choose an approved restricted directory.
$rows | Select-Object id,name,type,subscriptionId,resourceGroup,location |
Export-Csv -LiteralPath './azure-inventory.csv' -NoTypeInformation -Encoding utf8A useful follow-up report without automatic remediation
Record full resource ID, query, scope, collection time, finding, and owner. Priority depends on business risk, not row count. Never turn an export directly into a Remove-AzResource loop.
| Fictional finding | Validation | Proposed action |
|---|---|---|
| disk-recovery-01 unattached | Owner, recovery plan, current Compute state | Retain pending purpose and backup validation |
| srv-arc-demo not Connected | Maintenance, agent, proxy, connectivity | Investigate; do not delete Arc registration |
| Owner missing on vm-demo-01 | Tag support and responsible team | Prepare an approved pilot tag |
Move to monitoring, then controlled changes
- Pilot one known subscription: compare selected IDs against the portal and provider, then validate totals before broadening scope.
- For scheduled collection, use a managed identity where available with limited read access. Protect exports, define retention, and monitor collection failures too.
- ARG is not an availability or metrics engine. Complement it with Azure Monitor, Activity Log, and relevant diagnostics; a saved query alone sends no alerts. Validate availability status and cost for any chosen alert integration.
- Before a change: fresh provider read, owner, dependencies, backup, ticket, window, and pilot. Record prior configuration; define verification and rollback before writing.
- For a pilot tag, restore only the changed key while preserving concurrent changes. Disk deletion has no simple undo: tested restoration is essential. This guide provides no deletion.
- For 403, examine identity and scope without automatically broadening rights. For throttling, honor reported delays and quotas; space requests. For missing fields, check schema and casing before concluding.
Turn inventory into governance
These queries provide a starting point for CMDB reconciliation, ownership, and cost or configuration reviews. They replace neither a complete assessment nor end-to-end monitoring. Azure infrastructure consulting can help define scope, make collection dependable, and turn findings into measured changes.