cloudmap 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudmap/__init__.py +3 -0
- cloudmap/__main__.py +6 -0
- cloudmap/adapters/__init__.py +91 -0
- cloudmap/ask/__init__.py +111 -0
- cloudmap/ask/intent.py +133 -0
- cloudmap/ask/narration.py +54 -0
- cloudmap/ask/queries.py +296 -0
- cloudmap/cli.py +534 -0
- cloudmap/extract/__init__.py +0 -0
- cloudmap/extract/extractors.py +653 -0
- cloudmap/extract/llm.py +89 -0
- cloudmap/graph.py +208 -0
- cloudmap/ingest/__init__.py +0 -0
- cloudmap/ingest/azure.py +380 -0
- cloudmap/ingest/fixture.py +15 -0
- cloudmap/interactive.py +227 -0
- cloudmap/local_model.py +49 -0
- cloudmap/model.py +43 -0
- cloudmap/render/__init__.py +0 -0
- cloudmap/render/azure_icons.py +72 -0
- cloudmap/render/csv_export.py +47 -0
- cloudmap/render/drawio.py +149 -0
- cloudmap/render/html.py +579 -0
- cloudmap/render/json_out.py +52 -0
- cloudmap/render/mermaid.py +25 -0
- cloudmap/scrub.py +300 -0
- cloudmap-1.0.0.dist-info/METADATA +340 -0
- cloudmap-1.0.0.dist-info/RECORD +31 -0
- cloudmap-1.0.0.dist-info/WHEEL +4 -0
- cloudmap-1.0.0.dist-info/entry_points.txt +2 -0
- cloudmap-1.0.0.dist-info/licenses/LICENSE +21 -0
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,340 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cloudmap
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Trace the blast radius of an Azure resource: one name in, a verified dependency graph out.
|
|
5
|
+
Project-URL: Homepage, https://github.com/KatsaounisThanasis/cloudmap
|
|
6
|
+
Project-URL: Repository, https://github.com/KatsaounisThanasis/cloudmap
|
|
7
|
+
Project-URL: Issues, https://github.com/KatsaounisThanasis/cloudmap/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/KatsaounisThanasis/cloudmap/releases
|
|
9
|
+
Author: Thanos Katsaounis
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: azure,azure-resource-graph,blast-radius,dependency-graph,devops,drawio,incident-response,mermaid,sre
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: System Administrators
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: System :: Monitoring
|
|
26
|
+
Classifier: Topic :: System :: Systems Administration
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Requires-Dist: questionary>=2.0.0
|
|
29
|
+
Requires-Dist: rich>=13.0.0
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# cloudmap
|
|
36
|
+
|
|
37
|
+
[](https://github.com/KatsaounisThanasis/cloudmap/actions/workflows/ci.yml)
|
|
38
|
+
[](https://pypi.org/project/cloudmap/)
|
|
39
|
+
[](https://pypi.org/project/cloudmap/)
|
|
40
|
+
[](LICENSE)
|
|
41
|
+
|
|
42
|
+
**Give it the name of one Azure resource. Get back its full dependency graph -
|
|
43
|
+
the blast radius - as an editable draw.io diagram with native Azure icons, plus
|
|
44
|
+
an interactive HTML viewer, Mermaid, JSON and CSV.**
|
|
45
|
+
|
|
46
|
+
Azure Resource Graph has no "dependencies" table. The relationships that matter
|
|
47
|
+
- what an App Service is hosted on, which Key Vault it reads, which subnet it
|
|
48
|
+
integrates with, which managed identity has which role where, what a Private
|
|
49
|
+
Endpoint fronts, which App Gateway routes to it - are buried inside each
|
|
50
|
+
resource's `properties`. cloudmap reads them out, correlates them into one
|
|
51
|
+
graph, and draws it.
|
|
52
|
+
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+
**Contents** · [Install](#install) · [60-second demo](#60-second-demo) ·
|
|
56
|
+
[Interactive wizard](#interactive-wizard) · [Why](#why) ·
|
|
57
|
+
[What it maps](#what-it-maps) · [How it works](#how-it-works) ·
|
|
58
|
+
[Ask a map questions](#ask-a-map-questions) · [Live Azure](#live-azure-opt-in) ·
|
|
59
|
+
[Capture and scrub](#capture-a-real-export-so-the-tests-can-be-wrong) ·
|
|
60
|
+
[Usage reference](#usage-reference)
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
pip install cloudmap
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Python 3.9+. Two runtime dependencies (`rich` and `questionary`, both for the
|
|
69
|
+
terminal UI). Live mode additionally needs the [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli)
|
|
70
|
+
on your PATH, and the optional AI passes need a local [ollama](https://ollama.com).
|
|
71
|
+
|
|
72
|
+
To work on it instead:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
git clone https://github.com/KatsaounisThanasis/cloudmap && cd cloudmap
|
|
76
|
+
pip install -e ".[dev]" && pytest
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 60-second demo
|
|
80
|
+
|
|
81
|
+
No Azure account needed - the repo ships a synthetic estate:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
cloudmap trace contoso-web --from fixtures/contoso.json -o contoso-web.drawio
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
Blast radius: 9 resources (0 external), 9 dependencies
|
|
89
|
+
╭────────────────────────────── Dependency Graph ──────────────────────────────╮
|
|
90
|
+
│ 🌐 contoso-web │
|
|
91
|
+
│ ├── --hosted-on--> 📦 App Service Plan │
|
|
92
|
+
│ ├── --vnet-integration--> 📦 Virtual Network │
|
|
93
|
+
│ ├── --connects-to--> 🗄️ SQL Server │
|
|
94
|
+
│ ├── --sends-telemetry--> 📦 App Insights │
|
|
95
|
+
│ │ └── --uses-workspace--> 📦 Log Analytics │
|
|
96
|
+
│ ├── --reads-secret; role: Key Vault Secrets User--> 🔐 Key Vault │
|
|
97
|
+
│ └── --connects-to--> 📦 Storage │
|
|
98
|
+
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
99
|
+
🔗 draw.io: contoso-web.drawio
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
On a real estate it goes several layers deep:
|
|
103
|
+
|
|
104
|
+
```text
|
|
105
|
+
Blast radius: 15 resources (0 external), 14 dependencies
|
|
106
|
+
╭────────────────────────────── Dependency Graph ──────────────────────────────╮
|
|
107
|
+
│ 🌐 app-spa-frontend │
|
|
108
|
+
│ ├── --calls--> 🌐 app-auth-service │
|
|
109
|
+
│ │ ├── --reads-secret--> 🔐 kv-core-prod │
|
|
110
|
+
│ │ └── --connects-to--> 🗄️ cosmos-auth │
|
|
111
|
+
│ └── --calls--> 🌐 app-api-gateway │
|
|
112
|
+
│ ├── --calls--> 📦 capp-payment-service │
|
|
113
|
+
│ │ └── --reads-secret--> 🔐 kv-payments-prod │
|
|
114
|
+
│ ├── --calls--> 🌐 app-inventory-api │
|
|
115
|
+
│ │ ├── --connects-to--> 🗄️ pg-inventory-prod │
|
|
116
|
+
│ │ └── --connects-to--> 📦 stinventoryprod │
|
|
117
|
+
│ ├── --connects-to--> 🗄️ redis-gateway │
|
|
118
|
+
│ └── --calls--> 🌐 app-orders-api │
|
|
119
|
+
│ ├── --connects-to--> 🗄️ redis-orders │
|
|
120
|
+
│ ├── --connects-to--> 📦 sb-enterprise │
|
|
121
|
+
│ └── --connects-to--> 🗄️ sql-orders-prod │
|
|
122
|
+
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
(That is the default **high-level** view - resources grouped by type. Add
|
|
126
|
+
`--level detail` to see every instance with its real name.)
|
|
127
|
+
|
|
128
|
+
### Output formats
|
|
129
|
+
|
|
130
|
+
| Flag | You get |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `-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 |
|
|
133
|
+
| `--html FILE` | **Interactive viewer**: one self-contained file, no server and no CDN. Dark mode, edges colour-coded by relationship type (security / data / network), resource-group filters and search, SVG + PNG export, and direct links into the Azure portal |
|
|
134
|
+
| `--mermaid FILE` | Mermaid source, for embedding in Markdown docs |
|
|
135
|
+
| `--json FILE` | The graph itself - this is what `cloudmap ask` reads |
|
|
136
|
+
| `--csv FILE` | Flat edge list with evidence, for a spreadsheet or an auditor |
|
|
137
|
+
|
|
138
|
+
## Interactive wizard
|
|
139
|
+
|
|
140
|
+
Run it with no arguments and it walks you through the whole thing:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
cloudmap
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It asks for a subscription (yours is marked as the default), then a resource
|
|
147
|
+
group, then the resource to trace, then how deeply to enrich, and where to write
|
|
148
|
+
the results. It reads live Azure, so `az login` first.
|
|
149
|
+
|
|
150
|
+
## Why
|
|
151
|
+
|
|
152
|
+
- **Live-cloud tools upload your data.** cloudmap runs locally and reads only
|
|
153
|
+
what you point it at. Nothing leaves your machine.
|
|
154
|
+
- **Existing OSS is siloed** - Terraform-only or Kubernetes-only. cloudmap works
|
|
155
|
+
from Azure's own inventory (Resource Graph) and correlates across services.
|
|
156
|
+
- **Impact analysis, onboarding, change reviews.** "What breaks if I touch this?"
|
|
157
|
+
in one diagram instead of ten portal blades.
|
|
158
|
+
|
|
159
|
+
### Three things people use it for
|
|
160
|
+
|
|
161
|
+
**Is this safe to delete?** An Azure SQL database looks orphaned in the portal and
|
|
162
|
+
someone wants it gone to save the monthly bill. `cloudmap trace sql-orders-dev
|
|
163
|
+
--direction up` deep-enriches the connection strings of the web apps in the
|
|
164
|
+
subscription and shows what still points at it - including, occasionally, a
|
|
165
|
+
production app that was never supposed to.
|
|
166
|
+
|
|
167
|
+
**What is actually broken?** An AKS cluster starts failing at 3am. Tracing it
|
|
168
|
+
produces a dependency graph with the Key Vault it reads, and the map carries the
|
|
169
|
+
evidence for that edge ("found in Kubernetes secret X"), so the next question -
|
|
170
|
+
did anything change on that vault - has a place to start.
|
|
171
|
+
|
|
172
|
+
**Who has access to this?** An auditor asks which systems can reach the storage
|
|
173
|
+
account holding customer data. `cloudmap trace pii-storage --direction up --csv
|
|
174
|
+
pii-audit.csv` hands back a spreadsheet of the web apps and clusters with managed
|
|
175
|
+
identity RBAC on it, with the role assignments as proof.
|
|
176
|
+
|
|
177
|
+
## What it maps
|
|
178
|
+
|
|
179
|
+
App Service / Functions, Container Apps (+ environments), AKS, App Gateway, API
|
|
180
|
+
Management, Key Vault, Storage, SQL / PostgreSQL / MySQL / Cosmos, Redis, Service
|
|
181
|
+
Bus, Event Hub, Cognitive Search, Azure OpenAI, Container Registry, Log Analytics,
|
|
182
|
+
App Insights, VNets, Private Endpoints and managed identities.
|
|
183
|
+
|
|
184
|
+
## How it works
|
|
185
|
+
|
|
186
|
+
1. **Ingest** - a JSON fixture (default) or live `az graph query` (opt-in, guarded).
|
|
187
|
+
2. **Extract** - per-type rules turn `properties` into typed edges
|
|
188
|
+
(`hosted-on`, `reads-secret`, `private-link-to`, `role: ...`, `routes-to`, ...).
|
|
189
|
+
3. **Blast radius** - walk the graph from your seed with *direction consistency*:
|
|
190
|
+
from the seed it goes both ways (what it depends on **and** what depends on
|
|
191
|
+
it), but once it steps in one direction it never reverses. That single rule
|
|
192
|
+
keeps a shared resource (App Service Plan, VNet, Key Vault) from bridging your
|
|
193
|
+
seed to unrelated apps sitting on the same thing.
|
|
194
|
+
4. **Render** - draw.io (Azure icons) + Mermaid + JSON + CSV + a self-contained HTML viewer.
|
|
195
|
+
5. **Ask** - query the saved map in plain language (`cloudmap ask`); the answers are
|
|
196
|
+
computed from the graph, and a local model may only route the question or narrate
|
|
197
|
+
the result.
|
|
198
|
+
|
|
199
|
+
## Ask a map questions
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
cloudmap ask <map.json> "<question>"
|
|
203
|
+
|
|
204
|
+
--explain also narrate the answer with a LOCAL model (ollama)
|
|
205
|
+
--llm if no built-in rule understands the phrasing, let a LOCAL model
|
|
206
|
+
pick the query (its choice is validated against the map)
|
|
207
|
+
--max-hops N limit traversal depth
|
|
208
|
+
--json print the answer as JSON (for scripting)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Instance names only exist in a `--level detail` map; the default high-level map
|
|
212
|
+
groups by type, so ask it about a group (`"what breaks if I touch Key Vault"`) or
|
|
213
|
+
trace with `--level detail` first:
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
$ cloudmap trace contoso-web --from fixtures/contoso.json --level detail --json out.json
|
|
217
|
+
$ cloudmap ask out.json "what breaks if I touch contoso-kv"
|
|
218
|
+
Query: impact · subject: contoso-kv
|
|
219
|
+
2 resource(s) depend on contoso-kv - changing it can break them.
|
|
220
|
+
|
|
221
|
+
contoso-web (Web App) 1 hop(s)
|
|
222
|
+
contoso-web --reads-secret; role: Key Vault Secrets User--> contoso-kv
|
|
223
|
+
proof: app config references host contoso-kv.vault.azure.net; Key Vault
|
|
224
|
+
reference to vault contoso-kv; RBAC role assignment
|
|
225
|
+
contoso-agw (App Gateway) 2 hop(s)
|
|
226
|
+
...
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Questions it answers: what breaks if I touch X · what does X depend on · how does
|
|
230
|
+
X reach Y · what is shared in this map · what should I not trust · explain this map.
|
|
231
|
+
|
|
232
|
+
**The answers are computed, not generated.** "What breaks if I touch X" is a graph
|
|
233
|
+
traversal, so that is how it is answered - the numbers and names come from the
|
|
234
|
+
edges. A local model is optional and can only do two things: pick which query an
|
|
235
|
+
unusual phrasing meant (`--llm`, and its pick is validated against the map), or put
|
|
236
|
+
the already-computed facts into prose (`--explain`). It never supplies a fact, so it
|
|
237
|
+
cannot promote a guess to one. Every finding shows the hops behind it and the proof
|
|
238
|
+
of each hop, and a finding that leans on a model-proposed edge is marked `[GUESS]`.
|
|
239
|
+
If the map itself says it is incomplete, every answer from it repeats that warning.
|
|
240
|
+
|
|
241
|
+
## Live Azure (opt-in)
|
|
242
|
+
|
|
243
|
+
```
|
|
244
|
+
cloudmap trace my-app --live --allow-live
|
|
245
|
+
|
|
246
|
+
--single-sub query only the active subscription (default: every enabled
|
|
247
|
+
subscription in the tenant)
|
|
248
|
+
--enrich MODE which web apps to deep-enrich for the dependencies that only
|
|
249
|
+
exist in app config. auto (default) = the seed alone when the
|
|
250
|
+
seed is a web app, every app in scope when it is not;
|
|
251
|
+
all | seed | none
|
|
252
|
+
--resolve-secrets read KV secret values in-memory to see through KV-backed
|
|
253
|
+
connection strings (never printed or written)
|
|
254
|
+
--llm let a LOCAL model (ollama) propose extra edges, each
|
|
255
|
+
verified against scanned resources before it is trusted
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**Why `--enrich` matters.** A Key Vault reference or a connection string lives in an
|
|
259
|
+
app's settings, which Resource Graph does not return - so that edge exists only once
|
|
260
|
+
that app has been deep-enriched. Enriching only the seed would make the graph
|
|
261
|
+
asymmetric: tracing an app finds the vault it reads, but tracing the vault would never
|
|
262
|
+
find the app. Since "what breaks if I touch this" is usually asked about shared
|
|
263
|
+
infrastructure, `auto` enriches every app in scope whenever the seed is *not* an app.
|
|
264
|
+
Anything left un-enriched is reported as a **blind spot** on the map and repeated by
|
|
265
|
+
every `ask` answer drawn from it, so an empty result never passes for "nothing depends
|
|
266
|
+
on this".
|
|
267
|
+
|
|
268
|
+
Live mode is opt-in, not sandboxed: `--allow-live` is the deliberate switch, and
|
|
269
|
+
cloudmap reads whatever subscription `az` is pointed at. The read is read-only, but
|
|
270
|
+
it is a read of live infrastructure, so point it on purpose. As an optional guard
|
|
271
|
+
against a stale `az` context silently redirecting a scan, pin the subscription you
|
|
272
|
+
mean - if set, cloudmap refuses to run against any other active subscription:
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
export CLOUDMAP_ALLOW_SUBSCRIPTION=<subscription-id> # optional
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
If a live read fails (e.g. missing RBAC), cloudmap **reports the gap** instead of
|
|
279
|
+
silently dropping edges, and warns when a scan is truncated - so you know when the
|
|
280
|
+
picture is incomplete. Fixtures are always the default.
|
|
281
|
+
**Do not point this at data you are not authorized to read.**
|
|
282
|
+
|
|
283
|
+
## Capture a real export (so the tests can be wrong)
|
|
284
|
+
|
|
285
|
+
A fixture you wrote yourself can only confirm what you already believe. `capture`
|
|
286
|
+
saves what Azure actually returned, scrubs it, and gives you something that can
|
|
287
|
+
contradict the extractors.
|
|
288
|
+
|
|
289
|
+
```
|
|
290
|
+
cloudmap capture --allow-live --single-sub -o fixtures/captured_real.json
|
|
291
|
+
cloudmap scrub raw-export.json -o fixtures/captured_real.json # for a file you already have
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
The scrub is a **global, consistent** pseudonymisation, not a field-by-field
|
|
295
|
+
blanking: `kv-payments` becomes `kv-1` everywhere at once, so the app setting that
|
|
296
|
+
references `kv-payments.vault.azure.net` still points at the same vault
|
|
297
|
+
afterwards. What survives on purpose: service domain suffixes (the rules read them
|
|
298
|
+
to decide what an edge means), built-in role GUIDs (public Azure constants) and
|
|
299
|
+
private IP ranges (that is the topology). What does not: names, resource groups,
|
|
300
|
+
subscription and principal GUIDs, e-mails, public IPs, and any
|
|
301
|
+
password / key / SAS fragment, which is redacted rather than renamed.
|
|
302
|
+
|
|
303
|
+
The mapping is never written to disk - it is the re-identification key. Counts are
|
|
304
|
+
printed, the mapping is not. **A scrubber is not a proof: read the file before you
|
|
305
|
+
commit it.** `--no-scrub` exists for local debugging and writes credentials to
|
|
306
|
+
disk; keep those files named `*.live.json` so `.gitignore` catches them.
|
|
307
|
+
|
|
308
|
+
## Usage reference
|
|
309
|
+
|
|
310
|
+
```
|
|
311
|
+
cloudmap trace <name> (--from <fixture.json> | --live) [options]
|
|
312
|
+
|
|
313
|
+
--level high|detail high = architecture view grouped by type (default);
|
|
314
|
+
one box per type, so instance names are not in the map
|
|
315
|
+
detail = every instance with its real name
|
|
316
|
+
--direction both|down|up both = full blast radius (default)
|
|
317
|
+
down = only what it depends on
|
|
318
|
+
up = only what depends on it
|
|
319
|
+
--max-hops N limit traversal depth
|
|
320
|
+
-o FILE draw.io output (default: <name>.blast.drawio)
|
|
321
|
+
--mermaid FILE also write Mermaid
|
|
322
|
+
--json FILE also write the graph as JSON
|
|
323
|
+
--html FILE also write a self-contained interactive HTML viewer
|
|
324
|
+
--csv FILE also write the edge list as CSV
|
|
325
|
+
-d, --out-dir DIR write every artifact into DIR, named after the seed
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Other subcommands: `cloudmap capture`, `cloudmap scrub`, `cloudmap ask` (see above).
|
|
329
|
+
|
|
330
|
+
## Roadmap
|
|
331
|
+
|
|
332
|
+
- Terraform state ingestor + drift overlay (desired vs actual).
|
|
333
|
+
- Deeper AKS / Kubernetes workload correlation.
|
|
334
|
+
- Resource-group and application (tag-based) seeds.
|
|
335
|
+
- More edge extractors and Azure icon mappings (Container Apps still render as a
|
|
336
|
+
labelled box - an icon is only added once its azure2 asset path is verified).
|
|
337
|
+
|
|
338
|
+
## License
|
|
339
|
+
|
|
340
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
cloudmap/__init__.py,sha256=69NbQSVnApWgYso7YuGNb7hhUNhl1mMgO71Ah-kUr9I,103
|
|
2
|
+
cloudmap/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
|
|
3
|
+
cloudmap/cli.py,sha256=0S4axVH3RT4TtkOKgl5uDtbl8kg8ikY-QhMx02oNyZk,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=v5GnfFK1Z4pTZ-YRP4FEluPqiVVFlBz_FTXp3xAhODk,30599
|
|
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.0.dist-info/METADATA,sha256=zCie97x2f9DED5pLHZAK-0TXzZ2RXjyQa-UIxKG5fDI,17942
|
|
28
|
+
cloudmap-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
29
|
+
cloudmap-1.0.0.dist-info/entry_points.txt,sha256=ZUuIWqdDKuI5ByXzgguh-nfMG60JJw8eDQmCaGVq4YA,47
|
|
30
|
+
cloudmap-1.0.0.dist-info/licenses/LICENSE,sha256=Wy9D6_gdsGVBxkAiEFWggtrs5LzTxr1wTtA_vg4VV4A,1076
|
|
31
|
+
cloudmap-1.0.0.dist-info/RECORD,,
|