|
1 | 1 | """Orphaned Azure resource scanning and cost lookup service. |
2 | 2 |
|
3 | | -This service detects orphaned resources (unattached disks, NICs, public IPs, |
4 | | -NSGs, App Service Plans) across Azure subscriptions and computes their |
5 | | -historical cost via the Azure Cost Management API. |
| 3 | +This service detects orphaned resources (unattached disks, public IPs, |
| 4 | +App Service Plans, SQL Elastic Pools, Application Gateways, NAT Gateways, |
| 5 | +Load Balancers, Private DNS Zones, Private Endpoints, Virtual Network Gateways, |
| 6 | +DDoS Protection Plans) across Azure subscriptions and computes their historical |
| 7 | +cost via the Azure Cost Management API. |
6 | 8 |
|
7 | 9 | All methods require Azure authentication. If not authenticated, they return |
8 | 10 | a friendly error message with instructions for how to authenticate. |
|
27 | 29 | # Azure Resource Manager API versions per resource type |
28 | 30 | ARM_API_VERSIONS = { |
29 | 31 | "disks": "2023-10-02", |
30 | | - "networkInterfaces": "2023-11-01", |
31 | 32 | "publicIPAddresses": "2023-11-01", |
32 | | - "networkSecurityGroups": "2023-11-01", |
33 | 33 | "serverfarms": "2023-12-01", |
34 | 34 | "subscriptions": "2022-12-01", |
35 | 35 | } |
|
46 | 46 | timeCreated = tostring(properties.timeCreated) |
47 | 47 | """ |
48 | 48 |
|
49 | | -# Resource Graph query for orphaned NICs (not attached to any VM) |
50 | | -ORPHANED_NICS_QUERY = """ |
51 | | -Resources |
52 | | -| where type =~ 'microsoft.network/networkinterfaces' |
53 | | -| where isnull(properties.virtualMachine.id) or properties.virtualMachine.id == '' |
54 | | -| where isnull(properties.privateEndpoint.id) or properties.privateEndpoint.id == '' |
55 | | -| project id, name, type, location, resourceGroup, |
56 | | - subscriptionId |
57 | | -""" |
58 | | - |
59 | 49 | # Resource Graph query for orphaned public IPs (not associated) |
60 | 50 | ORPHANED_PUBLIC_IPS_QUERY = """ |
61 | 51 | Resources |
|
67 | 57 | allocationMethod = tostring(properties.publicIPAllocationMethod) |
68 | 58 | """ |
69 | 59 |
|
70 | | -# Resource Graph query for orphaned NSGs (not associated with subnet or NIC) |
71 | | -ORPHANED_NSGS_QUERY = """ |
| 60 | +# Resource Graph query for empty App Service Plans |
| 61 | +ORPHANED_ASP_QUERY = """ |
| 62 | +Resources |
| 63 | +| where type =~ 'microsoft.web/serverfarms' |
| 64 | +| where properties.numberOfSites == 0 |
| 65 | +| project id, name, type, location, resourceGroup, |
| 66 | + subscriptionId, |
| 67 | + sku = tostring(sku.name), |
| 68 | + tier = tostring(sku.tier) |
| 69 | +""" |
| 70 | + |
| 71 | +# Resource Graph query for orphaned SQL Elastic Pools (no databases) |
| 72 | +ORPHANED_SQL_ELASTIC_POOLS_QUERY = """ |
| 73 | +Resources |
| 74 | +| where type =~ 'microsoft.sql/servers/elasticpools' |
| 75 | +| project id, name, type, location, resourceGroup, |
| 76 | + subscriptionId, |
| 77 | + sku = tostring(sku.name), |
| 78 | + tier = tostring(sku.tier), |
| 79 | + dtu = tostring(properties.dtu), |
| 80 | + serverName = tostring(split(id, '/')[8]) |
| 81 | +""" |
| 82 | + |
| 83 | +# Resource Graph query for orphaned Application Gateways (no backend targets) |
| 84 | +ORPHANED_APP_GATEWAYS_QUERY = """ |
| 85 | +Resources |
| 86 | +| where type =~ 'microsoft.network/applicationgateways' |
| 87 | +| where isnull(properties.backendAddressPools) or array_length(properties.backendAddressPools) == 0 |
| 88 | + or properties.backendAddressPools == '[]' |
| 89 | +| project id, name, type, location, resourceGroup, |
| 90 | + subscriptionId, |
| 91 | + sku = tostring(sku.name), |
| 92 | + tier = tostring(sku.tier) |
| 93 | +""" |
| 94 | + |
| 95 | +# Resource Graph query for orphaned NAT Gateways (not associated with any subnet) |
| 96 | +ORPHANED_NAT_GATEWAYS_QUERY = """ |
72 | 97 | Resources |
73 | | -| where type =~ 'microsoft.network/networksecuritygroups' |
74 | | -| where isnull(properties.networkInterfaces) or array_length(properties.networkInterfaces) == 0 |
| 98 | +| where type =~ 'microsoft.network/natgateways' |
75 | 99 | | where isnull(properties.subnets) or array_length(properties.subnets) == 0 |
76 | 100 | | project id, name, type, location, resourceGroup, |
77 | 101 | subscriptionId |
78 | 102 | """ |
79 | 103 |
|
80 | | -# Resource Graph query for empty App Service Plans |
81 | | -ORPHANED_ASP_QUERY = """ |
| 104 | +# Resource Graph query for orphaned Load Balancers (no backend address pools) |
| 105 | +ORPHANED_LOAD_BALANCERS_QUERY = """ |
82 | 106 | Resources |
83 | | -| where type =~ 'microsoft.web/serverfarms' |
84 | | -| where properties.numberOfSites == 0 |
| 107 | +| where type =~ 'microsoft.network/loadbalancers' |
| 108 | +| where isnull(properties.backendAddressPools) or array_length(properties.backendAddressPools) == 0 |
| 109 | +| project id, name, type, location, resourceGroup, |
| 110 | + subscriptionId, |
| 111 | + sku = tostring(sku.name) |
| 112 | +""" |
| 113 | + |
| 114 | +# Resource Graph query for orphaned Private DNS Zones (no virtual network links) |
| 115 | +ORPHANED_PRIVATE_DNS_ZONES_QUERY = """ |
| 116 | +Resources |
| 117 | +| where type =~ 'microsoft.network/privatednszones' |
| 118 | +| where isnull(properties.numberOfVirtualNetworkLinks) |
| 119 | + or properties.numberOfVirtualNetworkLinks == 0 |
| 120 | +| project id, name, type, location, resourceGroup, |
| 121 | + subscriptionId, |
| 122 | + recordSets = tostring(properties.numberOfRecordSets) |
| 123 | +""" |
| 124 | + |
| 125 | +# Resource Graph query for orphaned Private Endpoints (no connections or not approved) |
| 126 | +ORPHANED_PRIVATE_ENDPOINTS_QUERY = """ |
| 127 | +Resources |
| 128 | +| where type =~ 'microsoft.network/privateendpoints' |
| 129 | +| where isnull(properties.privateLinkServiceConnections) |
| 130 | + or array_length(properties.privateLinkServiceConnections) == 0 |
| 131 | + or properties.privateLinkServiceConnections[0].properties.privateLinkServiceConnectionState.status != 'Approved' |
| 132 | +| project id, name, type, location, resourceGroup, |
| 133 | + subscriptionId |
| 134 | +""" |
| 135 | + |
| 136 | +# Resource Graph query for orphaned Virtual Network Gateways (no IP configurations) |
| 137 | +ORPHANED_VNET_GATEWAYS_QUERY = """ |
| 138 | +Resources |
| 139 | +| where type =~ 'microsoft.network/virtualnetworkgateways' |
| 140 | +| where isnull(properties.ipConfigurations) or array_length(properties.ipConfigurations) == 0 |
85 | 141 | | project id, name, type, location, resourceGroup, |
86 | 142 | subscriptionId, |
87 | 143 | sku = tostring(sku.name), |
88 | | - tier = tostring(sku.tier) |
| 144 | + gatewayType = tostring(properties.gatewayType) |
| 145 | +""" |
| 146 | + |
| 147 | +# Resource Graph query for orphaned DDoS Protection Plans (no virtual networks) |
| 148 | +ORPHANED_DDOS_PLANS_QUERY = """ |
| 149 | +Resources |
| 150 | +| where type =~ 'microsoft.network/ddosprotectionplans' |
| 151 | +| where isnull(properties.virtualNetworks) or array_length(properties.virtualNetworks) == 0 |
| 152 | +| project id, name, type, location, resourceGroup, |
| 153 | + subscriptionId |
89 | 154 | """ |
90 | 155 |
|
91 | 156 |
|
@@ -351,10 +416,16 @@ async def scan( |
351 | 416 | # Execute all orphaned resource queries |
352 | 417 | queries = { |
353 | 418 | "Unattached Disk": ORPHANED_DISKS_QUERY, |
354 | | - "Orphaned NIC": ORPHANED_NICS_QUERY, |
355 | 419 | "Orphaned Public IP": ORPHANED_PUBLIC_IPS_QUERY, |
356 | | - "Orphaned NSG": ORPHANED_NSGS_QUERY, |
357 | 420 | "Empty App Service Plan": ORPHANED_ASP_QUERY, |
| 421 | + "Orphaned SQL Elastic Pool": ORPHANED_SQL_ELASTIC_POOLS_QUERY, |
| 422 | + "Orphaned Application Gateway": ORPHANED_APP_GATEWAYS_QUERY, |
| 423 | + "Orphaned NAT Gateway": ORPHANED_NAT_GATEWAYS_QUERY, |
| 424 | + "Orphaned Load Balancer": ORPHANED_LOAD_BALANCERS_QUERY, |
| 425 | + "Orphaned Private DNS Zone": ORPHANED_PRIVATE_DNS_ZONES_QUERY, |
| 426 | + "Orphaned Private Endpoint": ORPHANED_PRIVATE_ENDPOINTS_QUERY, |
| 427 | + "Orphaned Virtual Network Gateway": ORPHANED_VNET_GATEWAYS_QUERY, |
| 428 | + "Orphaned DDoS Protection Plan": ORPHANED_DDOS_PLANS_QUERY, |
358 | 429 | } |
359 | 430 |
|
360 | 431 | all_orphaned: list[dict[str, Any]] = [] |
|
0 commit comments