cloudmap 1.0.0rc1__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/scrub.py ADDED
@@ -0,0 +1,300 @@
1
+ """Turn a captured raw export into a fixture that can be committed.
2
+
3
+ Why this exists: every test in this repo runs on data we wrote ourselves, which
4
+ only proves the extractors agree with our imagination. A *real* export, scrubbed,
5
+ turns "given this real input the map MUST contain these edges" into a test that
6
+ can actually fail. That is the difference between trust-by-design and
7
+ trust-by-evidence, and PLAN.md names it as the weakest link.
8
+
9
+ The hard part is that scrubbing must not destroy the very thing under test. An
10
+ app setting says `https://kv-payments.vault.azure.net/`; the vault resource is
11
+ named `kv-payments`; connecting those two IS the extractor's job. So this is a
12
+ GLOBAL, consistent substitution: one pseudonym per real token, applied to every
13
+ string in the document. Rename the vault and the app setting follows it.
14
+
15
+ Two things are deliberately preserved because the rules depend on them:
16
+ - service domain suffixes (`.vault.azure.net`, `.database.windows.net`, ...),
17
+ which is how `_DOMAIN_KIND` decides what an edge means;
18
+ - instrumentation-key GUIDs, which correlate an app to its App Insights - they
19
+ are pseudonymised consistently rather than redacted, so the link survives.
20
+
21
+ Credentials get the opposite treatment: they carry no structure worth keeping, so
22
+ password / key / SAS fragments are REDACTED in place while the host and database
23
+ around them survive, pseudonymised.
24
+
25
+ The mapping is never written to disk. It is the re-identification key; keeping it
26
+ would undo the point of the exercise.
27
+
28
+ This is a scrubber, not a proof. READ THE OUTPUT before you commit it.
29
+ """
30
+
31
+ import json
32
+ import re
33
+
34
+ from .extract.extractors import ROLE_NAMES
35
+
36
+ # Built-in role definition GUIDs are public Azure constants, identical in every
37
+ # tenant, and the extractor resolves role NAMES from them. Pseudonymising them
38
+ # would turn "AcrPull" into "custom role" and quietly degrade the fixture.
39
+ KEEP_GUIDS = {g.lower() for g in ROLE_NAMES}
40
+
41
+ # Pseudonym prefixes: a fixture is easier to reason about when the fake name
42
+ # still says what the resource is.
43
+ ABBREV = {
44
+ "microsoft.web/sites": "app",
45
+ "microsoft.web/serverfarms": "plan",
46
+ "microsoft.keyvault/vaults": "kv",
47
+ "microsoft.storage/storageaccounts": "st",
48
+ "microsoft.sql/servers": "sql",
49
+ "microsoft.dbforpostgresql/flexibleservers": "pg",
50
+ "microsoft.dbforpostgresql/servers": "pg",
51
+ "microsoft.dbformysql/flexibleservers": "mysql",
52
+ "microsoft.dbformysql/servers": "mysql",
53
+ "microsoft.documentdb/databaseaccounts": "cosmos",
54
+ "microsoft.cache/redis": "redis",
55
+ "microsoft.servicebus/namespaces": "sb",
56
+ "microsoft.eventhub/namespaces": "eh",
57
+ "microsoft.search/searchservices": "srch",
58
+ "microsoft.cognitiveservices/accounts": "ai",
59
+ "microsoft.containerregistry/registries": "acr",
60
+ "microsoft.containerservice/managedclusters": "aks",
61
+ "microsoft.operationalinsights/workspaces": "law",
62
+ "microsoft.insights/components": "appi",
63
+ "microsoft.network/virtualnetworks": "vnet",
64
+ "microsoft.network/privateendpoints": "pe",
65
+ "microsoft.network/applicationgateways": "agw",
66
+ "microsoft.apimanagement/service": "apim",
67
+ "microsoft.managedidentity/userassignedidentities": "mi",
68
+ "microsoft.app/containerapps": "ca",
69
+ "microsoft.app/managedenvironments": "cae",
70
+ "microsoft.authorization/roleassignments": "ra",
71
+ }
72
+
73
+ # Host suffixes that must survive verbatim: the extractor reads the suffix to
74
+ # decide what kind of dependency a hostname is.
75
+ AZURE_SUFFIXES = (
76
+ "vault.azure.net", "core.windows.net", "database.windows.net",
77
+ "postgres.database.azure.com", "mysql.database.azure.com",
78
+ "documents.azure.com", "redis.cache.windows.net", "servicebus.windows.net",
79
+ "search.windows.net", "openai.azure.com", "cognitiveservices.azure.com",
80
+ "azurecr.io", "azurewebsites.net", "azure-api.net", "azureedge.net",
81
+ "azurecontainerapps.io", "monitor.azure.com", "applicationinsights.azure.com",
82
+ )
83
+
84
+ _GUID = re.compile(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", re.I)
85
+ _EMAIL = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")
86
+ _IPV4 = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
87
+ # Hosts are only hunted where a host can legitimately appear. A bare
88
+ # "looks like a hostname" regex also matches `properties.serverFarmId` and
89
+ # `microsoft.web/sites`, and pseudonymising those would shred the document.
90
+ _AZURE_HOST = re.compile(r"\b(?:[a-z0-9-]+\.)+(?:" +
91
+ "|".join(re.escape(s) for s in AZURE_SUFFIXES) + r")\b", re.I)
92
+ _URL_HOST = re.compile(r"(?i)\bhttps?://([a-z0-9.-]+\.[a-z]{2,})")
93
+ _CONN_HOST = re.compile(r"(?i)\b(?:server|host|hostname|data source)\s*=\s*"
94
+ r"([a-z0-9.-]+\.[a-z]{2,})")
95
+
96
+ # Credential shapes. `instrumentationkey` is excluded on purpose: it is a
97
+ # correlation id the extractor needs, and the GUID pass pseudonymises it.
98
+ _SECRET_PATTERNS = [
99
+ re.compile(r"(?i)\b(password|pwd)\s*=\s*[^;,\"'\s]+"),
100
+ re.compile(r"(?i)\b(accountkey|sharedaccesskey|primarykey|secondarykey|"
101
+ r"apikey|api[-_]?key|client[-_]?secret|access[-_]?key)\s*=\s*[^;,\"'\s]+"),
102
+ re.compile(r"(?i)\b(sig)=[^&;,\"'\s]+"),
103
+ re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/-]{20,}=*"),
104
+ # Long opaque blobs: base64-ish keys that carry no structure worth keeping.
105
+ # `/` is deliberately NOT in the class even though it is a base64 character:
106
+ # with it, `.../providers/Microsoft.ContainerService/managedClusters/x` reads
107
+ # as one 40-char blob and the scrubber shreds ARM ids. A real key still has a
108
+ # 40+ run between its slashes. The digit lookahead keeps long words out.
109
+ re.compile(r"(?<![A-Za-z0-9+/])(?=[A-Za-z0-9+]*\d)[A-Za-z0-9+]{40,}={0,2}"
110
+ r"(?![A-Za-z0-9+])"),
111
+ ]
112
+
113
+ # A token shorter than this substitutes inside unrelated words more often than it
114
+ # hides anything, so it is reported instead of applied.
115
+ MIN_TOKEN = 3
116
+
117
+ # ARM path segments. A resource unluckily named "sites" would otherwise rewrite
118
+ # every `providers/Microsoft.Web/sites/...` id in the document and corrupt it.
119
+ RESERVED = {
120
+ "sites", "servers", "vaults", "providers", "subscriptions", "resourcegroups",
121
+ "components", "accounts", "service", "services", "registries", "namespaces",
122
+ "workspaces", "serverfarms", "redis", "virtualnetworks", "subnets",
123
+ "privateendpoints", "applicationgateways", "managedclusters", "databaseaccounts",
124
+ "flexibleservers", "searchservices", "roleassignments", "containerapps",
125
+ "userassignedidentities", "managedenvironments", "storageaccounts",
126
+ }
127
+
128
+ # Values that describe the schema rather than the tenant: substituting inside
129
+ # them can only do damage.
130
+ PROTECTED_KEYS = {"type", "location"}
131
+
132
+
133
+ def _private_ip(ip):
134
+ """Private ranges are topology, not identity - keep them, they are the whole
135
+ point of a network diagram."""
136
+ p = ip.split(".")
137
+ if len(p) != 4 or not all(x.isdigit() for x in p):
138
+ return False
139
+ a, b = int(p[0]), int(p[1])
140
+ return a == 10 or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168) or a == 127
141
+
142
+
143
+ class _Namer:
144
+ """Hands out stable pseudonyms, one per distinct real value. Stability is the
145
+ whole trick: the same real token always becomes the same fake one, wherever
146
+ it appears, which is what keeps a reference and its target correlated."""
147
+
148
+ def __init__(self):
149
+ self.map = {}
150
+ self._counts = {}
151
+
152
+ def custom(self, real, counter, template):
153
+ key = str(real).lower()
154
+ if key not in self.map:
155
+ self._counts[counter] = self._counts.get(counter, 0) + 1
156
+ self.map[key] = template.format(n=self._counts[counter])
157
+ return self.map[key]
158
+
159
+ def get(self, real, prefix):
160
+ return self.custom(real, prefix, prefix + "{n}")
161
+
162
+ def guid(self, real):
163
+ return self.custom(real, "guid", "{n:08d}-0000-0000-0000-000000000000")
164
+
165
+
166
+ def redact_credentials(text):
167
+ """Blank out credential fragments in place. Returns (text, n_redactions).
168
+
169
+ Only a NAMED key keeps its head: `AccountKey=<secret>` stays readable as
170
+ `AccountKey=REDACTED`, which is what makes a scrubbed export still legible.
171
+ A bare blob is replaced whole - splitting it on `=` would treat base64
172
+ PADDING as if it were a key name and write the secret back out verbatim
173
+ (`<secret>==` -> `<secret>=REDACTED`). That bug shipped a live storage key
174
+ into a capture; the head is now taken from the pattern's own group, never
175
+ guessed from the text.
176
+ """
177
+ total = 0
178
+ for pat in _SECRET_PATTERNS:
179
+ named = pat.groups > 0
180
+
181
+ def repl(m, named=named):
182
+ return f"{m.group(1)}=REDACTED" if named else "REDACTED"
183
+
184
+ text, n = pat.subn(repl, text)
185
+ total += n
186
+ return text, total
187
+
188
+
189
+ def _walk(obj, fn):
190
+ """Apply fn to every string in a nested structure, keys included - except the
191
+ values of PROTECTED_KEYS, which describe the schema, not the tenant."""
192
+ if isinstance(obj, dict):
193
+ out = {}
194
+ for k, v in obj.items():
195
+ nk = fn(k) if isinstance(k, str) else k
196
+ out[nk] = v if (isinstance(k, str) and k in PROTECTED_KEYS
197
+ and isinstance(v, str)) else _walk(v, fn)
198
+ return out
199
+ if isinstance(obj, list):
200
+ return [_walk(v, fn) for v in obj]
201
+ if isinstance(obj, str):
202
+ return fn(obj)
203
+ return obj
204
+
205
+
206
+ def collect_tokens(resources, namer):
207
+ """Build the real -> pseudonym map from the structured fields, where we know
208
+ what a value *is*, rather than guessing from free text."""
209
+ hosts = {}
210
+ for r in resources:
211
+ if not isinstance(r, dict):
212
+ continue
213
+ rtype = str(r.get("type") or "").lower()
214
+ prefix = ABBREV.get(rtype) or (rtype.rsplit("/", 1)[-1][:6] or "res")
215
+ name = r.get("name")
216
+ pseudo = namer.get(name, prefix + "-") if name else None
217
+ if r.get("resourceGroup"):
218
+ namer.get(r["resourceGroup"], "rg-")
219
+ if r.get("subscriptionId"):
220
+ namer.guid(r["subscriptionId"])
221
+
222
+ # Host fields: keep the service suffix, replace the identifying label.
223
+ props = r.get("properties") if isinstance(r.get("properties"), dict) else {}
224
+ candidates = [props.get("defaultHostName"), props.get("fullyQualifiedDomainName"),
225
+ props.get("vaultUri"), props.get("loginServer"),
226
+ props.get("documentEndpoint"), props.get("hostName")]
227
+ candidates.extend(props.get("hostNames") or []) # custom domains
228
+ for host in candidates:
229
+ if not isinstance(host, str) or "." not in host:
230
+ continue
231
+ host = host.replace("https://", "").replace("http://", "").strip("/").lower()
232
+ if host in hosts:
233
+ continue
234
+ label, suffix = host.split(".", 1)
235
+ if suffix.endswith(AZURE_SUFFIXES) and pseudo:
236
+ hosts[host] = f"{pseudo}.{suffix}"
237
+ else:
238
+ hosts[host] = namer.custom(host, "host", "host{n}.example.invalid")
239
+ namer.map.update(hosts)
240
+ return namer.map
241
+
242
+
243
+ def scrub(resources):
244
+ """Pseudonymise a raw export. Returns (resources, stats).
245
+
246
+ Order matters: redact first so a secret never enters the mapping, then map
247
+ the identifiers that survive, then substitute everywhere at once."""
248
+ for r in resources:
249
+ if isinstance(r, dict) and "kubernetes_text" in r:
250
+ del r["kubernetes_text"]
251
+
252
+ text = json.dumps(resources)
253
+ text, redactions = redact_credentials(text)
254
+ resources = json.loads(text)
255
+
256
+ namer = _Namer()
257
+ collect_tokens(resources, namer)
258
+
259
+ # Free-text sweep for identifiers the structured pass cannot see: references
260
+ # to resources outside the scan, GUIDs, people, endpoints.
261
+ for m in _GUID.findall(text):
262
+ if m.lower() not in KEEP_GUIDS:
263
+ namer.guid(m)
264
+ for m in _EMAIL.findall(text):
265
+ namer.custom(m, "user", "user{n}@example.invalid")
266
+ for m in _IPV4.findall(text):
267
+ if not _private_ip(m): # private ranges are topology, keep them
268
+ namer.custom(m, "ip", "203.0.113.{n}")
269
+ for m in _AZURE_HOST.findall(text):
270
+ h = m.lower()
271
+ if h not in namer.map:
272
+ # Outside the scanned set, so no resource lends it a name - but the
273
+ # service suffix must survive, it is what the edge kind is read from.
274
+ namer.custom(h, "ext", "ext{n}." + h.split(".", 1)[1])
275
+ for m in _URL_HOST.findall(text) + _CONN_HOST.findall(text):
276
+ h = m.lower()
277
+ if h not in namer.map and not h.endswith(AZURE_SUFFIXES):
278
+ namer.custom(h, "host", "host{n}.example.invalid")
279
+
280
+ mapping, skipped = {}, []
281
+ for real, pseudo in namer.map.items():
282
+ if len(real) < MIN_TOKEN or real in RESERVED:
283
+ skipped.append(real) # unsafe to replace - reported, never hidden
284
+ continue
285
+ mapping[real] = pseudo
286
+
287
+ if mapping:
288
+ # Longest first: a full hostname must win over the bare resource name
289
+ # inside it. The lookarounds stop a name matching inside a longer word.
290
+ alt = "|".join(re.escape(t) for t in sorted(mapping, key=len, reverse=True))
291
+ rx = re.compile(r"(?<![A-Za-z0-9])(?:" + alt + r")(?![A-Za-z0-9])", re.I)
292
+ resources = _walk(resources, lambda s: rx.sub(lambda m: mapping[m.group(0).lower()], s))
293
+
294
+ stats = {
295
+ "resources": len(resources) if isinstance(resources, list) else 0,
296
+ "tokens": len(mapping),
297
+ "redactions": redactions,
298
+ "short_tokens_left_alone": sorted(set(skipped)),
299
+ }
300
+ return resources, stats
@@ -0,0 +1,291 @@
1
+ Metadata-Version: 2.5
2
+ Name: cloudmap
3
+ Version: 1.0.0rc1
4
+ Summary: Trace the blast radius of your Azure resources with style.
5
+ License-File: LICENSE
6
+ Classifier: Development Status :: 5 - Production/Stable
7
+ Classifier: Environment :: Console
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.9
13
+ Requires-Dist: questionary>=2.0.0
14
+ Requires-Dist: rich>=13.0.0
15
+ Description-Content-Type: text/markdown
16
+
17
+ # cloudmap
18
+
19
+ [![CI](https://github.com/KatsaounisThanasis/cloudmap/actions/workflows/ci.yml/badge.svg)](https://github.com/KatsaounisThanasis/cloudmap/actions/workflows/ci.yml) [![PyPI version](https://badge.fury.io/py/cloudmap.svg)](https://badge.fury.io/py/cloudmap)
20
+
21
+
22
+ Give it the name of one Azure resource. Get back its **full dependency graph**
23
+ (the blast radius) as an **editable draw.io diagram with native Azure icons** -
24
+ plus Mermaid and JSON.
25
+
26
+ Azure Resource Graph has no "dependencies" table. The relationships that matter
27
+ - what an App Service is hosted on, which Key Vault it reads, which subnet it
28
+ integrates with, which managed identity has which role where, what a Private
29
+ Endpoint fronts, which App Gateway routes to it - are buried inside each
30
+ resource's `properties`. cloudmap reads them out, correlates them into one
31
+ graph, and draws it.
32
+
33
+ ```
34
+ cloudmap trace contoso-web --from fixtures/contoso.json -o contoso-web.drawio
35
+ ```
36
+
37
+ ```
38
+ Seed: contoso-web (microsoft.web/sites)
39
+ Blast radius: 9 resources (0 external/unverified), 9 dependencies
40
+ draw.io: contoso-web.drawio
41
+
42
+ contoso-web --hosted-on--> App Service Plan
43
+ contoso-web --vnet-integration--> Virtual Network
44
+ contoso-web --reads-secret; role: Key Vault Secrets User--> Key Vault
45
+ contoso-web --connects-to--> SQL Server
46
+ contoso-web --connects-to--> Storage
47
+ contoso-web --sends-telemetry--> App Insights
48
+ App Insights --uses-workspace--> Log Analytics
49
+ App Gateway --in-subnet--> Virtual Network
50
+ App Gateway --routes-to--> contoso-web
51
+ ```
52
+
53
+ And here is the rich dependency tree generated directly in your terminal:
54
+
55
+ ```text
56
+ Blast radius: 15 resources (0 external), 14 dependencies
57
+ ╭────────────────────────────── Dependency Graph ──────────────────────────────╮
58
+ │ 🌐 app-spa-frontend │
59
+ │ ├── --calls--> 🌐 app-auth-service │
60
+ │ │ ├── --reads-secret--> 🔐 kv-core-prod │
61
+ │ │ └── --connects-to--> 🗄️ cosmos-auth │
62
+ │ └── --calls--> 🌐 app-api-gateway │
63
+ │ ├── --calls--> 📦 capp-payment-service │
64
+ │ │ └── --reads-secret--> 🔐 kv-payments-prod │
65
+ │ ├── --calls--> 🌐 app-inventory-api │
66
+ │ │ ├── --connects-to--> 🗄️ pg-inventory-prod │
67
+ │ │ └── --connects-to--> 📦 stinventoryprod │
68
+ │ ├── --connects-to--> 🗄️ redis-gateway │
69
+ │ └── --calls--> 🌐 app-orders-api │
70
+ │ ├── --connects-to--> 🗄️ redis-orders │
71
+ │ ├── --connects-to--> 📦 sb-enterprise │
72
+ │ └── --connects-to--> 🗄️ sql-orders-prod │
73
+ ╰──────────────────────────────────────────────────────────────────────────────╯
74
+ ```
75
+
76
+ (That is the default **high-level** view - resources grouped by type. Add
77
+ `--level detail` to see every instance with its real name.)
78
+
79
+ Open the `.drawio` file in [draw.io](https://app.diagrams.net) / the desktop app
80
+ / the VS Code extension and edit it like any hand-drawn diagram - or open the
81
+ self-contained `--html` viewer straight from disk (no server, no install) and
82
+ click a resource to focus its blast radius:
83
+
84
+
85
+ ### Interactive HTML Viewer 🎨
86
+ The `--html` output generates a **single, self-contained HTML file** (no server, no CDN, no internet required) that includes:
87
+ - **Dark Mode Toggle** for better readability.
88
+ - **Color-Coded Edges** by relationship type (e.g., 🟡 *Security*, 🟢 *Data*, 🔵 *Network*).
89
+ - **Export to SVG / PNG** buttons for high-resolution snapshots.
90
+ - **Resource Group Filters** & Smart Search.
91
+ - **Direct Azure Portal Links** to jump straight to the resource in your cloud.
92
+
93
+ ![The interactive HTML viewer: the seed at the centre, dependencies fanned out with native Azure icons, each edge carrying its relationship and proof](estate-viewer.png)
94
+
95
+
96
+ ## Why
97
+
98
+ - **Live-cloud tools upload your data.** cloudmap runs locally and reads only
99
+ what you point it at. Nothing leaves your machine.
100
+ - **Existing OSS is siloed** - Terraform-only or Kubernetes-only. cloudmap works
101
+ from Azure's own inventory (Resource Graph) and correlates across services.
102
+ - **Impact analysis, onboarding, change reviews.** "What breaks if I touch this?"
103
+ in one diagram instead of ten portal blades.
104
+
105
+ ## Real-World Scenarios (Why you need this)
106
+
107
+ ### 1. The "Safe to Delete?" Scenario (FinOps / Cloud Cost Cleanup)
108
+ A developer spots an expensive Azure SQL Database (`sql-orders-dev`) that looks orphaned in the portal. They want to delete it to save $500/month.
109
+ By running `cloudmap trace sql-orders-dev --direction up`, the tool deep-enriches the connection strings of all Web Apps in the subscription. The map instantly reveals that a *production* Web App is mistakenly pointing to this dev database! The engineer just avoided a catastrophic outage.
110
+
111
+ ### 2. The "Incident Response / Root Cause" Scenario (SRE)
112
+ At 3:00 AM, alerts fire because `payment-api` (an AKS cluster) is failing. The team is blind.
113
+ Running the CloudMap interactive wizard on `payment-api` generates an HTML dependency graph in 5 seconds. It shows the cluster depends on a Key Vault (`kv-pay`). Checking the vault reveals someone changed its firewall rules 10 minutes ago. Cloudmap provides the exact *Evidence* ("found in Kubernetes secret X"), identifying the root cause instantly.
114
+
115
+ ### 3. The "Compliance & Auditor Review" Scenario (Security)
116
+ An auditor asks: *"Which systems have access to the Storage Account containing PII customer data?"*
117
+ Instead of manually clicking through 50 IAM screens in the Azure Portal, you run `cloudmap trace pii-storage --direction up --csv pii-audit.csv`. In seconds, you hand the auditor a clean spreadsheet showing exactly which Web Apps and AKS clusters have Managed Identity RBAC access to the storage, complete with the exact Role Assignments as proof.
118
+
119
+ ## How it works
120
+
121
+ 1. **Ingest** - a JSON fixture (default) or live `az graph query` (opt-in, guarded).
122
+ 2. **Extract** - per-type rules turn `properties` into typed edges
123
+ (`hosted-on`, `reads-secret`, `private-link-to`, `role: ...`, `routes-to`, ...).
124
+ 3. **Blast radius** - walk the graph from your seed with *direction consistency*:
125
+ from the seed it goes both ways (what it depends on **and** what depends on
126
+ it), but once it steps in one direction it never reverses. That single rule
127
+ keeps a shared resource (App Service Plan, VNet, Key Vault) from bridging your
128
+ seed to unrelated apps sitting on the same thing.
129
+ 4. **Render** - draw.io (Azure icons) + Mermaid + JSON + a self-contained HTML viewer.
130
+ 5. **Ask** - query the saved map in plain language (`cloudmap ask`); the answers are
131
+ computed from the graph, and a local model may only route the question or narrate
132
+ the result.
133
+
134
+ ## Usage
135
+
136
+ ```
137
+ cloudmap trace <name> --from <fixture.json> [options]
138
+
139
+ --level high|detail high = architecture view grouped by type (default);
140
+ one box per type, so instance names are not in the map
141
+ detail = every instance with its real name
142
+ --direction both|down|up both = full blast radius (default)
143
+ down = only what it depends on
144
+ up = only what depends on it
145
+ --max-hops N limit traversal depth
146
+ -o FILE draw.io output (default: <name>.blast.drawio)
147
+ --mermaid FILE also write Mermaid
148
+ --json FILE also write the graph as JSON
149
+ --html FILE also write a self-contained interactive HTML viewer
150
+ ```
151
+
152
+ Try it with the bundled synthetic estate:
153
+
154
+ ```
155
+ cloudmap trace contoso-web --from fixtures/contoso.json --mermaid out.mmd --json out.json
156
+ ```
157
+
158
+ ### Ask a map questions
159
+
160
+ ```
161
+ cloudmap ask <map.json> "<question>"
162
+
163
+ --explain also narrate the answer with a LOCAL model (ollama)
164
+ --llm if no built-in rule understands the phrasing, let a LOCAL model
165
+ pick the query (its choice is validated against the map)
166
+ --max-hops N limit traversal depth
167
+ --json print the answer as JSON (for scripting)
168
+ ```
169
+
170
+ Instance names only exist in a `--level detail` map; the default high-level map
171
+ groups by type, so ask it about a group (`"what breaks if I touch Key Vault"`) or
172
+ trace with `--level detail` first:
173
+
174
+ ```
175
+ $ cloudmap trace contoso-web --from fixtures/contoso.json --level detail --json out.json
176
+ $ cloudmap ask out.json "what breaks if I touch contoso-kv"
177
+ Query: impact · subject: contoso-kv
178
+ 2 resource(s) depend on contoso-kv - changing it can break them.
179
+
180
+ contoso-web (Web App) 1 hop(s)
181
+ contoso-web --reads-secret; role: Key Vault Secrets User--> contoso-kv
182
+ proof: app config references host contoso-kv.vault.azure.net; Key Vault
183
+ reference to vault contoso-kv; RBAC role assignment
184
+ contoso-agw (App Gateway) 2 hop(s)
185
+ ...
186
+ ```
187
+
188
+ Questions it answers: what breaks if I touch X · what does X depend on · how does
189
+ X reach Y · what is shared in this map · what should I not trust · explain this map.
190
+
191
+ **The answers are computed, not generated.** "What breaks if I touch X" is a graph
192
+ traversal, so that is how it is answered - the numbers and names come from the
193
+ edges. A local model is optional and can only do two things: pick which query an
194
+ unusual phrasing meant (`--llm`, and its pick is validated against the map), or put
195
+ the already-computed facts into prose (`--explain`). It never supplies a fact, so it
196
+ cannot promote a guess to one. Every finding shows the hops behind it and the proof
197
+ of each hop, and a finding that leans on a model-proposed edge is marked `[GUESS]`.
198
+ If the map itself says it is incomplete, every answer from it repeats that warning.
199
+
200
+ ### Capture a real export (so the tests can be wrong)
201
+
202
+ A fixture you wrote yourself can only confirm what you already believe. `capture`
203
+ saves what Azure actually returned, scrubs it, and gives you something that can
204
+ contradict the extractors.
205
+
206
+ ```
207
+ cloudmap capture --allow-live --single-sub -o fixtures/captured_real.json
208
+ cloudmap scrub raw-export.json -o fixtures/captured_real.json # for a file you already have
209
+ ```
210
+
211
+ The scrub is a **global, consistent** pseudonymisation, not a field-by-field
212
+ blanking: `kv-payments` becomes `kv-1` everywhere at once, so the app setting that
213
+ references `kv-payments.vault.azure.net` still points at the same vault
214
+ afterwards. What survives on purpose: service domain suffixes (the rules read them
215
+ to decide what an edge means), built-in role GUIDs (public Azure constants) and
216
+ private IP ranges (that is the topology). What does not: names, resource groups,
217
+ subscription and principal GUIDs, e-mails, public IPs, and any
218
+ password / key / SAS fragment, which is redacted rather than renamed.
219
+
220
+ The mapping is never written to disk - it is the re-identification key. Counts are
221
+ printed, the mapping is not. **A scrubber is not a proof: read the file before you
222
+ commit it.** `--no-scrub` exists for local debugging and writes credentials to
223
+ disk; keep those files named `*.live.json` so `.gitignore` catches them.
224
+
225
+ ### Live Azure (opt-in)
226
+
227
+ ```
228
+ cloudmap trace my-app --live --allow-live
229
+
230
+ --single-sub query only the active subscription (default: every enabled
231
+ subscription in the tenant)
232
+ --enrich MODE which web apps to deep-enrich for the dependencies that only
233
+ exist in app config. auto (default) = the seed alone when the
234
+ seed is a web app, every app in scope when it is not;
235
+ all | seed | none
236
+ --resolve-secrets read KV secret values in-memory to see through KV-backed
237
+ connection strings (never printed or written)
238
+ --llm let a LOCAL model (ollama) propose extra edges, each
239
+ verified against scanned resources before it is trusted
240
+ ```
241
+
242
+ **Why `--enrich` matters.** A Key Vault reference or a connection string lives in an
243
+ app's settings, which Resource Graph does not return - so that edge exists only once
244
+ that app has been deep-enriched. Enriching only the seed would make the graph
245
+ asymmetric: tracing an app finds the vault it reads, but tracing the vault would never
246
+ find the app. Since "what breaks if I touch this" is usually asked about shared
247
+ infrastructure, `auto` enriches every app in scope whenever the seed is *not* an app.
248
+ Anything left un-enriched is reported as a **blind spot** on the map and repeated by
249
+ every `ask` answer drawn from it, so an empty result never passes for "nothing depends
250
+ on this".
251
+
252
+ Live mode is opt-in, not sandboxed: `--allow-live` is the deliberate switch, and
253
+ cloudmap reads whatever subscription `az` is pointed at. The read is read-only, but
254
+ it is a read of live infrastructure, so point it on purpose. As an optional guard
255
+ against a stale `az` context silently redirecting a scan, pin the subscription you
256
+ mean - if set, cloudmap refuses to run against any other active subscription:
257
+
258
+ ```
259
+ export CLOUDMAP_ALLOW_SUBSCRIPTION=<subscription-id> # optional
260
+ ```
261
+
262
+ If a live read fails (e.g. missing RBAC), cloudmap **reports the gap** instead of
263
+ silently dropping edges, and warns when a scan is truncated - so you know when the
264
+ picture is incomplete. Fixtures are always the default.
265
+ **Do not point this at data you are not authorized to read.**
266
+
267
+ ## Install
268
+
269
+ ```
270
+ git clone https://github.com/KatsaounisThanasis/cloudmap && cd cloudmap
271
+ pip install -e . # or just: python -m cloudmap trace ...
272
+ ```
273
+
274
+ No third-party dependencies - Python 3.9+ standard library only.
275
+
276
+ Supported today: App Service / Functions, Container Apps (+ environments), AKS,
277
+ App Gateway, API Management, Key Vault, Storage, SQL / PostgreSQL / MySQL / Cosmos,
278
+ Redis, Service Bus, Event Hub, Cognitive Search, Azure OpenAI, Container Registry,
279
+ Log Analytics, App Insights, VNets, Private Endpoints and managed identities.
280
+
281
+ ## Roadmap
282
+
283
+ - Slice 2: Terraform state ingestor + drift overlay (desired vs actual).
284
+ - Slice 3: AKS / Kubernetes workload correlation.
285
+ - Resource-group and application (tag-based) seeds.
286
+ - More edge extractors and Azure icon mappings (Container Apps still render as a
287
+ labelled box - an icon is only added once its azure2 asset path is verified).
288
+
289
+ ## License
290
+
291
+ MIT
@@ -0,0 +1,31 @@
1
+ cloudmap/__init__.py,sha256=bG7QoXvuxTK968OssC3_VJvinwGyKGNxX_5W5z8RuNA,103
2
+ cloudmap/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
3
+ cloudmap/cli.py,sha256=_KZ_I1a8WEEo5dNVLZSkALgmrgtpUgKNXzntb_unWYY,24699
4
+ cloudmap/graph.py,sha256=Q-oFvzUvISmmmzvO3VVGzxQZXs3WT2ILVwmM9Aptl4A,8850
5
+ cloudmap/interactive.py,sha256=zi6OEWdWltlro5vmxGPZ1hZVO4IkcWWMB_oT71h4Uhs,8562
6
+ cloudmap/local_model.py,sha256=h8-f_UJt7b-fNQScA5-GK9Ms0hnNyU1XEG__wiliL3Q,1988
7
+ cloudmap/model.py,sha256=aGH9XLiLzkOUd5VzB4VxXvG-xMW5__bV9bywQJykPe8,1856
8
+ cloudmap/scrub.py,sha256=HdCFN0sLqM_cwQrFRWK7B-yVoVejpMth4sR6FLoEAKs,13593
9
+ cloudmap/adapters/__init__.py,sha256=Z-1S89QVo8aWgo8nrzJ4Sej84T7XlzIpxv2O7ltZnTo,3185
10
+ cloudmap/ask/__init__.py,sha256=Jx45UovITygdgqrhEbqZpawpXFDPRy9hoFXLAwXM3so,5040
11
+ cloudmap/ask/intent.py,sha256=Z4ndkYnwGghSZYe0bUzVZr-WnKA-joZNNh5KLveJcrQ,5558
12
+ cloudmap/ask/narration.py,sha256=zm9xksPGAUNYwe4LfvpCt05GAxNbb1kwwjZ4Da0QiwM,1934
13
+ cloudmap/ask/queries.py,sha256=KGwaspXU1JIEAub6hw6V92iVa2Ko8BN4avA3soLEbns,12803
14
+ cloudmap/extract/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ cloudmap/extract/extractors.py,sha256=mc0Mkll9y4GURZlQy6_jw-TD5Dam3oc55kFmcUhZiUE,30598
16
+ cloudmap/extract/llm.py,sha256=I9g0OjYHHqLdqGBwr3PXlk0XpYekWIfgRLVr0vuapXE,4578
17
+ cloudmap/ingest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ cloudmap/ingest/azure.py,sha256=YOlezJVTU9bhOdazEenQvgabuKaVcPrw5UGimaT-mnc,17219
19
+ cloudmap/ingest/fixture.py,sha256=89wWoNgSyXN_eR81dqfD_3YNwuLLps_6ZRQEeqwwyTM,460
20
+ cloudmap/render/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ cloudmap/render/azure_icons.py,sha256=7h9ig2rAxC98BL9yyY6nxIJyCu7BVmD2Zz4Kd99I26I,72340
22
+ cloudmap/render/csv_export.py,sha256=vX3fkZGTeJunK6Stp5mJE2sY0mlG06ksd7sixHVJ_zg,1315
23
+ cloudmap/render/drawio.py,sha256=9MEGRjfI4whYXf71sEOixx7RfUBGPbVXrHRVQlNmH00,6192
24
+ cloudmap/render/html.py,sha256=ZaGLVHUdOBWnu-CvVhTxNKXM-XOSufD1TdeOcmUr7TU,28980
25
+ cloudmap/render/json_out.py,sha256=3WxetnmkjZoaeRIT1sAfGl-jduQsCJkclx5kGKmEiks,2103
26
+ cloudmap/render/mermaid.py,sha256=KtypnIITMu71ZE37iuT2GIigBTt8mxBv_dhBifZXOjY,1081
27
+ cloudmap-1.0.0rc1.dist-info/METADATA,sha256=8jG_bw7iVFf1blEMXkjyllfAgqbdiuQqBXo6VMymLhU,15565
28
+ cloudmap-1.0.0rc1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
29
+ cloudmap-1.0.0rc1.dist-info/entry_points.txt,sha256=ZUuIWqdDKuI5ByXzgguh-nfMG60JJw8eDQmCaGVq4YA,47
30
+ cloudmap-1.0.0rc1.dist-info/licenses/LICENSE,sha256=Wy9D6_gdsGVBxkAiEFWggtrs5LzTxr1wTtA_vg4VV4A,1076
31
+ cloudmap-1.0.0rc1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cloudmap = cloudmap.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thanasis Katsaounis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.