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.
@@ -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)