Metadata-Version: 2.5
Name: cloudmap
Version: 1.3.3
Summary: Trace the blast radius of an Azure resource: one name in, a verified dependency graph out.
Project-URL: Homepage, https://github.com/KatsaounisThanasis/cloudmap
Project-URL: Repository, https://github.com/KatsaounisThanasis/cloudmap
Project-URL: Issues, https://github.com/KatsaounisThanasis/cloudmap/issues
Project-URL: Changelog, https://github.com/KatsaounisThanasis/cloudmap/releases
Author: Thanos Katsaounis
License: MIT
License-File: LICENSE
Keywords: azure,azure-resource-graph,blast-radius,dependency-graph,devops,drawio,incident-response,mermaid,sre
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Monitoring
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.9
Requires-Dist: questionary>=2.0.0
Requires-Dist: rich>=13.0.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Description-Content-Type: text/markdown

<div align="center">

<img src="docs/social-preview.png" alt="cloudmap — one Azure resource name in, its whole blast radius out" width="820">

<p>
  <a href="https://github.com/KatsaounisThanasis/cloudmap/actions/workflows/ci.yml"><img src="https://github.com/KatsaounisThanasis/cloudmap/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
  <a href="https://pypi.org/project/cloudmap/"><img src="https://img.shields.io/pypi/v/cloudmap?color=1f7a8c" alt="PyPI"></a>
  <a href="https://pypi.org/project/cloudmap/"><img src="https://img.shields.io/pypi/pyversions/cloudmap?color=1f7a8c" alt="Python versions"></a>
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></a>
</p>

<p>
  <a href="#install"><b>Install</b></a> ·
  <a href="#60-second-demo"><b>Demo</b></a> ·
  <a href="#interactive-wizard"><b>Wizard</b></a> ·
  <a href="#why"><b>Why</b></a> ·
  <a href="#what-you-will-see---and-what-you-wont"><b>What you'll see</b></a> ·
  <a href="#how-it-works"><b>How it works</b></a> ·
  <a href="#ask-a-map-questions"><b>Ask</b></a>
</p>

</div>

---

Azure Resource Graph has no "dependencies" table. The relationships that matter
- what an App Service is hosted on, which Key Vault it reads, which subnet it
integrates with, which managed identity has which role where, what a Private
Endpoint fronts, which App Gateway routes to it - are buried inside each
resource's `properties`. cloudmap reads them out, correlates them into one
graph, and draws it.

## Install

```
pip install cloudmap
```

Python 3.9+. Two runtime dependencies (`rich` and `questionary`, both for the
terminal UI). Live mode additionally needs the [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli)
on your PATH. The optional AI passes need a local model server - [ollama](https://ollama.com)
works out of the box, and any OpenAI-compatible server (LM Studio, llama.cpp,
vLLM, LocalAI) works via two env vars:

```
export CLOUDMAP_LLM_URL=http://localhost:1234/v1/chat/completions   # your server
export CLOUDMAP_LLM_MODEL=<model name>                              # default: qwen2.5-coder:3b
```

<details>
<summary>Working on it instead</summary>

```
git clone https://github.com/KatsaounisThanasis/cloudmap && cd cloudmap
pip install -e ".[dev]" && pytest
```

</details>

## 60-second demo

No Azure account needed - a synthetic estate ships inside the package:

```
cloudmap trace contoso-web --from demo -o contoso-web.drawio
```

```text
Blast radius: 9 resources (0 external), 9 dependencies
╭─────────────────────────────── Depends on (6) ───────────────────────────────╮
│ 🌐 contoso-web                                                               │
│ ├── --hosted-on--> 📦 contoso-plan                                           │
│ ├── --vnet-integration--> 📦 contoso-vnet                                    │
│ ├── --sends-telemetry--> 📦 contoso-appi                                     │
│ │   └── --uses-workspace--> 📦 contoso-law                                   │
│ ├── --connects-to--> 🗄️ contoso-sql                                          │
│ ├── --connects-to--> 📦 contosostg                                           │
│ └── --reads-secret; role: Key Vault Secrets User--> 🔐 contoso-kv            │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────── What depends on it (1) ───────────────────────────╮
│ 🌐 contoso-web                                                               │
│ └── <--routes-to-- 📦 contoso-agw                                            │
╰──────────────────────────────────────────────────────────────────────────────╯
🔗 draw.io: contoso-web.drawio
```

The two panels are the two halves of a blast radius: what the seed needs, and
what needs the seed. Arrows in the second panel are drawn backwards (`<--`)
because that is the direction the dependency actually runs.

That is the default **high-level** view - one box per resource type. Here every
type happens to have a single instance, so each box keeps its real name; where a
type has several, the box becomes `Web App ×4` and lists its members. Add
`--level detail` to always get every instance by name. On a larger estate the
map goes several layers deep:

<details>
<summary>A deeper map</summary>

Against the enterprise fixture in the repo
(`tests/complex_mock_demo/10_input_enterprise_architecture.json`):

```text
Blast radius: 16 resources (0 external), 15 dependencies
╭─────────────────────────────── Depends on (2) ───────────────────────────────╮
│ 🌐 app-spa-frontend                                                          │
│ ├── --calls--> 🌐 app-auth-service                                           │
│ │   ├── --connects-to--> 🗄️ cosmos-auth                                      │
│ │   └── --reads-secret--> 🔐 kv-core-prod                                    │
│ └── --calls--> 🌐 app-api-gateway                                            │
│     ├── --connects-to--> 🗄️ redis-gateway                                    │
│     ├── --calls--> 📦 capp-payment-service                                   │
│     │   ├── --reads-secret--> 🔐 kv-payments-prod                            │
│     │   └── --connects-to--> 📦 eh-transactions                              │
│     ├── --calls--> 🌐 app-inventory-api                                      │
│     │   ├── --connects-to--> 🗄️ pg-inventory-prod                            │
│     │   └── --connects-to--> 📦 stinventoryprod                              │
│     └── --calls--> 🌐 app-orders-api                                         │
│         ├── --connects-to--> 📦 sb-enterprise                                │
│         ├── --connects-to--> 🗄️ redis-orders                                 │
│         └── --connects-to--> 🗄️ sql-orders-prod                              │
╰──────────────────────────────────────────────────────────────────────────────╯
```

</details>

### What you get out

| Flag | Output |
|---|---|
| `-o FILE` | **draw.io** diagram with native Azure icons - open it in [draw.io](https://app.diagrams.net), the desktop app or the VS Code extension and edit it like any hand-drawn diagram |
| `--html FILE` | **Interactive viewer** - one self-contained file, no server and no CDN |
| `--mermaid FILE` | Mermaid source, for embedding in Markdown docs |
| `--json FILE` | The graph itself - this is what `cloudmap ask` reads |
| `--csv FILE` | Flat edge list with evidence, for a spreadsheet or an auditor |

<div align="center">
  <img src="estate-viewer.png" alt="The interactive HTML viewer: the seed at the centre, dependencies fanned out with native Azure icons, each edge carrying its relationship and proof">
</div>

The `--html` viewer has a dark mode, edges colour-coded by relationship type
(security / data / network), resource-group filters and search, SVG and PNG
export, and direct links into the Azure portal.

## Interactive wizard

Run it with no arguments and it walks you through the whole thing:

```
cloudmap
```

It asks for a subscription (yours is marked as the default), then a resource
group, then the resource to trace, then how deeply to enrich, and where to write
the results. It reads live Azure, so `az login` first.

## Why

- **Live-cloud tools upload your data.** cloudmap runs locally: the only traffic
  it generates is the `az` calls to Azure's own API that fetch your inventory.
  Nothing is uploaded anywhere else - no SaaS backend, no telemetry, no model API.
  The graph, the diagrams and the raw properties stay on disk, on your machine.
- **Existing OSS is siloed** - Terraform-only or Kubernetes-only. cloudmap works
  from Azure's own inventory (Resource Graph) and correlates across services.
- **Impact analysis, onboarding, change reviews.** "What breaks if I touch this?"
  in one diagram instead of ten portal blades.

### Three things people use it for

**Is this safe to delete?** An Azure SQL database looks orphaned in the portal and
someone wants it gone to save the monthly bill. `cloudmap trace sql-orders-dev
--live --allow-live --direction up` deep-enriches the connection strings of the
web apps in scope and shows what still points at it - including, occasionally, a
production app that was never supposed to.

**What is actually broken?** An AKS cluster starts failing at 3am. Tracing it
produces a dependency graph with the Key Vault it reads, and the map carries the
evidence for that edge ("found in Kubernetes secret X"), so the next question -
did anything change on that vault - has a place to start.

**Who has access to this?** An auditor asks which systems can reach the storage
account holding customer data. `cloudmap trace pii-storage --live --allow-live
--direction up --csv pii-audit.csv` hands back a spreadsheet of the apps and clusters with managed
identity RBAC on it, with the role assignments as proof.

## What it maps

**Any Azure resource type can be a seed.** The scan is not filtered by type - the
KQL is `resources | project ...` with no type filter, and the same is true of the
wizard's picker. What differs between types is not *whether* they are mapped but
*how precisely*, and that comes from four channels every resource goes through:

| Channel | What it finds | Evidence it records |
|---|---|---|
| **ARM ids in properties** | any resolvable resource id anywhere in the blob, plus `managedBy` and assigned identities | the property path (`properties.serverFarmId`) |
| **Advertised hostnames** | free text naming a scanned resource's own endpoint (`kv-x.vault.azure.net`, `acr.azurecr.io`) | the host and where it was found |
| **Nested child ids** | a child that cannot exist without its parent (a SQL database, a Service Bus queue) | `child-of`, derived from the id's shape |
| **RBAC role assignments** | which identity holds which role on which scope | `role: <name>`, principal → scope |

On top of those, ~17 services have **typed rules** that give the edge its real
meaning instead of a generic `references`: App Service / Functions, Container Apps
(+ managed environments), AKS, VMs, NICs, Key Vault, Storage, SQL, PostgreSQL,
MySQL, Cosmos, Redis, Service Bus, Cognitive Search, Cognitive Services / Azure
OpenAI, Container Registry, ML workspaces, Log Analytics, App Insights and public
IPs - producing `hosted-on`, `reads-secret`, `pulls-image`, `vnet-integration`,
`uses-workspace`. A few more rules key off a **property shape** rather than a type
name, so they fire wherever that shape appears: `privateLinkServiceConnections` →
`private-link-to` (Private Endpoints), `backendAddressPools[].fqdn` → `routes-to`
and `gatewayIPConfigurations` → `in-subnet` (Application Gateway), `subnet.id` →
`in-subnet`.

Everything else - API Management, Data Factory, Front Door, a type published last
week - still lands on the map through the four channels above, as a `references`
edge carrying the property path that produced it. That is the claim a conformance
sweep over all **4,687 published Azure resource types** pins
(`tests/test_type_agnostic.py`): every one of them can be a seed and can be found.
Deliberately excluded as *sources*: alert rules and dashboards, which reference
half the estate without depending on it - they emit `observes` instead, so they
are still visible without turning into hubs.

RBAC edges are extracted **tenant-wide by default** (`--single-sub` narrows this to
the active subscription), so "who has access to this" works for any resource that
can be a role scope.

Depth is honest about itself: apps and clusters have rich outbound edges because
their config names other resources. Infrastructure resources are usually leaves
going outward, and their value is the reverse view (`--direction up`).

## What you will see - and what you won't

cloudmap has **no credentials of its own**. Every read is a subprocess call to the
Azure CLI using your `az login` token, so the map is bounded by your own RBAC. If
you are about to try this on a tenant and want to know what to expect, this
section is the whole answer.

### The two ways a resource can be missing

**Invisible.** Azure Resource Graph filters by RBAC *server-side*. A resource you
have no role on is not returned and **no error is raised** - it simply is not in
the data. cloudmap cannot tell the difference between "does not exist" and "you
cannot see it", and neither can you from the output alone. This is the failure
mode to keep in mind: a thin map on a narrow account is not evidence of a small
blast radius.

**Denied.** The deep reads (app settings, Key Vault values, AKS manifests) are
ordinary API calls that fail loudly when your role is short. cloudmap catches
each one, classifies it, and writes it on the map as a **read gap** rather than
dropping the edges it would have produced. You get told.

### What each level of access buys you

| To get this on the map | cloudmap runs | You need |
|---|---|---|
| The resources themselves, and every role assignment | `az graph query` | **Reader** on the subscriptions in scope. Reader is literally `*/read`, and ARG returns only what that covers. |
| A workload's identity, VNet integration, runtime | `az webapp show` | Reader |
| Where a resource sends its diagnostics | `az monitor diagnostic-settings list` | Reader |
| The RBAC an app's managed identity holds elsewhere | `az role assignment list --assignee --all` | Reader at the scopes you want counted |
| **Key Vault references and connection strings** (`--enrich`) | `az webapp config appsettings list`, `az webapp config connection-string list` | `Microsoft.Web/sites/config/list/Action`. **Reader is not enough** - this is a POST, not a read. *Website Contributor* or *Contributor* on the app have it. |
| **What a KV-backed connection string actually points at** (`--resolve-secrets`) | `az keyvault secret show` | Key Vault **data-plane** access: *Key Vault Secrets User* (or an equivalent access policy). Values are used in memory and never printed or written. |
| **AKS workload edges** - images, configmaps, optionally secrets | `az aks command invoke` | `Microsoft.ContainerService/managedClusters/runCommand/action`. *Azure Kubernetes Service Contributor* has it; *Cluster User* does not. |

That is the complete list of `az` commands cloudmap can run. All of them are
`show` / `list` / `query` verbs; `aks command invoke` is the one asterisk, and it
is explained under [Live Azure](#live-azure).

The practical consequence: **on a plain Reader account, tracing a Key Vault will
usually come back nearly empty**, because the apps that read it declare that only
in their app settings, and app settings need more than Reader. The map will say so
as a read gap - it will not quietly show you an unused vault.

### How the map tells you it is incomplete

Three separate signals, all written into the artifact (`--json` `meta`, the HTML
viewer's `INCOMPLETE ⓘ` badge, and repeated by every `cloudmap ask` answer drawn
from that map):

| Signal | Means | Fix |
|---|---|---|
| `truncated` | the Resource Graph scan hit the pagination cap, so the inventory itself is partial | narrow the scope: `--single-sub` |
| `read_gaps` | a specific deep read failed - permission, network, or the resource refusing | grant the action in the table above, or accept the gap knowingly |
| `blind_spots` | a whole class of edge was never attempted, because those workloads were not deep-enriched | `--enrich all` |

`complete: false` in the JSON is set if any of the three is non-empty. An empty
answer from an incomplete map is never allowed to read as "nothing depends on
this".

### My map came back empty or thinner than expected

In order:

1. **Did it find the right resource?** Matching is case-insensitive substring; on
   more than one match cloudmap prints each candidate with its resource group and
   full id instead of guessing.
2. **Is the scope right?** By default cloudmap scans **every enabled subscription
   in the tenant**. If the terminal reports fewer subscriptions than you expect,
   your account has no role on the rest - they are invisible, not excluded.
3. **Read the meta.** `truncated` / `read_gaps` / `blind_spots` name what is
   missing, in that order of severity.
4. **Was it the right direction?** Infrastructure (a vault, a subnet, a plan) has
   few outbound edges by nature. `--direction up` is the view that matters for it.
5. **Was it enriched?** If the seed is a data service and you did not enrich, the
   dependents that live in app config were never looked for. Re-run with
   `--enrich all`.

## How it works

1. **Ingest** - a JSON fixture (default) or live `az graph query` (opt-in, guarded).
2. **Extract** - per-type rules turn `properties` into typed edges
   (`hosted-on`, `reads-secret`, `private-link-to`, `role: ...`, `routes-to`, ...).
3. **Blast radius** - walk the graph from your seed with *direction consistency*:
   from the seed it goes both ways (what it depends on **and** what depends on
   it), but once it steps in one direction it never reverses. That single rule
   keeps a shared resource (App Service Plan, VNet, Key Vault) from bridging your
   seed to unrelated apps sitting on the same thing.
4. **Render** - draw.io (Azure icons) + Mermaid + JSON + CSV + a self-contained HTML viewer.
5. **Ask** - query the saved map in plain language (`cloudmap ask`); the answers are
   computed from the graph, and a local model may only route the question or narrate
   the result.

## Ask a map questions

```
$ cloudmap trace contoso-web --from demo --level detail --json out.json
$ cloudmap ask out.json "what breaks if I touch contoso-kv"
Query: impact  ·  subject: contoso-kv
2 resource(s) depend on contoso-kv - changing it can break them.

  contoso-web  (Web App)  1 hop(s)
      contoso-web --reads-secret; role: Key Vault Secrets User--> contoso-kv
          proof: app config references host contoso-kv.vault.azure.net; Key Vault
                 reference to vault contoso-kv; RBAC role assignment
  contoso-agw  (App Gateway)  2 hop(s)
      ...
```

Questions it answers: what breaks if I touch X · what does X depend on · how does
X reach Y · what is shared in this map · what should I not trust · explain this map.

**The answers are computed, not generated.** "What breaks if I touch X" is a graph
traversal, so that is how it is answered - the numbers and names come from the
edges. A local model is optional and can only do two things: pick which query an
unusual phrasing meant (`--llm`, and its pick is validated against the map), or put
the already-computed facts into prose (`--explain`). It never supplies a fact, so it
cannot promote a guess to one. Every finding shows the hops behind it and the proof
of each hop, and a finding that leans on a model-proposed edge is marked `[GUESS]`.
If the map itself says it is incomplete, every answer from it repeats that warning.

<details>
<summary><b>Flags</b></summary>

```
cloudmap ask <map.json> "<question>"

  --explain      also narrate the answer with a LOCAL model
  --llm          if no built-in rule understands the phrasing, let a LOCAL model
                 pick the query (its choice is validated against the map)
  --max-hops N   limit traversal depth
  --json         print the answer as JSON (for scripting)
```

Instance names only exist in a `--level detail` map; the default high-level map
groups by type, so ask it about a group (`"what breaks if I touch Key Vault"`) or
trace with `--level detail` first.

</details>

## Live Azure

Fixtures are the default. Live mode is opt-in and **not sandboxed**: `--allow-live`
is the deliberate switch, and cloudmap reads whatever subscription `az` is pointed
at. The read is read-only, but it is a read of live infrastructure, so point it on
purpose. **Do not point this at data you are not authorized to read.**

One asterisk on "read-only": AKS manifests are read through `az aks command
invoke`, which Azure implements by starting a short-lived pod in the cluster to
run the (read-only) `kubectl get` commands. Nothing of yours is modified, but an
audit of the cluster's control plane will see that ephemeral pod.

Tenant-wide enrichment runs its `az` reads concurrently; `CLOUDMAP_ENRICH_WORKERS`
(default 12) tunes how many at once. Measured on a real subscription: tracing a
Key Vault's dependents with `--enrich` fanning out over 287 web apps and 2 AKS
clusters took 6m08s wall-clock at the default concurrency, peaking at ~100 MB
of memory. Seed-only traces (a web app, a VM) come back in seconds.

```
cloudmap trace my-app --live --allow-live
```

**cloudmap runs as you.** It has no credentials of its own - every read goes through
the Azure CLI with your `az login` token, so it sees exactly what your account can
see and nothing more. What that means in practice, per permission level, is in
[What you will see - and what you won't](#what-you-will-see---and-what-you-wont).

<details>
<summary><b>Flags, enrichment and the subscription pin</b></summary>

```
  --single-sub          query only the active subscription (default: every enabled
                        subscription in the tenant)
  --enrich MODE         which web apps to deep-enrich for the dependencies that only
                        exist in app config. auto (default) = the seed alone when the
                        seed is a workload; every app in scope when the seed is a data
                        service whose dependents hide in app config (Key Vault,
                        storage, SQL, Redis, ...); the seed alone for compute and
                        network resources, whose relationships ARM already returns;
                        all | seed | none
  --resolve-secrets     read KV secret values in-memory to see through KV-backed
                        connection strings (never printed or written)
  --llm                 let a LOCAL model propose extra edges, each
                        verified against scanned resources before it is trusted
```

**Why `--enrich` matters.** A Key Vault reference or a connection string lives in an
app's settings, which Resource Graph does not return - so that edge exists only once
that app has been deep-enriched. Enriching only the seed would make the graph
asymmetric: tracing an app finds the vault it reads, but tracing the vault would never
find the app. Since "what breaks if I touch this" is usually asked about shared
infrastructure, `auto` enriches every app in scope whenever the seed is *not* an app.
Anything left un-enriched is reported as a **blind spot** on the map and repeated by
every `ask` answer drawn from it, so an empty result never passes for "nothing depends
on this".

As an optional guard against a stale `az` context silently redirecting a scan, pin the
subscription you mean - if set, cloudmap refuses to run against any other active
subscription:

```
export CLOUDMAP_ALLOW_SUBSCRIPTION=<subscription-id>   # optional
```

</details>

<details>
<summary><b>Capture a real export (so the tests can be wrong)</b></summary>

A fixture you wrote yourself can only confirm what you already believe. `capture`
saves what Azure actually returned, scrubs it, and gives you something that can
contradict the extractors.

```
cloudmap capture --allow-live --single-sub -o fixtures/captured_real.json
cloudmap scrub  raw-export.json -o fixtures/captured_real.json   # for a file you already have
```

The scrub is a **global, consistent** pseudonymisation, not a field-by-field
blanking: `kv-payments` becomes `kv-1` everywhere at once, so the app setting that
references `kv-payments.vault.azure.net` still points at the same vault
afterwards. What survives on purpose: service domain suffixes (the rules read them
to decide what an edge means), built-in role GUIDs (public Azure constants) and
private IP ranges (that is the topology). What does not: names, resource groups,
subscription and principal GUIDs, e-mails, public IPs, and any
password / key / SAS fragment, which is redacted rather than renamed.

The mapping is never written to disk - it is the re-identification key. Counts are
printed, the mapping is not. **A scrubber is not a proof: read the file before you
commit it.** `--no-scrub` exists for local debugging and writes credentials to
disk; keep those files named `*.live.json` so `.gitignore` catches them.

</details>

<details>
<summary><b>Usage reference</b></summary>

```
cloudmap trace <name> (--from <fixture.json> | --live) [options]

  --level high|detail        high = architecture view grouped by type (default);
                             one box per type, so instance names are not in the map
                             detail = every instance with its real name
  --direction both|down|up   both = full blast radius (default)
                             down = only what it depends on
                             up   = only what depends on it
  --max-hops N               limit traversal depth
  -o FILE                    draw.io output (default: <name>.blast.drawio)
  --mermaid FILE             also write Mermaid
  --json FILE                also write the graph as JSON
  --html FILE                also write a self-contained interactive HTML viewer
  --csv FILE                 also write the edge list as CSV
  -d, --out-dir DIR          write every artifact into DIR, named after the seed
```

Other subcommands: `cloudmap capture`, `cloudmap scrub`, `cloudmap ask`.

</details>

## Roadmap

- Terraform state ingestor + drift overlay (desired vs actual).
- Deeper AKS / Kubernetes workload correlation.
- Resource-group and application (tag-based) seeds.
- More edge extractors and Azure icon mappings (Container Apps still render as a
  labelled box - an icon is only added once its azure2 asset path is verified).

## License

MIT
