cloudmap 1.2.0__tar.gz → 1.3.0__tar.gz

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.
Files changed (81) hide show
  1. {cloudmap-1.2.0 → cloudmap-1.3.0}/PKG-INFO +33 -6
  2. {cloudmap-1.2.0 → cloudmap-1.3.0}/README.md +32 -5
  3. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/__init__.py +1 -1
  4. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/cli.py +129 -41
  5. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/extract/extractors.py +269 -24
  6. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/ingest/azure.py +29 -16
  7. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/interactive.py +43 -30
  8. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/render/html.py +8 -0
  9. cloudmap-1.3.0/tests/data/azure_resource_types.txt +4692 -0
  10. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_arg_rows.py +78 -0
  11. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_cli_exports.py +72 -0
  12. cloudmap-1.3.0/tests/test_common_types.py +357 -0
  13. cloudmap-1.3.0/tests/test_containment_vs_association.py +233 -0
  14. cloudmap-1.3.0/tests/test_enrich.py +197 -0
  15. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_interactive_wizard.py +20 -15
  16. cloudmap-1.3.0/tests/test_type_agnostic.py +262 -0
  17. cloudmap-1.2.0/tests/test_enrich.py +0 -95
  18. {cloudmap-1.2.0 → cloudmap-1.3.0}/.github/workflows/ci.yml +0 -0
  19. {cloudmap-1.2.0 → cloudmap-1.3.0}/.github/workflows/publish.yml +0 -0
  20. {cloudmap-1.2.0 → cloudmap-1.3.0}/.gitignore +0 -0
  21. {cloudmap-1.2.0 → cloudmap-1.3.0}/ARCHITECTURE.md +0 -0
  22. {cloudmap-1.2.0 → cloudmap-1.3.0}/CONTRIBUTING.md +0 -0
  23. {cloudmap-1.2.0 → cloudmap-1.3.0}/FORMAT.md +0 -0
  24. {cloudmap-1.2.0 → cloudmap-1.3.0}/LICENSE +0 -0
  25. {cloudmap-1.2.0 → cloudmap-1.3.0}/PLAN.md +0 -0
  26. {cloudmap-1.2.0 → cloudmap-1.3.0}/SECURITY.md +0 -0
  27. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/__main__.py +0 -0
  28. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/adapters/__init__.py +0 -0
  29. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/ask/__init__.py +0 -0
  30. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/ask/intent.py +0 -0
  31. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/ask/narration.py +0 -0
  32. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/ask/queries.py +0 -0
  33. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/extract/__init__.py +0 -0
  34. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/extract/llm.py +0 -0
  35. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/graph.py +0 -0
  36. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/ingest/__init__.py +0 -0
  37. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/ingest/fixture.py +0 -0
  38. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/local_model.py +0 -0
  39. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/model.py +0 -0
  40. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/render/__init__.py +0 -0
  41. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/render/azure_icons.py +0 -0
  42. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/render/csv_export.py +0 -0
  43. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/render/drawio.py +0 -0
  44. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/render/json_out.py +0 -0
  45. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/render/mermaid.py +0 -0
  46. {cloudmap-1.2.0 → cloudmap-1.3.0}/cloudmap/scrub.py +0 -0
  47. {cloudmap-1.2.0 → cloudmap-1.3.0}/docs/social-preview.html +0 -0
  48. {cloudmap-1.2.0 → cloudmap-1.3.0}/docs/social-preview.png +0 -0
  49. {cloudmap-1.2.0 → cloudmap-1.3.0}/estate-viewer.png +0 -0
  50. {cloudmap-1.2.0 → cloudmap-1.3.0}/fixtures/acme_orders.json +0 -0
  51. {cloudmap-1.2.0 → cloudmap-1.3.0}/fixtures/contoso.json +0 -0
  52. {cloudmap-1.2.0 → cloudmap-1.3.0}/fixtures/estate.json +0 -0
  53. {cloudmap-1.2.0 → cloudmap-1.3.0}/pyproject.toml +0 -0
  54. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/complex_mock_demo/01_input_complex_random.json +0 -0
  55. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/complex_mock_demo/04_scrubbed_output.json +0 -0
  56. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/complex_mock_demo/06_trace_output.json +0 -0
  57. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/complex_mock_demo/07_trace_output.html +0 -0
  58. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/complex_mock_demo/08_trace_output.csv +0 -0
  59. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/complex_mock_demo/09_trace_output.drawio +0 -0
  60. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/complex_mock_demo/10_input_enterprise_architecture.json +0 -0
  61. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/complex_mock_demo/12_enterprise_trace.html +0 -0
  62. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/complex_mock_demo/13_enterprise_trace.json +0 -0
  63. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/complex_mock_demo/14_enterprise_scrubbed.json +0 -0
  64. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/complex_mock_demo/README.md +0 -0
  65. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_adapters.py +0 -0
  66. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_ask.py +0 -0
  67. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_azure.py +0 -0
  68. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_containerapps.py +0 -0
  69. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_drawio_xml.py +0 -0
  70. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_estate.py +0 -0
  71. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_fixtures_safe.py +0 -0
  72. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_golden_orders.py +0 -0
  73. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_graph.py +0 -0
  74. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_html.py +0 -0
  75. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_ingest_paging.py +0 -0
  76. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_llm.py +0 -0
  77. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_local_model.py +0 -0
  78. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_render_text.py +0 -0
  79. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_scrub.py +0 -0
  80. {cloudmap-1.2.0 → cloudmap-1.3.0}/tests/test_trust.py +0 -0
  81. {cloudmap-1.2.0 → cloudmap-1.3.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cloudmap
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Trace the blast radius of an Azure resource: one name in, a verified dependency graph out.
5
5
  Project-URL: Homepage, https://github.com/KatsaounisThanasis/cloudmap
6
6
  Project-URL: Repository, https://github.com/KatsaounisThanasis/cloudmap
@@ -202,10 +202,26 @@ identity RBAC on it, with the role assignments as proof.
202
202
 
203
203
  ## What it maps
204
204
 
205
- App Service / Functions, Container Apps (+ environments), AKS, App Gateway, API
206
- Management, Key Vault, Storage, SQL / PostgreSQL / MySQL / Cosmos, Redis, Service
207
- Bus, Event Hub, Cognitive Search, Azure OpenAI, Container Registry, Log Analytics,
208
- App Insights, VNets, Private Endpoints and managed identities.
205
+ **Any Azure resource type can be a seed.** The scan is not filtered by type, and
206
+ resources are mapped at two levels:
207
+
208
+ - **Typed rules** for the services where the relationship has a specific meaning:
209
+ App Service / Functions, Container Apps (+ environments), AKS, App Gateway, API
210
+ Management, Key Vault, Storage, SQL / PostgreSQL / MySQL / Cosmos, Redis, Service
211
+ Bus, Event Hub, Cognitive Search, Azure OpenAI, Container Registry, ML workspaces,
212
+ Log Analytics, App Insights, VNets, Private Endpoints and managed identities.
213
+ These produce edges like `hosted-on`, `reads-secret`, `pulls-image`, `routes-to`.
214
+ - **A generic ARM-reference pass** for everything else: any resolvable resource id
215
+ found in a resource's properties becomes a `references` edge, with the property
216
+ path as proof. So a type cloudmap has never heard of is still mapped, still
217
+ deterministically, still with evidence.
218
+
219
+ RBAC edges (`role: Key Vault Secrets User`) are extracted tenant-wide, so
220
+ "who has access to this" works for any resource that can be a role scope.
221
+
222
+ Depth is honest about itself: apps and clusters have rich outbound edges because
223
+ their config names other resources. Infrastructure resources are usually leaves
224
+ going outward, and their value is the reverse view (`--direction up`).
209
225
 
210
226
  ## How it works
211
227
 
@@ -276,6 +292,14 @@ is the deliberate switch, and cloudmap reads whatever subscription `az` is point
276
292
  at. The read is read-only, but it is a read of live infrastructure, so point it on
277
293
  purpose. **Do not point this at data you are not authorized to read.**
278
294
 
295
+ One asterisk on "read-only": AKS manifests are read through `az aks command
296
+ invoke`, which Azure implements by starting a short-lived pod in the cluster to
297
+ run the (read-only) `kubectl get` commands. Nothing of yours is modified, but an
298
+ audit of the cluster's control plane will see that ephemeral pod.
299
+
300
+ Tenant-wide enrichment runs its `az` reads concurrently; `CLOUDMAP_ENRICH_WORKERS`
301
+ (default 12) tunes how many at once.
302
+
279
303
  ```
280
304
  cloudmap trace my-app --live --allow-live
281
305
  ```
@@ -298,7 +322,10 @@ everything".
298
322
  subscription in the tenant)
299
323
  --enrich MODE which web apps to deep-enrich for the dependencies that only
300
324
  exist in app config. auto (default) = the seed alone when the
301
- seed is a web app, every app in scope when it is not;
325
+ seed is a workload; every app in scope when the seed is a data
326
+ service whose dependents hide in app config (Key Vault,
327
+ storage, SQL, Redis, ...); the seed alone for compute and
328
+ network resources, whose relationships ARM already returns;
302
329
  all | seed | none
303
330
  --resolve-secrets read KV secret values in-memory to see through KV-backed
304
331
  connection strings (never printed or written)
@@ -168,10 +168,26 @@ identity RBAC on it, with the role assignments as proof.
168
168
 
169
169
  ## What it maps
170
170
 
171
- App Service / Functions, Container Apps (+ environments), AKS, App Gateway, API
172
- Management, Key Vault, Storage, SQL / PostgreSQL / MySQL / Cosmos, Redis, Service
173
- Bus, Event Hub, Cognitive Search, Azure OpenAI, Container Registry, Log Analytics,
174
- App Insights, VNets, Private Endpoints and managed identities.
171
+ **Any Azure resource type can be a seed.** The scan is not filtered by type, and
172
+ resources are mapped at two levels:
173
+
174
+ - **Typed rules** for the services where the relationship has a specific meaning:
175
+ App Service / Functions, Container Apps (+ environments), AKS, App Gateway, API
176
+ Management, Key Vault, Storage, SQL / PostgreSQL / MySQL / Cosmos, Redis, Service
177
+ Bus, Event Hub, Cognitive Search, Azure OpenAI, Container Registry, ML workspaces,
178
+ Log Analytics, App Insights, VNets, Private Endpoints and managed identities.
179
+ These produce edges like `hosted-on`, `reads-secret`, `pulls-image`, `routes-to`.
180
+ - **A generic ARM-reference pass** for everything else: any resolvable resource id
181
+ found in a resource's properties becomes a `references` edge, with the property
182
+ path as proof. So a type cloudmap has never heard of is still mapped, still
183
+ deterministically, still with evidence.
184
+
185
+ RBAC edges (`role: Key Vault Secrets User`) are extracted tenant-wide, so
186
+ "who has access to this" works for any resource that can be a role scope.
187
+
188
+ Depth is honest about itself: apps and clusters have rich outbound edges because
189
+ their config names other resources. Infrastructure resources are usually leaves
190
+ going outward, and their value is the reverse view (`--direction up`).
175
191
 
176
192
  ## How it works
177
193
 
@@ -242,6 +258,14 @@ is the deliberate switch, and cloudmap reads whatever subscription `az` is point
242
258
  at. The read is read-only, but it is a read of live infrastructure, so point it on
243
259
  purpose. **Do not point this at data you are not authorized to read.**
244
260
 
261
+ One asterisk on "read-only": AKS manifests are read through `az aks command
262
+ invoke`, which Azure implements by starting a short-lived pod in the cluster to
263
+ run the (read-only) `kubectl get` commands. Nothing of yours is modified, but an
264
+ audit of the cluster's control plane will see that ephemeral pod.
265
+
266
+ Tenant-wide enrichment runs its `az` reads concurrently; `CLOUDMAP_ENRICH_WORKERS`
267
+ (default 12) tunes how many at once.
268
+
245
269
  ```
246
270
  cloudmap trace my-app --live --allow-live
247
271
  ```
@@ -264,7 +288,10 @@ everything".
264
288
  subscription in the tenant)
265
289
  --enrich MODE which web apps to deep-enrich for the dependencies that only
266
290
  exist in app config. auto (default) = the seed alone when the
267
- seed is a web app, every app in scope when it is not;
291
+ seed is a workload; every app in scope when the seed is a data
292
+ service whose dependents hide in app config (Key Vault,
293
+ storage, SQL, Redis, ...); the seed alone for compute and
294
+ network resources, whose relationships ARM already returns;
268
295
  all | seed | none
269
296
  --resolve-secrets read KV secret values in-memory to see through KV-backed
270
297
  connection strings (never printed or written)
@@ -1,3 +1,3 @@
1
1
  """cloudmap - trace an Azure resource's full dependency graph and export it."""
2
2
 
3
- __version__ = "1.2.0"
3
+ __version__ = "1.3.0"
@@ -51,9 +51,11 @@ def main(argv=None):
51
51
  t.add_argument("--enrich", choices=["auto", "seed", "all", "none"], default="auto",
52
52
  help="live: which web apps to deep-enrich for the dependencies that live "
53
53
  "in app config (Key Vault refs, connection strings, RBAC). "
54
- "auto = the seed alone when the seed is a web app, every app in scope "
55
- "when it is not (only other apps' config can reveal what depends on a "
56
- "shared resource); all = every app in scope; none = ARM topology only")
54
+ "auto = the seed alone when the seed is a workload; every app in scope "
55
+ "when the seed is a data service whose dependents hide in app config "
56
+ "(Key Vault, storage, SQL, Redis, ...); the seed alone for compute and "
57
+ "network resources, whose relationships ARM already returns. "
58
+ "all = every app in scope; none = ARM topology only")
57
59
  t.add_argument("--level", choices=["high", "detail"], default="high",
58
60
  help="high = architecture view grouped by resource type (default); "
59
61
  "detail = every instance with its real name")
@@ -226,7 +228,9 @@ def _export_outputs(sub, seed, args, meta):
226
228
  with open(args.csv_out, "w", encoding="utf-8") as f:
227
229
  f.write(to_csv(sub, seed, meta=meta))
228
230
 
229
- _print_summary(sub, seed, out, truncated=meta.get("truncated", False), blind_spots=meta.get("blind_spots", []))
231
+ _print_summary(sub, seed, out, truncated=meta.get("truncated", False),
232
+ blind_spots=meta.get("blind_spots", []),
233
+ single_sub=bool(getattr(args, "single_sub", False)))
230
234
 
231
235
 
232
236
  _WEBAPP = "microsoft.web/sites"
@@ -237,6 +241,35 @@ _AKS = "microsoft.containerservice/managedclusters"
237
241
  # template, so there is nothing extra to fetch.
238
242
  _CONFIG_WORKLOADS = (_WEBAPP, "microsoft.app/containerapps", _AKS)
239
243
 
244
+ # Seeds whose DEPENDENTS are typically named only inside application config, so
245
+ # reading every app in scope is the only way to find them. A tenant-wide pass
246
+ # costs one `az` round-trip per app, so it is spent here and nowhere else.
247
+ #
248
+ # Compute and network resources are deliberately absent: a VM's or a VNet's
249
+ # dependents are already in Resource Graph (a NIC's subnet, an app's
250
+ # virtualNetworkSubnetId), so enriching hundreds of apps to trace one VM buys
251
+ # nothing. Found the hard way - tracing a VM used to enrich 289 web apps and run
252
+ # for minutes before printing edges that were all in the ARM data already.
253
+ _CONFIG_REFERENCED_TYPES = frozenset({
254
+ "microsoft.keyvault/vaults",
255
+ "microsoft.storage/storageaccounts",
256
+ "microsoft.sql/servers",
257
+ "microsoft.sql/servers/databases",
258
+ "microsoft.dbforpostgresql/flexibleservers",
259
+ "microsoft.dbformysql/flexibleservers",
260
+ "microsoft.documentdb/databaseaccounts",
261
+ "microsoft.cache/redis",
262
+ "microsoft.servicebus/namespaces",
263
+ "microsoft.eventhub/namespaces",
264
+ "microsoft.search/searchservices",
265
+ "microsoft.cognitiveservices/accounts",
266
+ "microsoft.containerregistry/registries",
267
+ "microsoft.insights/components",
268
+ "microsoft.operationalinsights/workspaces",
269
+ "microsoft.web/sites",
270
+ "microsoft.app/containerapps",
271
+ })
272
+
240
273
 
241
274
  def _enrichment_targets(graph, seed, mode, direction):
242
275
  """Which workloads to deep-enrich, and which stay a blind spot.
@@ -244,22 +277,25 @@ def _enrichment_targets(graph, seed, mode, direction):
244
277
  An app's config-level dependencies exist nowhere until that app is enriched,
245
278
  but enriching a whole tenant on every trace costs an `az` round-trip per app.
246
279
  So `auto` spends the calls where the answer actually needs them: a web-app
247
- seed's own config already yields its downward view, whereas a shared-resource
248
- seed (Key Vault, database, plan) can only learn its dependents from the
249
- config of the apps pointing at it.
280
+ seed's own config already yields its downward view, whereas a shared data
281
+ service (Key Vault, database, registry) can only learn its dependents from
282
+ the config of the apps pointing at it.
250
283
  """
251
284
  workloads = [n for n in graph.nodes.values() if n.type in (_WEBAPP, _AKS)]
252
285
  seed_only = [n for n in workloads if n.id == seed]
253
-
286
+ seed_type = graph.nodes[seed].type if seed in graph.nodes else ""
287
+
254
288
  if mode == "none":
255
289
  chosen = []
256
290
  elif mode == "all":
257
291
  chosen = workloads
258
- elif mode == "seed" or seed_only and direction != "up":
292
+ elif mode == "seed" or (seed_only and direction != "up"):
259
293
  chosen = seed_only
294
+ elif seed_type in _CONFIG_REFERENCED_TYPES:
295
+ chosen = workloads # its dependents hide in app config
260
296
  else:
261
- chosen = workloads
262
-
297
+ chosen = seed_only # compute/network: ARM already has it
298
+
263
299
  picked = {n.id for n in chosen}
264
300
  return chosen, [n for n in workloads if n.id not in picked]
265
301
 
@@ -329,12 +365,24 @@ def _enrich_live(args, graph, seed, resources):
329
365
  # An un-enriched app is a known class of missing edge - say so rather than let
330
366
  # an empty upward view read as "nothing depends on this".
331
367
  if skipped and args.direction != "down":
332
- blind_spots.append(
333
- f"{len(skipped)} workload(s) in scope were not deep-enriched, so anything that "
334
- f"depends on this resource through config (Key Vault references, connection "
335
- f"strings, hostnames) cannot appear as an inbound edge. "
336
- f"Re-run with --enrich all to close this gap."
337
- )
368
+ # Only claim this matters when it actually does. A Key Vault's dependents
369
+ # really do hide in app config; a VNet's are already in the ARM data, so
370
+ # telling its user to enrich 289 apps would send them off for minutes to
371
+ # learn nothing.
372
+ if graph.nodes[seed].type in _CONFIG_REFERENCED_TYPES:
373
+ blind_spots.append(
374
+ f"{len(skipped)} workload(s) in scope were not deep-enriched, so anything that "
375
+ f"depends on this resource through config (Key Vault references, connection "
376
+ f"strings, hostnames) cannot appear as an inbound edge. "
377
+ f"Re-run with --enrich all to close this gap."
378
+ )
379
+ else:
380
+ blind_spots.append(
381
+ f"{len(skipped)} workload(s) in scope were not deep-enriched. For this "
382
+ f"resource type that is usually fine - its relationships are in the ARM "
383
+ f"data - but an application naming it in free-text config would be missed. "
384
+ f"Use --enrich all if you need that certainty."
385
+ )
338
386
  return graph, read_gaps, blind_spots
339
387
 
340
388
 
@@ -375,6 +423,19 @@ def _cmd_capture(args):
375
423
  from .scrub import scrub
376
424
  resources, stats = scrub(resources)
377
425
 
426
+ # Kubernetes secret material is read in-memory so the extractors can spot a
427
+ # connection string inside a secret, and it is dropped here unconditionally -
428
+ # including under --no-scrub. A capture is a file people commit; the one
429
+ # field that carries decoded secrets has no business in it, and the scrub
430
+ # pass deleting it too is belt and braces rather than the only guard.
431
+ dropped = 0
432
+ for r in resources:
433
+ if isinstance(r, dict) and r.pop("kubernetes_text", None) is not None:
434
+ dropped += 1
435
+ if dropped:
436
+ print(f"Dropped Kubernetes manifest text from {dropped} cluster(s): it can carry "
437
+ f"decoded secret values and is never written to an export.", file=sys.stderr)
438
+
378
439
  _write_export(args.out, resources, scrubbed=not args.no_scrub,
379
440
  meta={"truncated": truncated, "enriched": args.enrich == "all"})
380
441
  print(f"Captured {len(resources)} rows -> {args.out}")
@@ -483,7 +544,7 @@ def _ensure_parent(path):
483
544
  os.makedirs(parent, exist_ok=True)
484
545
 
485
546
 
486
- def _print_summary(graph, seed, out, truncated=False, blind_spots=()):
547
+ def _print_summary(graph, seed, out, truncated=False, blind_spots=(), single_sub=False):
487
548
  try:
488
549
  from rich.console import Console
489
550
  from rich.panel import Panel
@@ -510,35 +571,62 @@ def _print_summary(graph, seed, out, truncated=False, blind_spots=()):
510
571
  if "vault" in t: return "🔐"
511
572
  return "📦"
512
573
 
513
- def _build_tree(node_id, seen):
514
- node = graph.nodes[node_id]
574
+ def _label(node):
515
575
  color = "cyan" if not node.external else "dim white"
516
576
  txt = f"[{color}]{_get_icon(node.type)} {node.name}[/{color}]"
517
- if node.external: txt += " [italic dim](external)[/italic dim]"
518
-
519
- tree = Tree(txt)
577
+ return txt + (" [italic dim](external)[/italic dim]" if node.external else "")
578
+
579
+ def _kind_colour(kind):
580
+ if "secret" in kind: return "yellow"
581
+ if "connects" in kind: return "green"
582
+ if "auth" in kind: return "magenta"
583
+ if "observes" in kind: return "dim white"
584
+ return "blue"
585
+
586
+ def _build_tree(node_id, seen, upward):
587
+ """Walk one direction only. Upward reads "what depends on me", so its
588
+ arrows are drawn pointing back at the parent - printing them like
589
+ downstream edges would state the dependency backwards."""
590
+ tree = Tree(_label(graph.nodes[node_id]))
520
591
  seen.add(node_id)
521
-
522
592
  for e in graph.edges:
523
- if e.source == node_id:
524
- kind_color = "blue"
525
- if "secret" in e.kind: kind_color = "yellow"
526
- elif "connects" in e.kind: kind_color = "green"
527
- elif "auth" in e.kind: kind_color = "magenta"
528
-
529
- lbl = f"[{kind_color}]--{e.kind}-->[/{kind_color}]"
530
-
531
- if e.target in seen:
532
- tgt = graph.nodes[e.target]
533
- tree.add(f"{lbl} [dim]{tgt.name} (cycle)[/dim]")
534
- else:
535
- branch = _build_tree(e.target, set(seen))
536
- branch.label = f"{lbl} " + str(branch.label)
537
- tree.add(branch)
593
+ nxt = e.source if upward else e.target
594
+ if (e.target if upward else e.source) != node_id:
595
+ continue
596
+ lbl = (f"[{_kind_colour(e.kind)}]<--{e.kind}--[/{_kind_colour(e.kind)}]" if upward
597
+ else f"[{_kind_colour(e.kind)}]--{e.kind}-->[/{_kind_colour(e.kind)}]")
598
+ if nxt in seen:
599
+ tree.add(f"{lbl} [dim]{graph.nodes[nxt].name} (cycle)[/dim]")
600
+ else:
601
+ branch = _build_tree(nxt, set(seen), upward)
602
+ branch.label = f"{lbl} " + str(branch.label)
603
+ tree.add(branch)
538
604
  return tree
539
605
 
540
- tree = _build_tree(seed, set())
541
- console.print(Panel(tree, title="Dependency Graph", border_style="blue"))
606
+ # Both directions get their own panel. Tracing shared infrastructure (a
607
+ # VNet, a Key Vault, a plan) puts everything in the upward half, so a
608
+ # single downward tree used to print two branches under a headline that
609
+ # counted seven resources.
610
+ down = [e for e in graph.edges if e.source == seed]
611
+ up = [e for e in graph.edges if e.target == seed]
612
+ if not graph.edges:
613
+ # A lonely one-node map is a real answer, but an unexplained one is
614
+ # indistinguishable from a broken tool. Say what could hide it.
615
+ console.print(
616
+ "[bold yellow]No dependencies found.[/bold yellow] Nothing in the scanned "
617
+ "scope references this resource and it references nothing scanned. Worth "
618
+ "checking: whether its dependents live in another subscription (this scan "
619
+ "was scoped), and whether --enrich all would reveal a config-only reference."
620
+ if single_sub else
621
+ "[bold yellow]No dependencies found.[/bold yellow] Nothing in the scanned "
622
+ "scope references this resource and it references nothing scanned."
623
+ )
624
+ if down or not up:
625
+ console.print(Panel(_build_tree(seed, set(), upward=False),
626
+ title=f"Depends on ({len(down)})", border_style="blue"))
627
+ if up:
628
+ console.print(Panel(_build_tree(seed, set(), upward=True),
629
+ title=f"What depends on it ({len(up)})", border_style="magenta"))
542
630
  console.print(f"🔗 [bold]draw.io:[/bold] {out}\n")
543
631
  else:
544
632
  print(f"Seed: {n.name} ({n.type})")