cloudmap 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudmap/__init__.py +3 -0
- cloudmap/__main__.py +6 -0
- cloudmap/adapters/__init__.py +91 -0
- cloudmap/ask/__init__.py +111 -0
- cloudmap/ask/intent.py +133 -0
- cloudmap/ask/narration.py +54 -0
- cloudmap/ask/queries.py +296 -0
- cloudmap/cli.py +534 -0
- cloudmap/extract/__init__.py +0 -0
- cloudmap/extract/extractors.py +653 -0
- cloudmap/extract/llm.py +89 -0
- cloudmap/graph.py +208 -0
- cloudmap/ingest/__init__.py +0 -0
- cloudmap/ingest/azure.py +380 -0
- cloudmap/ingest/fixture.py +15 -0
- cloudmap/interactive.py +227 -0
- cloudmap/local_model.py +49 -0
- cloudmap/model.py +43 -0
- cloudmap/render/__init__.py +0 -0
- cloudmap/render/azure_icons.py +72 -0
- cloudmap/render/csv_export.py +47 -0
- cloudmap/render/drawio.py +149 -0
- cloudmap/render/html.py +579 -0
- cloudmap/render/json_out.py +52 -0
- cloudmap/render/mermaid.py +25 -0
- cloudmap/scrub.py +300 -0
- cloudmap-1.0.0.dist-info/METADATA +340 -0
- cloudmap-1.0.0.dist-info/RECORD +31 -0
- cloudmap-1.0.0.dist-info/WHEEL +4 -0
- cloudmap-1.0.0.dist-info/entry_points.txt +2 -0
- cloudmap-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""Edge extractors: derive dependency edges from Azure resource properties.
|
|
2
|
+
|
|
3
|
+
Azure Resource Graph has no first-class "dependencies" table. Relationships are
|
|
4
|
+
implied by ids, hostnames and secrets buried in each resource's `properties` and
|
|
5
|
+
config. This module reads them out and, crucially, VERIFIES each target against
|
|
6
|
+
the set of resources we scanned - anything referenced but not found is surfaced
|
|
7
|
+
as an explicit `external` node rather than silently dropped.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from ..model import Edge, Node
|
|
14
|
+
|
|
15
|
+
# Well-known role definition GUIDs -> friendly names (fallback when az/RG did not
|
|
16
|
+
# already give us roleDefinitionName).
|
|
17
|
+
# Built-in Azure role GUIDs. These are GLOBAL constants - identical in every
|
|
18
|
+
# tenant - so naming them here is generic, not tenant-specific. Resource Graph
|
|
19
|
+
# often omits roleDefinitionName, leaving only the GUID; without this table every
|
|
20
|
+
# such edge collapses to "custom role", which hides the one thing an RBAC edge is
|
|
21
|
+
# for: WHAT the access is. An AKS "RBAC Reader" (an observer) then looks identical
|
|
22
|
+
# to a "Cluster Admin" (an operator). Anything genuinely custom (a tenant-defined
|
|
23
|
+
# role) still falls through to "custom role" - honestly, because we cannot name it.
|
|
24
|
+
ROLE_NAMES = {
|
|
25
|
+
# identity / general
|
|
26
|
+
"8e3af657-a8ff-443c-a75c-2fe8c4bcb635": "Owner",
|
|
27
|
+
"b24988ac-6180-42a0-ab88-20f7382dd24c": "Contributor",
|
|
28
|
+
"acdd72a7-3385-48ef-bd42-f606fba81ae7": "Reader",
|
|
29
|
+
"c12c1c16-33a1-487b-954d-41c89c60f349": "Reader and Data Access",
|
|
30
|
+
"18d7d88d-d35e-4fb5-a5c3-7773c20a72d9": "User Access Administrator",
|
|
31
|
+
"f58310d9-a9f6-439a-9e8d-f62e7b41a168": "Role Based Access Control Administrator",
|
|
32
|
+
# web / compute
|
|
33
|
+
"de139f84-1756-47ae-9be6-808fbbe84772": "Website Contributor",
|
|
34
|
+
# Key Vault
|
|
35
|
+
"4633458b-17de-408a-b874-0445c86b69e6": "Key Vault Secrets User",
|
|
36
|
+
"b86a8fe4-44ce-4948-aee5-eccb2c155cd7": "Key Vault Secrets Officer",
|
|
37
|
+
"21090545-7ca7-4776-b22c-e363652d74d2": "Key Vault Reader",
|
|
38
|
+
"db79e9a7-68ee-4b58-9aeb-b90e7c24fcba": "Key Vault Certificate User",
|
|
39
|
+
"12338af0-0e69-4776-bea7-57ae8d297424": "Key Vault Crypto User",
|
|
40
|
+
"00482a5a-887f-4fb3-b363-3b7fe8e74483": "Key Vault Administrator",
|
|
41
|
+
# storage
|
|
42
|
+
"ba92f5b4-2d11-453d-a403-e96b0029c9fe": "Storage Blob Data Contributor",
|
|
43
|
+
"2a2b9908-6ea1-4ae2-8e65-a410df84e7d1": "Storage Blob Data Reader",
|
|
44
|
+
"b7e6dc6d-f1e8-4753-8033-0f276bb0955b": "Storage Blob Data Owner",
|
|
45
|
+
"0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3": "Storage Table Data Contributor",
|
|
46
|
+
"69566ab7-960f-475b-8e7c-b3118f30c6bd": "Storage File Data Privileged Contributor",
|
|
47
|
+
"17d1049b-9a84-46fb-8f53-869881c3d3ab": "Storage Account Contributor",
|
|
48
|
+
# container registry
|
|
49
|
+
"7f951dda-4ed3-4680-a7ca-43fe172d538d": "AcrPull",
|
|
50
|
+
"8311e382-0749-4cb8-b61a-304f252e45ec": "AcrPush",
|
|
51
|
+
# AI / Cognitive Services / Foundry (central to an AI platform - these were
|
|
52
|
+
# the bulk of the "custom role" edges on OpenAI and Search resources)
|
|
53
|
+
"a97b65f3-24c7-4388-baec-2e87135dc908": "Cognitive Services User",
|
|
54
|
+
"5e0bd9bd-7b93-4f28-af87-19fc36ad61bd": "Cognitive Services OpenAI User",
|
|
55
|
+
"a001fd3d-188f-4b5d-821b-7da978bf7442": "Cognitive Services OpenAI Contributor",
|
|
56
|
+
"b78c5d69-af96-48a3-bf8d-a8b4d589de94": "Azure AI Administrator",
|
|
57
|
+
"64702f94-c441-49e6-a78b-ef80e0188fee": "Azure AI Developer",
|
|
58
|
+
"3afb7f49-54cb-416e-8c09-6dc049efa503": "Azure AI Inference Deployment Operator",
|
|
59
|
+
"53ca6127-db72-4b80-b1b0-d745d6d5456d": "Foundry User",
|
|
60
|
+
# Cognitive Search
|
|
61
|
+
"1407120a-92aa-4202-b7e9-c0e197c71c8f": "Search Index Data Reader",
|
|
62
|
+
"8ebe5a00-799e-43f5-93ac-243d3dce84a7": "Search Index Data Contributor",
|
|
63
|
+
"7ca78c08-252a-4471-8644-bb5ff32d4ba0": "Search Service Contributor",
|
|
64
|
+
# AKS (all built-in - the reason an observer showed up as "custom role")
|
|
65
|
+
"0ab0b1a8-8aac-4efd-b8c2-3ee1fb270be8": "Azure Kubernetes Service Cluster Admin Role",
|
|
66
|
+
"4abbcc35-e782-43d8-92c5-2d3f1bd2253f": "Azure Kubernetes Service Cluster User Role",
|
|
67
|
+
"b1ff04bb-8a4e-4dc4-8eb5-8693973ce19b": "Azure Kubernetes Service RBAC Cluster Admin",
|
|
68
|
+
"3498e952-d568-435e-9b2c-8d77e338d7f7": "Azure Kubernetes Service RBAC Admin",
|
|
69
|
+
"a7ffa36f-339b-4b5c-8bdf-e2c188b2c0eb": "Azure Kubernetes Service RBAC Writer",
|
|
70
|
+
"7f6c6a51-bcf8-42ba-9220-52d62157d7db": "Azure Kubernetes Service RBAC Reader",
|
|
71
|
+
# networking / monitoring
|
|
72
|
+
"4d97b98b-1d4f-4787-a291-c67834d212e7": "Network Contributor",
|
|
73
|
+
"43d0d8ad-25c7-4714-9337-8ba259a9fe05": "Monitoring Reader",
|
|
74
|
+
"73c42c96-874c-492b-b04d-ab87d138a893": "Log Analytics Reader",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Service-endpoint domains we recognise -> (edge kind, friendly category).
|
|
78
|
+
# Order matters: first suffix match wins.
|
|
79
|
+
_DOMAIN_KIND = [
|
|
80
|
+
("vault.azure.net", "reads-secret", "Key Vault"),
|
|
81
|
+
("postgres.database.azure.com", "connects-to", "PostgreSQL"),
|
|
82
|
+
("mysql.database.azure.com", "connects-to", "MySQL"),
|
|
83
|
+
("database.windows.net", "connects-to", "SQL"),
|
|
84
|
+
("documents.azure.com", "connects-to", "Cosmos DB"),
|
|
85
|
+
("redis.cache.windows.net", "connects-to", "Redis"),
|
|
86
|
+
("servicebus.windows.net", "connects-to", "Service Bus"),
|
|
87
|
+
("search.windows.net", "connects-to", "Cognitive Search"),
|
|
88
|
+
("openai.azure.com", "connects-to", "Azure OpenAI"),
|
|
89
|
+
("cognitiveservices.azure.com", "connects-to", "Cognitive Services"),
|
|
90
|
+
("blob.core.windows.net", "connects-to", "Storage (blob)"),
|
|
91
|
+
("queue.core.windows.net", "connects-to", "Storage (queue)"),
|
|
92
|
+
("table.core.windows.net", "connects-to", "Storage (table)"),
|
|
93
|
+
("file.core.windows.net", "connects-to", "Storage (file)"),
|
|
94
|
+
("azurecr.io", "pulls-image", "Container Registry"),
|
|
95
|
+
("azurewebsites.net", "calls", "Web App"),
|
|
96
|
+
("azurecontainerapps.io", "calls", "Container App"),
|
|
97
|
+
("login.microsoftonline.com", "authenticates-via", "Entra ID"),
|
|
98
|
+
("sts.windows.net", "authenticates-via", "Entra ID"),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
_HOST_RE = re.compile(
|
|
102
|
+
r"([a-z0-9][a-z0-9\-]*(?:\.[a-z0-9\-]+)*\.(?:"
|
|
103
|
+
r"vault\.azure\.net|postgres\.database\.azure\.com|mysql\.database\.azure\.com|"
|
|
104
|
+
r"database\.windows\.net|documents\.azure\.com|redis\.cache\.windows\.net|"
|
|
105
|
+
r"servicebus\.windows\.net|search\.windows\.net|openai\.azure\.com|"
|
|
106
|
+
r"cognitiveservices\.azure\.com|blob\.core\.windows\.net|queue\.core\.windows\.net|"
|
|
107
|
+
r"table\.core\.windows\.net|file\.core\.windows\.net|azurecr\.io|azurewebsites\.net|"
|
|
108
|
+
r"azurecontainerapps\.io"
|
|
109
|
+
r"))",
|
|
110
|
+
re.I,
|
|
111
|
+
)
|
|
112
|
+
_ENTRA_RE = re.compile(r"(login\.microsoftonline\.com|sts\.windows\.net)", re.I)
|
|
113
|
+
|
|
114
|
+
_KV_REF = re.compile(r"@microsoft\.keyvault\(([^)]*)\)", re.I)
|
|
115
|
+
_VAULT_NAME = re.compile(r"vaultname\s*=\s*([^;)\s]+)", re.I)
|
|
116
|
+
_SECRET_URI_HOST = re.compile(r"https://([a-z0-9\-]+)\.vault\.azure\.net", re.I)
|
|
117
|
+
_STORAGE_ACCOUNT = re.compile(r"accountname\s*=\s*([a-z0-9]{3,24})", re.I)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _lower(s):
|
|
121
|
+
return s.lower() if isinstance(s, str) else s
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def domain_kind(host):
|
|
125
|
+
h = (host or "").lower()
|
|
126
|
+
for suffix, kind, cat in _DOMAIN_KIND:
|
|
127
|
+
if h.endswith(suffix) or suffix in h:
|
|
128
|
+
return kind, cat
|
|
129
|
+
return "references", "external service"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _vault_refs(text):
|
|
133
|
+
names = set()
|
|
134
|
+
for inner in _KV_REF.findall(text):
|
|
135
|
+
m = _VAULT_NAME.search(inner)
|
|
136
|
+
if m:
|
|
137
|
+
names.add(m.group(1).lower())
|
|
138
|
+
m = _SECRET_URI_HOST.search(inner)
|
|
139
|
+
if m:
|
|
140
|
+
names.add(m.group(1).lower())
|
|
141
|
+
return names
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _host_of(url_or_host):
|
|
145
|
+
s = (url_or_host or "").strip().lower()
|
|
146
|
+
if "://" in s:
|
|
147
|
+
return urlparse(s).hostname or ""
|
|
148
|
+
return s.split("/")[0].split(":")[0]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
_IDENT = set("abcdefghijklmnopqrstuvwxyz0123456789-.")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _bounded_in(needle, blob):
|
|
155
|
+
"""True if `needle` occurs in `blob` as a whole hostname/identifier, i.e. not
|
|
156
|
+
glued to more hostname chars on either side. Stops 'api.azurewebsites.net'
|
|
157
|
+
from matching inside 'myapi.azurewebsites.net', or account 'foo' inside
|
|
158
|
+
'foobar'. A boundary is anything that is not a letter/digit/dot/hyphen."""
|
|
159
|
+
start = 0
|
|
160
|
+
while True:
|
|
161
|
+
i = blob.find(needle, start)
|
|
162
|
+
if i < 0:
|
|
163
|
+
return False
|
|
164
|
+
before = blob[i - 1] if i > 0 else ""
|
|
165
|
+
after = blob[i + len(needle)] if i + len(needle) < len(blob) else ""
|
|
166
|
+
if before not in _IDENT and after not in _IDENT:
|
|
167
|
+
return True
|
|
168
|
+
start = i + 1
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Resolver:
|
|
172
|
+
"""Maps ids / subnet ids / hostnames / principals to a known node id, and
|
|
173
|
+
can look up whether an endpoint host corresponds to a scanned resource."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, nodes):
|
|
176
|
+
self.by_id = {}
|
|
177
|
+
self.by_host = {} # endpoint hostname -> node_id
|
|
178
|
+
self.by_principal = {}
|
|
179
|
+
self.by_ik = {}
|
|
180
|
+
self.web_hosts = {} # web default hostname -> node_id
|
|
181
|
+
self.kv_by_name = {}
|
|
182
|
+
self.acr_by_loginserver = {}
|
|
183
|
+
self.storage_by_name = {}
|
|
184
|
+
self.law_by_customer_id = {} # Log Analytics workspace GUID -> node_id
|
|
185
|
+
self.by_name = {} # resource name -> node_id (for verifying LLM proposals)
|
|
186
|
+
for n in nodes.values():
|
|
187
|
+
self.by_id[n.id.lower()] = n.id
|
|
188
|
+
if n.name:
|
|
189
|
+
self.by_name.setdefault(n.name.lower(), n.id)
|
|
190
|
+
self._index(n)
|
|
191
|
+
|
|
192
|
+
def _add_host(self, host, node_id):
|
|
193
|
+
if host:
|
|
194
|
+
self.by_host[host.lower()] = node_id
|
|
195
|
+
|
|
196
|
+
def _index(self, n):
|
|
197
|
+
p = n.raw.get("properties") or {}
|
|
198
|
+
t = n.type
|
|
199
|
+
name = n.name.lower()
|
|
200
|
+
|
|
201
|
+
pid = (n.raw.get("identity") or {}).get("principalId")
|
|
202
|
+
if pid:
|
|
203
|
+
self.by_principal[pid.lower()] = n.id
|
|
204
|
+
# AKS pulls images with a SEPARATE kubelet identity; map it to the cluster
|
|
205
|
+
# so its role assignments (e.g. AcrPull on an ACR) attribute to the cluster.
|
|
206
|
+
kubelet = ((p.get("identityProfile") or {}).get("kubeletidentity") or {})
|
|
207
|
+
koid = kubelet.get("objectId")
|
|
208
|
+
if koid:
|
|
209
|
+
self.by_principal[koid.lower()] = n.id
|
|
210
|
+
|
|
211
|
+
if t == "microsoft.keyvault/vaults":
|
|
212
|
+
self._add_host(_host_of(p.get("vaultUri")) or f"{name}.vault.azure.net", n.id)
|
|
213
|
+
self.kv_by_name[name] = n.id
|
|
214
|
+
elif t == "microsoft.sql/servers":
|
|
215
|
+
self._add_host(p.get("fullyQualifiedDomainName") or f"{name}.database.windows.net", n.id)
|
|
216
|
+
elif t.startswith("microsoft.dbforpostgresql/"):
|
|
217
|
+
self._add_host(p.get("fullyQualifiedDomainName") or f"{name}.postgres.database.azure.com", n.id)
|
|
218
|
+
elif t.startswith("microsoft.dbformysql/"):
|
|
219
|
+
self._add_host(p.get("fullyQualifiedDomainName") or f"{name}.mysql.database.azure.com", n.id)
|
|
220
|
+
elif t == "microsoft.documentdb/databaseaccounts":
|
|
221
|
+
self._add_host(_host_of(p.get("documentEndpoint")) or f"{name}.documents.azure.com", n.id)
|
|
222
|
+
elif t == "microsoft.cache/redis":
|
|
223
|
+
self._add_host(p.get("hostName") or f"{name}.redis.cache.windows.net", n.id)
|
|
224
|
+
elif t == "microsoft.servicebus/namespaces":
|
|
225
|
+
self._add_host(_host_of(p.get("serviceBusEndpoint")) or f"{name}.servicebus.windows.net", n.id)
|
|
226
|
+
elif t == "microsoft.search/searchservices":
|
|
227
|
+
self._add_host(f"{name}.search.windows.net", n.id)
|
|
228
|
+
elif t == "microsoft.cognitiveservices/accounts":
|
|
229
|
+
self._add_host(_host_of(p.get("endpoint")), n.id)
|
|
230
|
+
self._add_host(f"{name}.openai.azure.com", n.id)
|
|
231
|
+
self._add_host(f"{name}.cognitiveservices.azure.com", n.id)
|
|
232
|
+
elif t == "microsoft.storage/storageaccounts":
|
|
233
|
+
for suffix in ("blob", "queue", "table", "file"):
|
|
234
|
+
self._add_host(f"{name}.{suffix}.core.windows.net", n.id)
|
|
235
|
+
self.storage_by_name[name] = n.id
|
|
236
|
+
elif t == "microsoft.containerregistry/registries":
|
|
237
|
+
login = (p.get("loginServer") or f"{name}.azurecr.io").lower()
|
|
238
|
+
self._add_host(login, n.id)
|
|
239
|
+
self.acr_by_loginserver[login] = n.id
|
|
240
|
+
elif t == "microsoft.web/sites":
|
|
241
|
+
dh = (p.get("defaultHostName") or f"{name}.azurewebsites.net").lower()
|
|
242
|
+
self.web_hosts[dh] = n.id
|
|
243
|
+
self._add_host(dh, n.id)
|
|
244
|
+
elif t == "microsoft.app/containerapps":
|
|
245
|
+
# Its ingress FQDN goes in web_hosts too, so an App Gateway backend
|
|
246
|
+
# pool pointing at a container app resolves the same way as one
|
|
247
|
+
# pointing at a web app.
|
|
248
|
+
fqdn = _lower(((p.get("configuration") or {}).get("ingress") or {}).get("fqdn"))
|
|
249
|
+
if fqdn:
|
|
250
|
+
self.web_hosts[fqdn] = n.id
|
|
251
|
+
self._add_host(fqdn, n.id)
|
|
252
|
+
elif t == "microsoft.insights/components":
|
|
253
|
+
ik = p.get("InstrumentationKey")
|
|
254
|
+
if ik:
|
|
255
|
+
self.by_ik[ik.lower()] = n.id
|
|
256
|
+
elif t == "microsoft.operationalinsights/workspaces":
|
|
257
|
+
# A Container Apps environment names its workspace by customerId, not
|
|
258
|
+
# by resource id - index it so that reference can still be resolved.
|
|
259
|
+
cid = _lower(p.get("customerId"))
|
|
260
|
+
if cid:
|
|
261
|
+
self.law_by_customer_id[cid] = n.id
|
|
262
|
+
|
|
263
|
+
def by_resource_id(self, idstr):
|
|
264
|
+
if not isinstance(idstr, str):
|
|
265
|
+
return None
|
|
266
|
+
key = idstr.strip().lower()
|
|
267
|
+
if key in self.by_id:
|
|
268
|
+
return self.by_id[key]
|
|
269
|
+
marker = "/subnets/"
|
|
270
|
+
if marker in key:
|
|
271
|
+
vnet = key.split(marker)[0]
|
|
272
|
+
if vnet in self.by_id:
|
|
273
|
+
return self.by_id[vnet]
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
def host_lookup(self, host):
|
|
277
|
+
return self.by_host.get((host or "").lower())
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# Types excluded as a SOURCE of generic reference edges. Two rationales, same
|
|
281
|
+
# effect - keep them off the map as edge origins:
|
|
282
|
+
# - fabric: foundational network resources are depended-UPON, never depending;
|
|
283
|
+
# emitting edges from them turns a shared VNet into a hub (its subnets list
|
|
284
|
+
# everything plugged in). Dependents still point AT them (vnet-integration).
|
|
285
|
+
# - observers: monitoring/alerting/dashboards exist to POINT AT resources and
|
|
286
|
+
# watch or display them. They are not dependencies - if you change the target
|
|
287
|
+
# the observer does not break, it just observes - so they are noise on a
|
|
288
|
+
# blast-radius map (the same reason a read-only RBAC role is not a dependent).
|
|
289
|
+
_GENERIC_SOURCE_SKIP = {
|
|
290
|
+
# fabric
|
|
291
|
+
"microsoft.network/virtualnetworks",
|
|
292
|
+
"microsoft.network/networksecuritygroups",
|
|
293
|
+
"microsoft.network/routetables",
|
|
294
|
+
"microsoft.network/privatednszones",
|
|
295
|
+
# observers
|
|
296
|
+
"microsoft.alertsmanagement/prometheusrulegroups",
|
|
297
|
+
"microsoft.alertsmanagement/smartdetectoralertrules",
|
|
298
|
+
"microsoft.insights/metricalerts",
|
|
299
|
+
"microsoft.insights/scheduledqueryrules",
|
|
300
|
+
"microsoft.insights/activitylogalerts",
|
|
301
|
+
"microsoft.portal/dashboards",
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _iter_arm_ids(obj, path):
|
|
306
|
+
"""Yield (arm_id, dotted_path) for every value under `obj` that is itself a
|
|
307
|
+
whole ARM resource id. The path becomes the edge's proof. Substrings inside
|
|
308
|
+
URIs are not matched (the value must BE an id), and the resolver is the real
|
|
309
|
+
filter - an id that names nothing we scanned yields no edge."""
|
|
310
|
+
if isinstance(obj, dict):
|
|
311
|
+
for k, v in obj.items():
|
|
312
|
+
yield from _iter_arm_ids(v, f"{path}.{k}")
|
|
313
|
+
elif isinstance(obj, list):
|
|
314
|
+
for v in obj:
|
|
315
|
+
yield from _iter_arm_ids(v, f"{path}[]")
|
|
316
|
+
elif isinstance(obj, str):
|
|
317
|
+
s = obj.strip()
|
|
318
|
+
low = s.lower()
|
|
319
|
+
if low.startswith("/subscriptions/") and "/providers/" in low:
|
|
320
|
+
yield s, path
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def extract_edges(nodes):
|
|
324
|
+
"""ARM-derivable edges over the whole scanned set. Targets that do not
|
|
325
|
+
resolve are dropped here (they would be noise at tenant scale); the seed's
|
|
326
|
+
own unresolved references are surfaced separately as external nodes."""
|
|
327
|
+
r = Resolver(nodes)
|
|
328
|
+
edges = []
|
|
329
|
+
|
|
330
|
+
def add(src, dst, kind, evidence=""):
|
|
331
|
+
if src and dst and src != dst:
|
|
332
|
+
edges.append(Edge(source=src, target=dst, kind=kind,
|
|
333
|
+
origin="extracted", evidence=evidence))
|
|
334
|
+
|
|
335
|
+
for n in nodes.values():
|
|
336
|
+
p = n.raw.get("properties") or {}
|
|
337
|
+
t = n.type
|
|
338
|
+
|
|
339
|
+
farm = p.get("serverFarmId")
|
|
340
|
+
if farm:
|
|
341
|
+
add(n.id, r.by_resource_id(farm), "hosted-on", "properties.serverFarmId")
|
|
342
|
+
|
|
343
|
+
subnet = p.get("virtualNetworkSubnetId")
|
|
344
|
+
if subnet:
|
|
345
|
+
add(n.id, r.by_resource_id(subnet), "vnet-integration",
|
|
346
|
+
"properties.virtualNetworkSubnetId")
|
|
347
|
+
|
|
348
|
+
for pool in p.get("agentPoolProfiles") or []:
|
|
349
|
+
if pool.get("vnetSubnetID"):
|
|
350
|
+
add(n.id, r.by_resource_id(pool["vnetSubnetID"]), "vnet-integration",
|
|
351
|
+
"properties.agentPoolProfiles[].vnetSubnetID")
|
|
352
|
+
|
|
353
|
+
addon = (((p.get("addonProfiles") or {}).get("omsagent") or {}).get("config") or {})
|
|
354
|
+
if addon.get("logAnalyticsWorkspaceResourceID"):
|
|
355
|
+
add(n.id, r.by_resource_id(addon["logAnalyticsWorkspaceResourceID"]),
|
|
356
|
+
"uses-workspace", "properties.addonProfiles.omsagent")
|
|
357
|
+
if p.get("WorkspaceResourceId"):
|
|
358
|
+
add(n.id, r.by_resource_id(p["WorkspaceResourceId"]), "uses-workspace",
|
|
359
|
+
"properties.WorkspaceResourceId")
|
|
360
|
+
|
|
361
|
+
# ML workspace / AI Hub: its backing platform is a set of ARM ids in
|
|
362
|
+
# properties (the store, vault, telemetry and image registry it is built on).
|
|
363
|
+
if t == "microsoft.machinelearningservices/workspaces":
|
|
364
|
+
for key, kind in (("storageAccount", "connects-to"),
|
|
365
|
+
("keyVault", "reads-secret"),
|
|
366
|
+
("applicationInsights", "sends-telemetry"),
|
|
367
|
+
("containerRegistry", "pulls-image")):
|
|
368
|
+
ref = p.get(key)
|
|
369
|
+
if isinstance(ref, str) and ref.startswith("/subscriptions/"):
|
|
370
|
+
add(n.id, r.by_resource_id(ref), kind, f"properties.{key}")
|
|
371
|
+
for assoc in p.get("associatedWorkspaces") or []:
|
|
372
|
+
add(n.id, r.by_resource_id(assoc), "associated-with",
|
|
373
|
+
"properties.associatedWorkspaces[]")
|
|
374
|
+
|
|
375
|
+
for c in p.get("privateLinkServiceConnections") or []:
|
|
376
|
+
cp = c.get("properties", c) or {}
|
|
377
|
+
if cp.get("privateLinkServiceId"):
|
|
378
|
+
add(n.id, r.by_resource_id(cp["privateLinkServiceId"]), "private-link-to",
|
|
379
|
+
"properties.privateLinkServiceConnections[].privateLinkServiceId")
|
|
380
|
+
if (p.get("subnet") or {}).get("id"):
|
|
381
|
+
add(n.id, r.by_resource_id(p["subnet"]["id"]), "in-subnet", "properties.subnet.id")
|
|
382
|
+
|
|
383
|
+
for g in p.get("gatewayIPConfigurations") or []:
|
|
384
|
+
gs = ((g.get("properties") or {}).get("subnet") or {}).get("id")
|
|
385
|
+
if gs:
|
|
386
|
+
add(n.id, r.by_resource_id(gs), "in-subnet",
|
|
387
|
+
"properties.gatewayIPConfigurations[].subnet")
|
|
388
|
+
for pool in p.get("backendAddressPools") or []:
|
|
389
|
+
for ba in (pool.get("properties") or {}).get("backendAddresses") or []:
|
|
390
|
+
fqdn = _lower(ba.get("fqdn"))
|
|
391
|
+
if fqdn and fqdn in r.web_hosts:
|
|
392
|
+
add(n.id, r.web_hosts[fqdn], "routes-to",
|
|
393
|
+
f"backend pool fqdn {fqdn}")
|
|
394
|
+
|
|
395
|
+
if t == "microsoft.authorization/roleassignments":
|
|
396
|
+
src = r.by_principal.get(_lower(p.get("principalId")))
|
|
397
|
+
scope = r.by_resource_id(p.get("scope", ""))
|
|
398
|
+
role = p.get("roleDefinitionName")
|
|
399
|
+
if not role:
|
|
400
|
+
rid = (p.get("roleDefinitionId") or "").rsplit("/", 1)[-1].lower()
|
|
401
|
+
role = ROLE_NAMES.get(rid, "custom role")
|
|
402
|
+
add(src, scope, f"role: {role}", "RBAC role assignment (principal -> scope)")
|
|
403
|
+
|
|
404
|
+
if t == "microsoft.web/sites":
|
|
405
|
+
_webapp_config_edges(n, p, r, add)
|
|
406
|
+
elif t == "microsoft.app/containerapps":
|
|
407
|
+
_containerapp_edges(n, p, r, add)
|
|
408
|
+
elif t == "microsoft.app/managedenvironments":
|
|
409
|
+
_managedenv_edges(n, p, r, add)
|
|
410
|
+
elif t == "microsoft.containerservice/managedclusters":
|
|
411
|
+
_aks_config_edges(n, p, r, add)
|
|
412
|
+
|
|
413
|
+
# Generic ARM-reference pass. Any resolvable resource id sitting in a
|
|
414
|
+
# resource's properties is a real dependency, whatever the type - this is
|
|
415
|
+
# what lets cloudmap map resource types it has no hand-written rule for,
|
|
416
|
+
# deterministically, with the property path as proof. A semantic rule that
|
|
417
|
+
# already named a pair wins (kept as-is); the generic edge only fills gaps.
|
|
418
|
+
known = {(e.source, e.target) for e in edges}
|
|
419
|
+
generic = set()
|
|
420
|
+
for n in nodes.values():
|
|
421
|
+
if n.type == "microsoft.authorization/roleassignments":
|
|
422
|
+
continue # plumbing we derive edges FROM, not a map node
|
|
423
|
+
# Fabric (a VNet listing everything plugged in) and observers (a rule
|
|
424
|
+
# group or dashboard watching a resource) are not dependencies - skip them
|
|
425
|
+
# as generic-edge sources so they do not clutter or hub the map.
|
|
426
|
+
if n.type in _GENERIC_SOURCE_SKIP:
|
|
427
|
+
continue
|
|
428
|
+
for arm_id, path in _iter_arm_ids(n.raw.get("properties") or {}, "properties"):
|
|
429
|
+
tgt = r.by_resource_id(arm_id)
|
|
430
|
+
if "privateendpointconnections" in path.lower() or "privatelinkserviceconnections" in path.lower():
|
|
431
|
+
continue
|
|
432
|
+
if (not tgt or tgt == n.id or (n.id, tgt) in known or (tgt, n.id) in known
|
|
433
|
+
or nodes[tgt].type == "microsoft.authorization/roleassignments"):
|
|
434
|
+
continue
|
|
435
|
+
# two resources that name each other (a NIC and its VNet) would give a
|
|
436
|
+
# back-and-forth pair; keep the first direction seen, drop the mirror.
|
|
437
|
+
if (tgt, n.id) in generic:
|
|
438
|
+
continue
|
|
439
|
+
add(n.id, tgt, "references", path)
|
|
440
|
+
known.add((n.id, tgt))
|
|
441
|
+
generic.add((n.id, tgt))
|
|
442
|
+
|
|
443
|
+
return _dedupe(edges)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _config_values(p):
|
|
447
|
+
sc = p.get("siteConfig") or {}
|
|
448
|
+
out = []
|
|
449
|
+
for s in sc.get("appSettings") or []:
|
|
450
|
+
out.append(str(s.get("value", "")))
|
|
451
|
+
for c in (sc.get("connectionStrings") or p.get("connectionStrings") or []):
|
|
452
|
+
out.append(str(c.get("connectionString", c.get("value", ""))))
|
|
453
|
+
return out, sc
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _config_edges(n, values, r, add, label="app config"):
|
|
457
|
+
"""The rules that read a workload's configuration. Shared, because a web app's
|
|
458
|
+
appSettings and a container app's env vars are the same problem wearing
|
|
459
|
+
different field names: free text that happens to name other resources."""
|
|
460
|
+
blob = " ".join(values).lower()
|
|
461
|
+
|
|
462
|
+
# O(blob) extraction instead of O(hosts * blob)
|
|
463
|
+
import re
|
|
464
|
+
words = set(re.findall(r"[a-z0-9.-]+", blob))
|
|
465
|
+
|
|
466
|
+
for word in words:
|
|
467
|
+
if word in r.by_host:
|
|
468
|
+
add(n.id, r.by_host[word], domain_kind(word)[0], f"{label} references host {word}")
|
|
469
|
+
if word in r.by_ik:
|
|
470
|
+
add(n.id, r.by_ik[word], "sends-telemetry", f"{label} contains instrumentation key")
|
|
471
|
+
|
|
472
|
+
# Storage accounts are often in connection strings: accountname=xyz
|
|
473
|
+
for m in re.finditer(r"accountname=([a-z0-9-]+)", blob):
|
|
474
|
+
acct = m.group(1)
|
|
475
|
+
if acct in r.storage_by_name:
|
|
476
|
+
add(n.id, r.storage_by_name[acct], "connects-to", f"{label} references accountname={acct}")
|
|
477
|
+
|
|
478
|
+
for vname in _vault_refs(blob):
|
|
479
|
+
if vname in r.kv_by_name:
|
|
480
|
+
add(n.id, r.kv_by_name[vname], "reads-secret",
|
|
481
|
+
f"Key Vault reference to vault {vname}")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _webapp_config_edges(n, p, r, add):
|
|
485
|
+
values, sc = _config_values(p)
|
|
486
|
+
_config_edges(n, values, r, add)
|
|
487
|
+
|
|
488
|
+
fx = f"{sc.get('linuxFxVersion', '')} {sc.get('windowsFxVersion', '')}"
|
|
489
|
+
m = re.search(r"docker\|([a-z0-9.\-]+\.azurecr\.io)/", fx, re.I)
|
|
490
|
+
if m:
|
|
491
|
+
add(n.id, r.acr_by_loginserver.get(m.group(1).lower()), "pulls-image",
|
|
492
|
+
f"linuxFxVersion docker image {m.group(1).lower()}")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _containerapp_values(p):
|
|
496
|
+
"""A container app's free-text config: container env vars, plus the vault URLs
|
|
497
|
+
its secrets are pulled from."""
|
|
498
|
+
out = []
|
|
499
|
+
for c in (p.get("template") or {}).get("containers") or []:
|
|
500
|
+
for e in c.get("env") or []:
|
|
501
|
+
out.append(str(e.get("value", "")))
|
|
502
|
+
for s in (p.get("configuration") or {}).get("secrets") or []:
|
|
503
|
+
out.append(str(s.get("keyVaultUrl", "")))
|
|
504
|
+
return out
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _containerapp_edges(n, p, r, add):
|
|
508
|
+
"""Container Apps hold the same dependencies a web app holds, in different
|
|
509
|
+
places: an environment instead of a plan, `configuration.registries` instead
|
|
510
|
+
of a docker image string, `template.containers[].env` instead of appSettings.
|
|
511
|
+
|
|
512
|
+
Unlike a web app, none of this needs deep enrichment - Resource Graph returns
|
|
513
|
+
`properties.template` in full, so a plain scan already sees the config.
|
|
514
|
+
"""
|
|
515
|
+
env_id = p.get("environmentId") or p.get("managedEnvironmentId")
|
|
516
|
+
if env_id:
|
|
517
|
+
add(n.id, r.by_resource_id(env_id), "hosted-on", "properties.environmentId")
|
|
518
|
+
|
|
519
|
+
cfg = p.get("configuration") or {}
|
|
520
|
+
for reg in cfg.get("registries") or []:
|
|
521
|
+
server = _lower(reg.get("server"))
|
|
522
|
+
if server:
|
|
523
|
+
add(n.id, r.acr_by_loginserver.get(server), "pulls-image",
|
|
524
|
+
f"configuration.registries[].server {server}")
|
|
525
|
+
for sec in cfg.get("secrets") or []:
|
|
526
|
+
m = _SECRET_URI_HOST.search(str(sec.get("keyVaultUrl") or ""))
|
|
527
|
+
if m:
|
|
528
|
+
add(n.id, r.kv_by_name.get(m.group(1).lower()), "reads-secret",
|
|
529
|
+
f"configuration.secrets[].keyVaultUrl vault {m.group(1).lower()}")
|
|
530
|
+
|
|
531
|
+
for c in (p.get("template") or {}).get("containers") or []:
|
|
532
|
+
m = re.match(r"([a-z0-9.\-]+\.azurecr\.io)/", str(c.get("image") or "").lower())
|
|
533
|
+
if m:
|
|
534
|
+
add(n.id, r.acr_by_loginserver.get(m.group(1)), "pulls-image",
|
|
535
|
+
f"template.containers[].image {m.group(1)}")
|
|
536
|
+
|
|
537
|
+
_config_edges(n, _containerapp_values(p), r, add, label="container env")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _managedenv_edges(n, p, r, add):
|
|
541
|
+
subnet = (p.get("vnetConfiguration") or {}).get("infrastructureSubnetId")
|
|
542
|
+
if subnet:
|
|
543
|
+
add(n.id, r.by_resource_id(subnet), "vnet-integration",
|
|
544
|
+
"properties.vnetConfiguration.infrastructureSubnetId")
|
|
545
|
+
|
|
546
|
+
law = ((p.get("appLogsConfiguration") or {}).get("logAnalyticsConfiguration") or {})
|
|
547
|
+
cid = _lower(law.get("customerId"))
|
|
548
|
+
if cid:
|
|
549
|
+
add(n.id, r.law_by_customer_id.get(cid), "uses-workspace",
|
|
550
|
+
"appLogsConfiguration.logAnalyticsConfiguration.customerId")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _aks_config_edges(n, p, r, add):
|
|
554
|
+
"""Extract dependencies from an AKS cluster's Kubernetes manifests."""
|
|
555
|
+
k8s_text = n.raw.get("kubernetes_text") or ""
|
|
556
|
+
|
|
557
|
+
values = []
|
|
558
|
+
|
|
559
|
+
for line in k8s_text.splitlines():
|
|
560
|
+
line = line.strip()
|
|
561
|
+
if not line:
|
|
562
|
+
continue
|
|
563
|
+
if line.startswith("image:"):
|
|
564
|
+
image = line[6:]
|
|
565
|
+
m = re.match(r"([a-z0-9.\-]+\.azurecr\.io)/", image.lower())
|
|
566
|
+
if m:
|
|
567
|
+
add(n.id, r.acr_by_loginserver.get(m.group(1)), "pulls-image",
|
|
568
|
+
f"Pod container image {m.group(1)}")
|
|
569
|
+
elif line.startswith("env:"):
|
|
570
|
+
values.append(line[4:])
|
|
571
|
+
elif line.startswith("cm:"):
|
|
572
|
+
values.append(line[3:])
|
|
573
|
+
elif line.startswith("sec_decoded:"):
|
|
574
|
+
values.append(line[12:])
|
|
575
|
+
|
|
576
|
+
_config_edges(n, values, r, add, label="AKS workload config")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def seed_external_dependencies(seed_node, resolver):
|
|
580
|
+
"""Anything the seed references that did NOT resolve to a scanned resource is
|
|
581
|
+
surfaced as an explicit external / unverified node - never silently dropped.
|
|
582
|
+
Values are already secret-resolved by the ingest layer if the user opted in.
|
|
583
|
+
"""
|
|
584
|
+
p = seed_node.raw.get("properties") or {}
|
|
585
|
+
if seed_node.type == "microsoft.app/containerapps":
|
|
586
|
+
values = _containerapp_values(p)
|
|
587
|
+
self_host = _lower(((p.get("configuration") or {}).get("ingress") or {}).get("fqdn")) or ""
|
|
588
|
+
else:
|
|
589
|
+
values, _sc = _config_values(p)
|
|
590
|
+
self_host = (p.get("defaultHostName") or f"{seed_node.name}.azurewebsites.net").lower()
|
|
591
|
+
|
|
592
|
+
ext_nodes, edges, seen = {}, [], set()
|
|
593
|
+
|
|
594
|
+
def add_external(host, kind, cat, why):
|
|
595
|
+
nid = f"external://{host}"
|
|
596
|
+
if nid not in ext_nodes:
|
|
597
|
+
ext_nodes[nid] = Node(id=nid, name=host, type=f"external/{cat}",
|
|
598
|
+
external=True, note=why)
|
|
599
|
+
edges.append(Edge(seed_node.id, nid, kind))
|
|
600
|
+
|
|
601
|
+
for val in values:
|
|
602
|
+
low = val.lower()
|
|
603
|
+
hosts = set(_HOST_RE.findall(low)) | set(_ENTRA_RE.findall(low))
|
|
604
|
+
for host in hosts:
|
|
605
|
+
if host in seen or host == self_host:
|
|
606
|
+
continue
|
|
607
|
+
seen.add(host)
|
|
608
|
+
if resolver.host_lookup(host):
|
|
609
|
+
continue # resolved -> edge already emitted
|
|
610
|
+
kind, cat = domain_kind(host)
|
|
611
|
+
add_external(host, kind, cat, "referenced in app config; not found in scanned subscriptions")
|
|
612
|
+
for vname in _vault_refs(low):
|
|
613
|
+
if vname not in resolver.kv_by_name and vname not in seen:
|
|
614
|
+
seen.add(vname)
|
|
615
|
+
add_external(f"{vname}.vault.azure.net", "reads-secret", "Key Vault",
|
|
616
|
+
"Key Vault reference; vault not found in scanned subscriptions")
|
|
617
|
+
for acct in _STORAGE_ACCOUNT.findall(low):
|
|
618
|
+
acct = acct.lower()
|
|
619
|
+
if acct not in resolver.storage_by_name and acct not in seen:
|
|
620
|
+
seen.add(acct)
|
|
621
|
+
add_external(f"{acct}.blob.core.windows.net", "connects-to", "Storage",
|
|
622
|
+
"storage account referenced in app config; not found in scanned subscriptions")
|
|
623
|
+
|
|
624
|
+
return list(ext_nodes.values()), edges
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _dedupe(edges):
|
|
628
|
+
merged = {}
|
|
629
|
+
for e in edges:
|
|
630
|
+
key = (e.source, e.target)
|
|
631
|
+
if key in merged:
|
|
632
|
+
m = merged[key]
|
|
633
|
+
for k in e.kind.split("; "):
|
|
634
|
+
if k not in m.kind.split("; "):
|
|
635
|
+
m.kind += "; " + k
|
|
636
|
+
if e.origin == "extracted": # a verified edge on this pair wins
|
|
637
|
+
m.origin = "extracted"
|
|
638
|
+
if e.evidence and e.evidence not in m.evidence:
|
|
639
|
+
m.evidence = f"{m.evidence}; {e.evidence}".strip("; ")
|
|
640
|
+
else:
|
|
641
|
+
merged[key] = Edge(source=e.source, target=e.target, kind=e.kind,
|
|
642
|
+
origin=e.origin, evidence=e.evidence)
|
|
643
|
+
return list(merged.values())
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def merge_model_edges(edges, model_edges):
|
|
647
|
+
"""Fold model-proposed edges into the deterministic set with the one rule that
|
|
648
|
+
keeps the graph trustworthy: a model edge may only ADD a new (source, target)
|
|
649
|
+
pair. It never modifies or relabels an edge a deterministic rule already
|
|
650
|
+
produced - deterministic always wins."""
|
|
651
|
+
existing = {(e.source, e.target) for e in edges}
|
|
652
|
+
additions = [e for e in model_edges if (e.source, e.target) not in existing]
|
|
653
|
+
return _dedupe(edges + additions)
|