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/cli.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""cloudmap command-line interface."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .graph import blast_radius, build_graph, collapse_high_level, find_seeds, node_from_id
|
|
8
|
+
from .model import Edge
|
|
9
|
+
from .render.drawio import to_drawio
|
|
10
|
+
from .render.html import to_html
|
|
11
|
+
from .render.json_out import to_json
|
|
12
|
+
from .render.mermaid import to_mermaid
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv=None):
|
|
16
|
+
# Interactive mode if no arguments are provided
|
|
17
|
+
if (argv is None and len(sys.argv) == 1) or (argv is not None and len(argv) == 0):
|
|
18
|
+
from .interactive import interactive_main
|
|
19
|
+
return interactive_main()
|
|
20
|
+
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="cloudmap",
|
|
23
|
+
description="Trace an Azure resource's full dependency graph (blast radius) "
|
|
24
|
+
"and export it to draw.io / Mermaid / JSON.",
|
|
25
|
+
)
|
|
26
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
27
|
+
|
|
28
|
+
t = sub.add_parser("trace", help="trace a component's blast radius")
|
|
29
|
+
t.add_argument("name", help="resource name (or substring) to seed from")
|
|
30
|
+
src = t.add_mutually_exclusive_group(required=True)
|
|
31
|
+
src.add_argument("--from", dest="fixture", help="path to a Resource Graph JSON fixture")
|
|
32
|
+
src.add_argument("--live", action="store_true", help="query live Azure (guarded, opt-in)")
|
|
33
|
+
t.add_argument("--allow-live", action="store_true", help="required together with --live")
|
|
34
|
+
t.add_argument("--single-sub", action="store_true",
|
|
35
|
+
help="live: query only the active subscription "
|
|
36
|
+
"(default: every enabled subscription in the tenant)")
|
|
37
|
+
t.add_argument("--resolve-secrets", action="store_true",
|
|
38
|
+
help="live: read KV secret values in-memory to see through KV-backed "
|
|
39
|
+
"connection strings (never printed or written)")
|
|
40
|
+
t.add_argument("--llm", action="store_true",
|
|
41
|
+
help="also let a LOCAL model (ollama) propose edges from the seed's JSON; "
|
|
42
|
+
"each proposal is verified against scanned resources (nothing leaves the machine)")
|
|
43
|
+
t.add_argument("--enrich", choices=["auto", "seed", "all", "none"], default="auto",
|
|
44
|
+
help="live: which web apps to deep-enrich for the dependencies that live "
|
|
45
|
+
"in app config (Key Vault refs, connection strings, RBAC). "
|
|
46
|
+
"auto = the seed alone when the seed is a web app, every app in scope "
|
|
47
|
+
"when it is not (only other apps' config can reveal what depends on a "
|
|
48
|
+
"shared resource); all = every app in scope; none = ARM topology only")
|
|
49
|
+
t.add_argument("--level", choices=["high", "detail"], default="high",
|
|
50
|
+
help="high = architecture view grouped by resource type (default); "
|
|
51
|
+
"detail = every instance with its real name")
|
|
52
|
+
t.add_argument("--direction", choices=["both", "down", "up"], default="both",
|
|
53
|
+
help="both = full blast radius (default); down = dependencies; up = dependents")
|
|
54
|
+
t.add_argument("--max-hops", type=int, default=None, help="limit traversal depth")
|
|
55
|
+
t.add_argument("-o", "--out", default=None, help="draw.io output file")
|
|
56
|
+
t.add_argument("--mermaid", default=None, help="also write a Mermaid file")
|
|
57
|
+
t.add_argument("--json", dest="json_out", default=None, help="also write the graph JSON")
|
|
58
|
+
t.add_argument("--html", dest="html_out", default=None,
|
|
59
|
+
help="also write a self-contained interactive HTML viewer (open in a browser)")
|
|
60
|
+
t.add_argument("--csv", dest="csv_out", default=None, help="also write the graph as CSV")
|
|
61
|
+
t.add_argument("-d", "--out-dir", dest="out_dir", default=None,
|
|
62
|
+
help="write ALL formats into this directory, under a folder named after the resource")
|
|
63
|
+
|
|
64
|
+
c = sub.add_parser("capture", help="save a raw live export so it can become a fixture")
|
|
65
|
+
c.add_argument("-o", "--out", required=True, help="where to write the export")
|
|
66
|
+
c.add_argument("--allow-live", action="store_true", help="required: this reads live Azure")
|
|
67
|
+
c.add_argument("--single-sub", action="store_true",
|
|
68
|
+
help="capture only the active subscription "
|
|
69
|
+
"(default: every enabled subscription in the tenant)")
|
|
70
|
+
c.add_argument("--enrich", choices=["all", "none"], default="all",
|
|
71
|
+
help="all (default) also captures web app config, which is where the "
|
|
72
|
+
"interesting dependencies live; none captures ARM topology only")
|
|
73
|
+
c.add_argument("--resolve-secrets", action="store_true",
|
|
74
|
+
help="resolve Key Vault references while capturing (values are redacted "
|
|
75
|
+
"by the scrub pass, but see --no-scrub)")
|
|
76
|
+
c.add_argument("--no-scrub", action="store_true",
|
|
77
|
+
help="write the export UNSCRUBBED - real names, hosts and app settings, "
|
|
78
|
+
"i.e. credentials on disk. Never commit that file")
|
|
79
|
+
|
|
80
|
+
s = sub.add_parser("scrub", help="pseudonymise a raw export so it can be committed")
|
|
81
|
+
s.add_argument("input", help="a raw export (from `capture --no-scrub` or `az graph query`)")
|
|
82
|
+
s.add_argument("-o", "--out", required=True, help="where to write the scrubbed export")
|
|
83
|
+
|
|
84
|
+
a = sub.add_parser("ask", help="ask a question about a map you already produced")
|
|
85
|
+
a.add_argument("map", help="a cloudmap graph JSON (written by `trace --json`) or a raw export")
|
|
86
|
+
a.add_argument("question", help='e.g. "what breaks if I touch kv-orders-dev"')
|
|
87
|
+
a.add_argument("--explain", action="store_true",
|
|
88
|
+
help="also narrate the answer with a LOCAL model (ollama); the computed "
|
|
89
|
+
"answer is printed either way")
|
|
90
|
+
a.add_argument("--llm", action="store_true",
|
|
91
|
+
help="if no built-in rule understands the question, let a LOCAL model pick "
|
|
92
|
+
"the query (its choice is validated against the map)")
|
|
93
|
+
a.add_argument("--max-hops", type=int, default=None, help="limit traversal depth")
|
|
94
|
+
a.add_argument("--json", dest="json_out", action="store_true",
|
|
95
|
+
help="print the answer as JSON instead of text (for scripting)")
|
|
96
|
+
|
|
97
|
+
args = parser.parse_args(argv)
|
|
98
|
+
if args.cmd == "trace":
|
|
99
|
+
return _cmd_trace(args)
|
|
100
|
+
if args.cmd == "capture":
|
|
101
|
+
return _cmd_capture(args)
|
|
102
|
+
if args.cmd == "scrub":
|
|
103
|
+
return _cmd_scrub(args)
|
|
104
|
+
if args.cmd == "ask":
|
|
105
|
+
return _cmd_ask(args)
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _cmd_trace(args):
|
|
110
|
+
read_gaps, blind_spots = [], []
|
|
111
|
+
if args.live:
|
|
112
|
+
from .ingest.azure import query_live
|
|
113
|
+
resources, truncated = query_live(allow_live=args.allow_live, tenant_wide=not args.single_sub)
|
|
114
|
+
graph = build_graph(resources)
|
|
115
|
+
else:
|
|
116
|
+
from .adapters import load_graph
|
|
117
|
+
resources, truncated = [], False
|
|
118
|
+
graph = load_graph(args.fixture) # auto-detects raw export vs neutral cloudmap graph
|
|
119
|
+
|
|
120
|
+
seeds = find_seeds(graph, args.name)
|
|
121
|
+
if not seeds:
|
|
122
|
+
print(f"No resource matched '{args.name}'.", file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
if len(seeds) > 1:
|
|
125
|
+
print(f"'{args.name}' is ambiguous. Matches:", file=sys.stderr)
|
|
126
|
+
for s in seeds:
|
|
127
|
+
print(f" - {graph.nodes[s].name} ({graph.nodes[s].type})", file=sys.stderr)
|
|
128
|
+
return 2
|
|
129
|
+
seed = seeds[0]
|
|
130
|
+
|
|
131
|
+
# Live: Resource Graph omits config/RBAC/diagnostics, so deep-enrich web apps
|
|
132
|
+
# and re-extract, then report whatever stayed invisible.
|
|
133
|
+
if args.live:
|
|
134
|
+
graph, read_gaps, blind_spots = _enrich_live(args, graph, seed, resources)
|
|
135
|
+
|
|
136
|
+
sub = blast_radius(graph, seed, direction=args.direction, max_hops=args.max_hops)
|
|
137
|
+
|
|
138
|
+
# LLM-assisted extraction (Phase 3): local model proposes edges for resources
|
|
139
|
+
# with no hand-written rules. It runs on the initial deterministic blast radius
|
|
140
|
+
# to find hidden links, verifies them against the tenant, and re-computes the radius.
|
|
141
|
+
if args.llm:
|
|
142
|
+
from rich.console import Console
|
|
143
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
144
|
+
console = Console(stderr=True)
|
|
145
|
+
console.print("[bold yellow]Running local model (ollama) on blast radius nodes to propose hidden edges...[/bold yellow]")
|
|
146
|
+
from .extract.extractors import Resolver, merge_model_edges
|
|
147
|
+
from .extract.llm import llm_edges_for_seed
|
|
148
|
+
|
|
149
|
+
resolver = Resolver(graph.nodes)
|
|
150
|
+
ledges = []
|
|
151
|
+
|
|
152
|
+
with Progress(
|
|
153
|
+
SpinnerColumn(),
|
|
154
|
+
TextColumn("[progress.description]{task.description}"),
|
|
155
|
+
console=console
|
|
156
|
+
) as progress:
|
|
157
|
+
task = progress.add_task(f"Asking AI about {len(sub.nodes)} resources...", total=len(sub.nodes))
|
|
158
|
+
for n_id in list(sub.nodes.keys()):
|
|
159
|
+
node_name = graph.nodes[n_id].name
|
|
160
|
+
progress.update(task, description=f"AI analyzing: [cyan]{node_name}[/cyan] ({graph.nodes[n_id].type})")
|
|
161
|
+
lext, ledges_n = llm_edges_for_seed(graph.nodes[n_id], resolver)
|
|
162
|
+
for nd in lext:
|
|
163
|
+
graph.nodes.setdefault(nd.id, nd)
|
|
164
|
+
ledges.extend(ledges_n)
|
|
165
|
+
progress.advance(task)
|
|
166
|
+
|
|
167
|
+
# model edges may only add new targets, never override deterministic ones
|
|
168
|
+
graph.edges = merge_model_edges(graph.edges, ledges)
|
|
169
|
+
|
|
170
|
+
# Re-compute blast radius now that we have LLM-proposed edges
|
|
171
|
+
sub = blast_radius(graph, seed, direction=args.direction, max_hops=args.max_hops)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if args.level == "high":
|
|
175
|
+
sub = collapse_high_level(sub, seed)
|
|
176
|
+
|
|
177
|
+
meta = {"truncated": truncated, "read_gaps": read_gaps, "blind_spots": blind_spots}
|
|
178
|
+
|
|
179
|
+
_export_outputs(sub, seed, args, meta)
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _export_outputs(sub, seed, args, meta):
|
|
184
|
+
name = sub.nodes[seed].name
|
|
185
|
+
if getattr(args, "out_dir", None):
|
|
186
|
+
target_dir = os.path.join(args.out_dir, name)
|
|
187
|
+
args.out = args.out or os.path.join(target_dir, f"{name}.drawio")
|
|
188
|
+
args.mermaid = args.mermaid or os.path.join(target_dir, f"{name}.mmd")
|
|
189
|
+
args.json_out = args.json_out or os.path.join(target_dir, f"{name}.json")
|
|
190
|
+
args.html_out = args.html_out or os.path.join(target_dir, f"{name}.html")
|
|
191
|
+
args.csv_out = args.csv_out or os.path.join(target_dir, f"{name}.csv")
|
|
192
|
+
|
|
193
|
+
out = args.out or f"{name}.blast.drawio"
|
|
194
|
+
_ensure_parent(out)
|
|
195
|
+
with open(out, "w", encoding="utf-8") as f:
|
|
196
|
+
f.write(to_drawio(sub, seed))
|
|
197
|
+
if args.mermaid:
|
|
198
|
+
_ensure_parent(args.mermaid)
|
|
199
|
+
with open(args.mermaid, "w", encoding="utf-8") as f:
|
|
200
|
+
f.write(to_mermaid(sub, seed))
|
|
201
|
+
if args.json_out:
|
|
202
|
+
_ensure_parent(args.json_out)
|
|
203
|
+
with open(args.json_out, "w", encoding="utf-8") as f:
|
|
204
|
+
f.write(to_json(sub, seed, meta=meta))
|
|
205
|
+
if args.html_out:
|
|
206
|
+
_ensure_parent(args.html_out)
|
|
207
|
+
with open(args.html_out, "w", encoding="utf-8") as f:
|
|
208
|
+
f.write(to_html(sub, seed, meta=meta))
|
|
209
|
+
if args.csv_out:
|
|
210
|
+
_ensure_parent(args.csv_out)
|
|
211
|
+
from .render.csv_export import to_csv
|
|
212
|
+
with open(args.csv_out, "w", encoding="utf-8") as f:
|
|
213
|
+
f.write(to_csv(sub, seed, meta=meta))
|
|
214
|
+
|
|
215
|
+
_print_summary(sub, seed, out, truncated=meta.get("truncated", False), blind_spots=meta.get("blind_spots", []))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
_WEBAPP = "microsoft.web/sites"
|
|
219
|
+
_AKS = "microsoft.containerservice/managedclusters"
|
|
220
|
+
# Workloads whose dependencies live in free-text config, so an unresolved
|
|
221
|
+
# reference is worth resurfacing as an explicit external node. Container apps are
|
|
222
|
+
# here but not in the enrichment list: Resource Graph already returns their
|
|
223
|
+
# template, so there is nothing extra to fetch.
|
|
224
|
+
_CONFIG_WORKLOADS = (_WEBAPP, "microsoft.app/containerapps", _AKS)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _enrichment_targets(graph, seed, mode, direction):
|
|
228
|
+
"""Which workloads to deep-enrich, and which stay a blind spot.
|
|
229
|
+
|
|
230
|
+
An app's config-level dependencies exist nowhere until that app is enriched,
|
|
231
|
+
but enriching a whole tenant on every trace costs an `az` round-trip per app.
|
|
232
|
+
So `auto` spends the calls where the answer actually needs them: a web-app
|
|
233
|
+
seed's own config already yields its downward view, whereas a shared-resource
|
|
234
|
+
seed (Key Vault, database, plan) can only learn its dependents from the
|
|
235
|
+
config of the apps pointing at it.
|
|
236
|
+
"""
|
|
237
|
+
workloads = [n for n in graph.nodes.values() if n.type in (_WEBAPP, _AKS)]
|
|
238
|
+
seed_only = [n for n in workloads if n.id == seed]
|
|
239
|
+
|
|
240
|
+
if mode == "none":
|
|
241
|
+
chosen = []
|
|
242
|
+
elif mode == "all":
|
|
243
|
+
chosen = workloads
|
|
244
|
+
elif mode == "seed" or seed_only and direction != "up":
|
|
245
|
+
chosen = seed_only
|
|
246
|
+
else:
|
|
247
|
+
chosen = workloads
|
|
248
|
+
|
|
249
|
+
picked = {n.id for n in chosen}
|
|
250
|
+
return chosen, [n for n in workloads if n.id not in picked]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _enrich_live(args, graph, seed, resources):
|
|
254
|
+
"""Deep-enrich workloads, re-extract, and name what stayed invisible.
|
|
255
|
+
Returns (graph, read_gaps, blind_spots) - `resources` is extended in place."""
|
|
256
|
+
from .extract.extractors import Resolver, _dedupe, seed_external_dependencies
|
|
257
|
+
from .ingest.azure import enrich_aks_clusters, enrich_webapps
|
|
258
|
+
|
|
259
|
+
targets, skipped = _enrichment_targets(graph, seed, args.enrich, args.direction)
|
|
260
|
+
read_gaps, blind_spots = [], []
|
|
261
|
+
|
|
262
|
+
if targets:
|
|
263
|
+
apps = [n for n in targets if n.type == _WEBAPP]
|
|
264
|
+
akss = [n for n in targets if n.type == _AKS]
|
|
265
|
+
|
|
266
|
+
diagnostics = []
|
|
267
|
+
if apps:
|
|
268
|
+
print(f"Deep-enriching {len(apps)} web app(s) - app settings, connection "
|
|
269
|
+
f"strings, RBAC, diagnostics...", file=sys.stderr)
|
|
270
|
+
enr = enrich_webapps([n.raw for n in apps], resolve_secrets=args.resolve_secrets)
|
|
271
|
+
read_gaps.extend(enr["errors"])
|
|
272
|
+
resources.extend(enr["role_assignments"])
|
|
273
|
+
diagnostics.extend(enr["diagnostics"])
|
|
274
|
+
|
|
275
|
+
if akss:
|
|
276
|
+
print(f"Deep-enriching {len(akss)} AKS cluster(s) - reading Kubernetes manifests...", file=sys.stderr)
|
|
277
|
+
enr_aks = enrich_aks_clusters([n.raw for n in akss], resolve_secrets=args.resolve_secrets)
|
|
278
|
+
read_gaps.extend(enr_aks["errors"])
|
|
279
|
+
|
|
280
|
+
if read_gaps:
|
|
281
|
+
print("Read gaps while enriching (edges below may be INCOMPLETE):", file=sys.stderr)
|
|
282
|
+
for msg in read_gaps:
|
|
283
|
+
print(f" ! could not read {msg}", file=sys.stderr)
|
|
284
|
+
|
|
285
|
+
graph = build_graph(resources) # re-extract, now that config is present
|
|
286
|
+
resolver = Resolver(graph.nodes)
|
|
287
|
+
|
|
288
|
+
# Unresolved references are resurfaced as external nodes for the seed only:
|
|
289
|
+
# doing it for every enriched app would bury the map in tenant-wide noise.
|
|
290
|
+
if graph.nodes[seed].type in _CONFIG_WORKLOADS:
|
|
291
|
+
ext_nodes, ext_edges = seed_external_dependencies(graph.nodes[seed], resolver)
|
|
292
|
+
for nd in ext_nodes:
|
|
293
|
+
graph.nodes.setdefault(nd.id, nd)
|
|
294
|
+
graph.edges.extend(ext_edges)
|
|
295
|
+
|
|
296
|
+
for app_id, tid in diagnostics:
|
|
297
|
+
if app_id not in graph.nodes:
|
|
298
|
+
continue
|
|
299
|
+
target = resolver.by_resource_id(tid)
|
|
300
|
+
if not target:
|
|
301
|
+
nd = node_from_id(tid, note="diagnostic target (outside scanned scope)")
|
|
302
|
+
graph.nodes.setdefault(nd.id, nd)
|
|
303
|
+
target = nd.id
|
|
304
|
+
graph.edges.append(Edge(app_id, target, "sends-logs-to"))
|
|
305
|
+
|
|
306
|
+
graph.edges = _dedupe(graph.edges)
|
|
307
|
+
|
|
308
|
+
# An un-enriched app is a known class of missing edge - say so rather than let
|
|
309
|
+
# an empty upward view read as "nothing depends on this".
|
|
310
|
+
if skipped and args.direction != "down":
|
|
311
|
+
blind_spots.append(
|
|
312
|
+
f"{len(skipped)} workload(s) in scope were not deep-enriched, so anything that "
|
|
313
|
+
f"depends on this resource through config (Key Vault references, connection "
|
|
314
|
+
f"strings, hostnames) cannot appear as an inbound edge. "
|
|
315
|
+
f"Re-run with --enrich all to close this gap."
|
|
316
|
+
)
|
|
317
|
+
return graph, read_gaps, blind_spots
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _cmd_capture(args):
|
|
321
|
+
"""Save what the cloud actually returned, before any interpretation.
|
|
322
|
+
|
|
323
|
+
A fixture we invented can only confirm what we already believe; a captured
|
|
324
|
+
export can contradict us, which is the only way a test earns trust. Scrubbed
|
|
325
|
+
by default, because the interesting half of a capture is app config and app
|
|
326
|
+
config is full of credentials."""
|
|
327
|
+
from .ingest.azure import enrich_aks_clusters, enrich_webapps, query_live
|
|
328
|
+
|
|
329
|
+
resources, truncated = query_live(allow_live=args.allow_live,
|
|
330
|
+
tenant_wide=not args.single_sub)
|
|
331
|
+
if args.enrich == "all":
|
|
332
|
+
apps = [r for r in resources if str(r.get("type", "")).lower() == _WEBAPP]
|
|
333
|
+
akss = [r for r in resources if str(r.get("type", "")).lower() == _AKS]
|
|
334
|
+
if apps:
|
|
335
|
+
print(f"Deep-enriching {len(apps)} web app(s) so the capture includes the "
|
|
336
|
+
f"dependencies that only exist in app config...", file=sys.stderr)
|
|
337
|
+
enr = enrich_webapps(apps, resolve_secrets=args.resolve_secrets)
|
|
338
|
+
for msg in enr["errors"]:
|
|
339
|
+
print(f" ! could not read {msg}", file=sys.stderr)
|
|
340
|
+
resources.extend(enr["role_assignments"])
|
|
341
|
+
if akss:
|
|
342
|
+
print(f"Deep-enriching {len(akss)} AKS cluster(s) to capture Kubernetes manifests...", file=sys.stderr)
|
|
343
|
+
enr_aks = enrich_aks_clusters(akss, resolve_secrets=args.resolve_secrets)
|
|
344
|
+
for msg in enr_aks["errors"]:
|
|
345
|
+
print(f" ! could not read {msg}", file=sys.stderr)
|
|
346
|
+
|
|
347
|
+
stats = None
|
|
348
|
+
if args.no_scrub:
|
|
349
|
+
print("WARNING: writing an UNSCRUBBED export - real resource names, hostnames "
|
|
350
|
+
"and app settings (i.e. credentials) are about to be written to disk.\n"
|
|
351
|
+
" Do not commit or share this file; run `cloudmap scrub` on it first.",
|
|
352
|
+
file=sys.stderr)
|
|
353
|
+
else:
|
|
354
|
+
from .scrub import scrub
|
|
355
|
+
resources, stats = scrub(resources)
|
|
356
|
+
|
|
357
|
+
_write_export(args.out, resources, scrubbed=not args.no_scrub,
|
|
358
|
+
meta={"truncated": truncated, "enriched": args.enrich == "all"})
|
|
359
|
+
print(f"Captured {len(resources)} rows -> {args.out}")
|
|
360
|
+
_print_scrub_stats(stats)
|
|
361
|
+
return 0
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _cmd_scrub(args):
|
|
365
|
+
import json as _json
|
|
366
|
+
|
|
367
|
+
from .scrub import scrub
|
|
368
|
+
|
|
369
|
+
with open(args.input, encoding="utf-8") as f:
|
|
370
|
+
data = _json.load(f)
|
|
371
|
+
resources = data.get("data", data) if isinstance(data, dict) else data
|
|
372
|
+
meta = dict((data.get("meta") or {}) if isinstance(data, dict) else {})
|
|
373
|
+
|
|
374
|
+
scrubbed, stats = scrub(resources)
|
|
375
|
+
_write_export(args.out, scrubbed, scrubbed=True, meta=meta)
|
|
376
|
+
print(f"Scrubbed {len(scrubbed)} rows -> {args.out}")
|
|
377
|
+
_print_scrub_stats(stats)
|
|
378
|
+
return 0
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _write_export(path, resources, scrubbed, meta=None):
|
|
382
|
+
import json as _json
|
|
383
|
+
|
|
384
|
+
_ensure_parent(path)
|
|
385
|
+
doc = {"meta": dict(meta or {}, scrubbed=scrubbed), "data": resources}
|
|
386
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
387
|
+
_json.dump(doc, f, indent=2)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _print_scrub_stats(stats):
|
|
391
|
+
"""Counts only. The mapping itself is the re-identification key and is never
|
|
392
|
+
printed or written."""
|
|
393
|
+
if not stats:
|
|
394
|
+
return
|
|
395
|
+
print(f"Scrub: {stats['tokens']} identifier(s) pseudonymised, "
|
|
396
|
+
f"{stats['redactions']} credential fragment(s) redacted.")
|
|
397
|
+
if stats["short_tokens_left_alone"]:
|
|
398
|
+
print(f" ! left alone (too short to replace safely): "
|
|
399
|
+
f"{', '.join(stats['short_tokens_left_alone'])}")
|
|
400
|
+
print(" Review the output before committing it - a scrubber is not a proof.")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _cmd_ask(args):
|
|
404
|
+
import json as _json
|
|
405
|
+
|
|
406
|
+
from .adapters import load_graph
|
|
407
|
+
from .ask import answer, narrate
|
|
408
|
+
|
|
409
|
+
graph = load_graph(args.map)
|
|
410
|
+
result = answer(graph, args.question, allow_llm_intent=args.llm, max_hops=args.max_hops)
|
|
411
|
+
if args.explain:
|
|
412
|
+
result["narration"] = narrate(result)
|
|
413
|
+
|
|
414
|
+
if args.json_out:
|
|
415
|
+
print(_json.dumps(result, indent=2))
|
|
416
|
+
return 0 if not result.get("error") else 2
|
|
417
|
+
|
|
418
|
+
_print_answer(result)
|
|
419
|
+
return 0 if not result.get("error") else 2
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _print_answer(result):
|
|
423
|
+
if result.get("error"):
|
|
424
|
+
print(result["error"], file=sys.stderr)
|
|
425
|
+
print("\nQuestions this map can answer:", file=sys.stderr)
|
|
426
|
+
for line in result.get("supported", []):
|
|
427
|
+
print(f" {line}", file=sys.stderr)
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
subject = result.get("subject_name")
|
|
431
|
+
print(f"Query: {result['query']}" + (f" Β· subject: {subject}" if subject else ""))
|
|
432
|
+
print(result["headline"])
|
|
433
|
+
|
|
434
|
+
if result.get("hint"):
|
|
435
|
+
print(f" -> {result['hint']}")
|
|
436
|
+
for w in result.get("warnings", []):
|
|
437
|
+
print(f" ! {w}")
|
|
438
|
+
|
|
439
|
+
print()
|
|
440
|
+
for f in result["findings"]:
|
|
441
|
+
flag = " [GUESS]" if f["trust"] != "verified" else (
|
|
442
|
+
" [unverified target]" if f.get("external") else "")
|
|
443
|
+
metric = f" {f['metric']}" if f.get("metric") else ""
|
|
444
|
+
print(f" {f['name']} ({f['type']}){metric}{flag}")
|
|
445
|
+
if f.get("why"):
|
|
446
|
+
print(f" why unverified: {f['why']}")
|
|
447
|
+
for hop in f.get("path", []):
|
|
448
|
+
print(f" {hop['source']} --{hop['kind']}--> {hop['target']}")
|
|
449
|
+
if hop.get("evidence"):
|
|
450
|
+
print(f" proof: {hop['evidence']}")
|
|
451
|
+
for dep in f.get("dependents", []):
|
|
452
|
+
print(f" depended on by: {dep}")
|
|
453
|
+
|
|
454
|
+
if result.get("narration"):
|
|
455
|
+
print("\nNarration (local model, from the facts above - not a source of facts):")
|
|
456
|
+
print(f" {result['narration']}")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _ensure_parent(path):
|
|
460
|
+
parent = os.path.dirname(path)
|
|
461
|
+
if parent:
|
|
462
|
+
os.makedirs(parent, exist_ok=True)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _print_summary(graph, seed, out, truncated=False, blind_spots=()):
|
|
466
|
+
try:
|
|
467
|
+
from rich.console import Console
|
|
468
|
+
from rich.panel import Panel
|
|
469
|
+
from rich.tree import Tree
|
|
470
|
+
except ImportError:
|
|
471
|
+
Console, Tree, Panel = None, None, None
|
|
472
|
+
|
|
473
|
+
n = graph.nodes[seed]
|
|
474
|
+
ext = sum(1 for x in graph.nodes.values() if x.external)
|
|
475
|
+
|
|
476
|
+
if Console:
|
|
477
|
+
console = Console()
|
|
478
|
+
console.print(f"\n[bold green]Blast radius:[/bold green] {len(graph.nodes)} resources ({ext} external), {len(graph.edges)} dependencies")
|
|
479
|
+
if truncated:
|
|
480
|
+
console.print("[bold red]INCOMPLETE:[/bold red] scan hit the pagination cap - some resources/edges are missing.")
|
|
481
|
+
for spot in blind_spots:
|
|
482
|
+
console.print(f"[bold yellow]BLIND SPOT:[/bold yellow] {spot}")
|
|
483
|
+
|
|
484
|
+
def _get_icon(t):
|
|
485
|
+
t = (t or "").lower()
|
|
486
|
+
if "sites" in t: return "π"
|
|
487
|
+
if "clusters" in t: return "βΈοΈ"
|
|
488
|
+
if "database" in t or "sql" in t or "redis" in t or "cosmos" in t: return "ποΈ"
|
|
489
|
+
if "vault" in t: return "π"
|
|
490
|
+
return "π¦"
|
|
491
|
+
|
|
492
|
+
def _build_tree(node_id, seen):
|
|
493
|
+
node = graph.nodes[node_id]
|
|
494
|
+
color = "cyan" if not node.external else "dim white"
|
|
495
|
+
txt = f"[{color}]{_get_icon(node.type)} {node.name}[/{color}]"
|
|
496
|
+
if node.external: txt += " [italic dim](external)[/italic dim]"
|
|
497
|
+
|
|
498
|
+
tree = Tree(txt)
|
|
499
|
+
seen.add(node_id)
|
|
500
|
+
|
|
501
|
+
for e in graph.edges:
|
|
502
|
+
if e.source == node_id:
|
|
503
|
+
kind_color = "blue"
|
|
504
|
+
if "secret" in e.kind: kind_color = "yellow"
|
|
505
|
+
elif "connects" in e.kind: kind_color = "green"
|
|
506
|
+
elif "auth" in e.kind: kind_color = "magenta"
|
|
507
|
+
|
|
508
|
+
lbl = f"[{kind_color}]--{e.kind}-->[/{kind_color}]"
|
|
509
|
+
|
|
510
|
+
if e.target in seen:
|
|
511
|
+
tgt = graph.nodes[e.target]
|
|
512
|
+
tree.add(f"{lbl} [dim]{tgt.name} (cycle)[/dim]")
|
|
513
|
+
else:
|
|
514
|
+
branch = _build_tree(e.target, set(seen))
|
|
515
|
+
branch.label = f"{lbl} " + str(branch.label)
|
|
516
|
+
tree.add(branch)
|
|
517
|
+
return tree
|
|
518
|
+
|
|
519
|
+
tree = _build_tree(seed, set())
|
|
520
|
+
console.print(Panel(tree, title="Dependency Graph", border_style="blue"))
|
|
521
|
+
console.print(f"π [bold]draw.io:[/bold] {out}\n")
|
|
522
|
+
else:
|
|
523
|
+
print(f"Seed: {n.name} ({n.type})")
|
|
524
|
+
print(f"Blast radius: {len(graph.nodes)} resources "
|
|
525
|
+
f"({ext} external/unverified), {len(graph.edges)} dependencies")
|
|
526
|
+
if truncated:
|
|
527
|
+
print("INCOMPLETE: scan hit the pagination cap - some resources/edges are missing.")
|
|
528
|
+
for spot in blind_spots:
|
|
529
|
+
print(f"BLIND SPOT: {spot}")
|
|
530
|
+
print(f"draw.io: {out}\n")
|
|
531
|
+
for e in graph.edges:
|
|
532
|
+
tgt = graph.nodes[e.target]
|
|
533
|
+
tag = " [external]" if tgt.external else ""
|
|
534
|
+
print(f" {graph.nodes[e.source].name} --{e.kind}--> {tgt.name}{tag}")
|
|
File without changes
|