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/extract/llm.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""LLM-assisted extraction: a LOCAL model reads ONE resource's JSON and proposes
|
|
2
|
+
which other resources it depends on; the deterministic Resolver then VERIFIES each
|
|
3
|
+
proposal against the scanned set. This is the discovery half of "map anything" -
|
|
4
|
+
it catches dependencies expressed as free text (a hostname or name in a setting)
|
|
5
|
+
that no hand-written rule and no ARM-id pass would find.
|
|
6
|
+
|
|
7
|
+
Trust is preserved by two rails, not by trusting the model:
|
|
8
|
+
1. The model is an EXTRACTOR, told to output only targets that appear verbatim in
|
|
9
|
+
the JSON - not to invent.
|
|
10
|
+
2. Only proposals whose target RESOLVES to a real scanned resource survive. An
|
|
11
|
+
unverifiable proposal is dropped, never shown - a model guess we cannot confirm
|
|
12
|
+
is exactly what must not reach the map. Verified edges are still marked
|
|
13
|
+
origin="model" (drawn dashed), because the model supplied the relationship.
|
|
14
|
+
|
|
15
|
+
Local by design (ollama) - the resource JSON never leaves the machine.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
from ..local_model import DEFAULT_MODEL, OLLAMA_URL, generate_json # noqa: F401 (re-exported)
|
|
22
|
+
from ..model import Edge
|
|
23
|
+
|
|
24
|
+
# A plausible dependency target is a hostname / resource name / ARM id - never a
|
|
25
|
+
# secret or connection string. Reject anything that smells like one.
|
|
26
|
+
_SAFE_TARGET = re.compile(r"^[A-Za-z0-9._/\-]{1,160}$")
|
|
27
|
+
_SECRETISH = re.compile(r"(password|secret|accountkey|sharedaccesskey|=|;| )", re.I)
|
|
28
|
+
|
|
29
|
+
_PROMPT = """You analyse ONE Azure resource to find the OTHER Azure resources it depends on.
|
|
30
|
+
Work ONLY from the JSON below. Every target you output MUST appear verbatim in the JSON
|
|
31
|
+
(a hostname, a resource name, or an ARM resource id inside a setting, connection string,
|
|
32
|
+
endpoint or property). Do NOT invent, guess or infer a resource that is not written there.
|
|
33
|
+
|
|
34
|
+
Output ONLY JSON of this shape:
|
|
35
|
+
{{"edges":[{{"target":"<hostname | name | ARM id from the JSON>","relationship":"<short kind>"}}]}}
|
|
36
|
+
- relationship: one of hosted-on, reads-secret, connects-to, sends-telemetry, pulls-image,
|
|
37
|
+
routes-to, uses-workspace, vnet-integration, or a short lowercase verb phrase.
|
|
38
|
+
- Prefer the most specific hostname or name. Never output a secret, password, key or a
|
|
39
|
+
connection-string value - only the resource it points at.
|
|
40
|
+
- If nothing is referenced, output {{"edges":[]}}.
|
|
41
|
+
|
|
42
|
+
Resource type: {rtype}
|
|
43
|
+
Resource JSON:
|
|
44
|
+
{resource}
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def propose_edges(resource_raw, model=None, timeout=600):
|
|
49
|
+
"""Ask the local model for candidate edges. Returns [(target, relationship)].
|
|
50
|
+
Empty list on any failure (ollama down, bad JSON, timeout)."""
|
|
51
|
+
prompt = _PROMPT.format(rtype=resource_raw.get("type", "unknown"),
|
|
52
|
+
resource=json.dumps(resource_raw, indent=2))
|
|
53
|
+
parsed = generate_json(prompt, model=model, timeout=timeout)
|
|
54
|
+
|
|
55
|
+
out = []
|
|
56
|
+
for e in parsed.get("edges", []) if isinstance(parsed, dict) else []:
|
|
57
|
+
tgt = str(e.get("target", "")).strip()
|
|
58
|
+
rel = (str(e.get("relationship", "") or "references").strip())[:40]
|
|
59
|
+
if tgt and _SAFE_TARGET.match(tgt) and not _SECRETISH.search(tgt):
|
|
60
|
+
out.append((tgt, rel))
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve(hint, resolver):
|
|
65
|
+
"""Verify a proposed target against scanned resources -> node id, else None."""
|
|
66
|
+
h = hint.strip().lower()
|
|
67
|
+
if h.startswith("/subscriptions/"):
|
|
68
|
+
return resolver.by_resource_id(hint)
|
|
69
|
+
return (resolver.host_lookup(h) or resolver.kv_by_name.get(h)
|
|
70
|
+
or resolver.storage_by_name.get(h) or resolver.acr_by_loginserver.get(h)
|
|
71
|
+
or resolver.by_name.get(h) or resolver.by_name.get(h.split(".")[0]))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def llm_edges_for_seed(seed_node, resolver, model=None):
|
|
75
|
+
"""Propose edges for the seed via the local model, keep only the ones whose
|
|
76
|
+
target resolves to a real scanned resource. Returns (external_nodes, edges) -
|
|
77
|
+
external_nodes is always empty (an unverifiable model proposal is dropped, not
|
|
78
|
+
shown); the tuple shape is kept for the caller. Verified edges are origin
|
|
79
|
+
"model" so renderers draw them dashed and the ask layer treats them as guesses."""
|
|
80
|
+
edges, seen = [], set()
|
|
81
|
+
for tgt, rel in propose_edges(seed_node.raw, model=model):
|
|
82
|
+
nid = _resolve(tgt, resolver)
|
|
83
|
+
if not nid or nid == seed_node.id or (seed_node.id, nid) in seen:
|
|
84
|
+
continue # unverifiable or redundant -> drop it
|
|
85
|
+
seen.add((seed_node.id, nid))
|
|
86
|
+
edges.append(Edge(seed_node.id, nid, rel, origin="model",
|
|
87
|
+
evidence=f"proposed by local model from the resource's own JSON "
|
|
88
|
+
f"(target '{tgt}' verified as a scanned resource)"))
|
|
89
|
+
return [], edges
|
cloudmap/graph.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Build the dependency graph and compute a component's blast radius.
|
|
2
|
+
|
|
3
|
+
`blast_radius` walks edges from a seed with DIRECTION CONSISTENCY: from the seed
|
|
4
|
+
it may go both downstream (what the seed depends on) and upstream (what depends
|
|
5
|
+
on the seed), but once it has stepped in one direction it keeps going that way -
|
|
6
|
+
it never reverses. That single rule keeps a shared resource (App Service Plan,
|
|
7
|
+
VNet, Key Vault, Storage) from bridging the seed to unrelated apps: reaching a
|
|
8
|
+
shared plan downstream never walks back up to the other apps hosted on it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from .extract.extractors import extract_edges
|
|
14
|
+
from .model import Graph, Node
|
|
15
|
+
|
|
16
|
+
# Friendly, architecture-level names per resource type (for the high-level view).
|
|
17
|
+
FRIENDLY = {
|
|
18
|
+
"microsoft.web/sites": "Web App",
|
|
19
|
+
"microsoft.web/serverfarms": "App Service Plan",
|
|
20
|
+
"microsoft.keyvault/vaults": "Key Vault",
|
|
21
|
+
"microsoft.storage/storageaccounts": "Storage",
|
|
22
|
+
"microsoft.sql/servers": "SQL Server",
|
|
23
|
+
"microsoft.dbforpostgresql/flexibleservers": "PostgreSQL",
|
|
24
|
+
"microsoft.dbforpostgresql/servers": "PostgreSQL",
|
|
25
|
+
"microsoft.dbformysql/flexibleservers": "MySQL",
|
|
26
|
+
"microsoft.dbformysql/servers": "MySQL",
|
|
27
|
+
"microsoft.documentdb/databaseaccounts": "Cosmos DB",
|
|
28
|
+
"microsoft.cache/redis": "Redis",
|
|
29
|
+
"microsoft.servicebus/namespaces": "Service Bus",
|
|
30
|
+
"microsoft.eventhub/namespaces": "Event Hub",
|
|
31
|
+
"microsoft.search/searchservices": "Cognitive Search",
|
|
32
|
+
"microsoft.cognitiveservices/accounts": "Azure AI / OpenAI",
|
|
33
|
+
"microsoft.containerregistry/registries": "Container Registry",
|
|
34
|
+
"microsoft.containerservice/managedclusters": "AKS",
|
|
35
|
+
"microsoft.app/containerapps": "Container App",
|
|
36
|
+
"microsoft.app/managedenvironments": "Container Apps Environment",
|
|
37
|
+
"microsoft.operationalinsights/workspaces": "Log Analytics",
|
|
38
|
+
"microsoft.insights/components": "App Insights",
|
|
39
|
+
"microsoft.network/virtualnetworks": "Virtual Network",
|
|
40
|
+
"microsoft.network/privateendpoints": "Private Endpoint",
|
|
41
|
+
"microsoft.network/applicationgateways": "App Gateway",
|
|
42
|
+
"microsoft.apimanagement/service": "API Management",
|
|
43
|
+
"microsoft.managedidentity/userassignedidentities": "Managed Identity",
|
|
44
|
+
"microsoft.machinelearningservices/workspaces": "ML Workspace",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def friendly_type(t):
|
|
49
|
+
return FRIENDLY.get(t) or (t.split("/")[-1] if "/" in t else t or "resource")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Id prefixes minted by collapse_high_level. They are also how a reader of a saved
|
|
53
|
+
# map can tell it was collapsed - see is_high_level.
|
|
54
|
+
HIGH_LEVEL_PREFIXES = ("type::", "ext::")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_high_level(graph):
|
|
58
|
+
"""True if this map is the architecture view: instances were merged into one
|
|
59
|
+
box per resource type, so instance names no longer exist in it. Worth knowing
|
|
60
|
+
before telling someone their resource is not in the map - it may well be,
|
|
61
|
+
inside a group."""
|
|
62
|
+
return any(str(nid).startswith(HIGH_LEVEL_PREFIXES) for nid in graph.nodes)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _merge_semis(existing, addition):
|
|
66
|
+
"""Union of two '; '-joined lists, order preserved. Collapsing many instances
|
|
67
|
+
into one box folds their arrows into one, and both the relationship kinds and
|
|
68
|
+
their proofs have to survive that fold - a grouped arrow with no evidence is
|
|
69
|
+
an unfalsifiable claim."""
|
|
70
|
+
parts = [p for p in (existing or "").split("; ") if p]
|
|
71
|
+
for p in (addition or "").split("; "):
|
|
72
|
+
if p and p not in parts:
|
|
73
|
+
parts.append(p)
|
|
74
|
+
return "; ".join(parts)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def collapse_high_level(graph, seed_id):
|
|
78
|
+
"""Collapse the graph to an architecture-level view: the seed keeps its name,
|
|
79
|
+
every other node is grouped by resource TYPE (one box per type, labelled by
|
|
80
|
+
role, e.g. 'Storage', 'Key Vault'), so the diagram shows the shape rather than
|
|
81
|
+
instance names. Multiple instances of a type collapse into one, noting the count.
|
|
82
|
+
"""
|
|
83
|
+
from .model import Edge, Graph, Node
|
|
84
|
+
|
|
85
|
+
def group_key(nid):
|
|
86
|
+
n = graph.nodes[nid]
|
|
87
|
+
if nid == seed_id:
|
|
88
|
+
return seed_id
|
|
89
|
+
return f"ext::{n.type}" if n.external else f"type::{n.type}" # HIGH_LEVEL_PREFIXES
|
|
90
|
+
|
|
91
|
+
groups = {}
|
|
92
|
+
for nid in graph.nodes:
|
|
93
|
+
groups.setdefault(group_key(nid), []).append(nid)
|
|
94
|
+
|
|
95
|
+
new_nodes, remap, distances = {}, {}, {}
|
|
96
|
+
for gkey, members in groups.items():
|
|
97
|
+
rep = graph.nodes[members[0]]
|
|
98
|
+
for m in members:
|
|
99
|
+
remap[m] = gkey
|
|
100
|
+
dist = min((graph.distances or {}).get(m, 0) for m in members)
|
|
101
|
+
if gkey == seed_id:
|
|
102
|
+
new_nodes[gkey] = rep
|
|
103
|
+
else:
|
|
104
|
+
label = rep.type.split("/", 1)[-1] if rep.external else friendly_type(rep.type)
|
|
105
|
+
if len(members) > 1:
|
|
106
|
+
label += f" ×{len(members)}"
|
|
107
|
+
# an external group keeps its members' reasons: "why is this unverified"
|
|
108
|
+
# must not be lost to grouping either
|
|
109
|
+
note = ""
|
|
110
|
+
for m in members:
|
|
111
|
+
note = _merge_semis(note, graph.nodes[m].note)
|
|
112
|
+
new_nodes[gkey] = Node(id=gkey, name=label, type=rep.type,
|
|
113
|
+
external=rep.external, note=note)
|
|
114
|
+
distances[gkey] = dist
|
|
115
|
+
|
|
116
|
+
merged = {}
|
|
117
|
+
for e in graph.edges:
|
|
118
|
+
s, t = remap.get(e.source), remap.get(e.target)
|
|
119
|
+
if not (s and t) or s == t:
|
|
120
|
+
continue
|
|
121
|
+
key = (s, t)
|
|
122
|
+
if key in merged:
|
|
123
|
+
m = merged[key]
|
|
124
|
+
m.kind = _merge_semis(m.kind, e.kind)
|
|
125
|
+
m.evidence = _merge_semis(m.evidence, e.evidence)
|
|
126
|
+
if e.origin == "extracted": # any verified member -> arrow is verified
|
|
127
|
+
m.origin = "extracted"
|
|
128
|
+
else:
|
|
129
|
+
merged[key] = Edge(source=s, target=t, kind=e.kind, origin=e.origin,
|
|
130
|
+
evidence=e.evidence)
|
|
131
|
+
|
|
132
|
+
return Graph(nodes=new_nodes, edges=list(merged.values()), distances=distances)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def node_from_id(rid: str, external: bool = False, note: str = "") -> Node:
|
|
136
|
+
"""Build a Node from a bare ARM id (name = last segment, type from provider),
|
|
137
|
+
for targets referenced but not present in the scanned resource set."""
|
|
138
|
+
typ = ""
|
|
139
|
+
low = rid.lower()
|
|
140
|
+
if "/providers/" in low:
|
|
141
|
+
after = rid.split("/providers/", 1)[1].split("/")
|
|
142
|
+
if len(after) >= 3:
|
|
143
|
+
typ = f"{after[0]}/{after[1]}".lower()
|
|
144
|
+
return Node(id=rid, name=rid.rstrip("/").rsplit("/", 1)[-1], type=typ,
|
|
145
|
+
external=external, note=note)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_graph(resources: list[dict[str, Any]]) -> Graph:
|
|
149
|
+
nodes = {}
|
|
150
|
+
for r in resources:
|
|
151
|
+
rid = (r.get("id") or "").lower()
|
|
152
|
+
if not rid:
|
|
153
|
+
continue
|
|
154
|
+
nodes[rid] = Node(
|
|
155
|
+
id=rid,
|
|
156
|
+
name=r.get("name") or rid.rsplit("/", 1)[-1],
|
|
157
|
+
type=(r.get("type") or "").lower(),
|
|
158
|
+
resource_group=r.get("resourceGroup") or "",
|
|
159
|
+
subscription=r.get("subscriptionId") or "",
|
|
160
|
+
location=r.get("location") or "",
|
|
161
|
+
kind=r.get("kind") or "",
|
|
162
|
+
tags=r.get("tags") or {},
|
|
163
|
+
raw=r,
|
|
164
|
+
)
|
|
165
|
+
return Graph(nodes=nodes, edges=extract_edges(nodes))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def find_seeds(graph: Graph, name: str) -> list[str]:
|
|
169
|
+
name_l = name.lower()
|
|
170
|
+
exact = [n.id for n in graph.nodes.values() if n.name.lower() == name_l or n.id.lower() == name_l]
|
|
171
|
+
if exact:
|
|
172
|
+
return exact
|
|
173
|
+
return [n.id for n in graph.nodes.values()
|
|
174
|
+
if name_l in n.name.lower() or n.id.lower().endswith("/" + name_l)]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def blast_radius(graph: Graph, seed_id: str, direction: str = "both", max_hops: Optional[int] = None) -> Graph:
|
|
178
|
+
down, up = {}, {}
|
|
179
|
+
for e in graph.edges:
|
|
180
|
+
down.setdefault(e.source, []).append(e.target)
|
|
181
|
+
up.setdefault(e.target, []).append(e.source)
|
|
182
|
+
|
|
183
|
+
from collections import deque
|
|
184
|
+
seed_dirs = {"both": ("down", "up"), "down": ("down",), "up": ("up",)}[direction]
|
|
185
|
+
|
|
186
|
+
visited_state = {(seed_id, "seed")}
|
|
187
|
+
distances = {seed_id: 0}
|
|
188
|
+
queue = deque([(seed_id, 0, "seed")]) # (node, distance, direction it was reached by)
|
|
189
|
+
while queue:
|
|
190
|
+
cur, dist, arrived = queue.popleft()
|
|
191
|
+
if max_hops is not None and dist >= max_hops:
|
|
192
|
+
continue
|
|
193
|
+
dirs = seed_dirs if arrived == "seed" else (arrived,) # never reverse direction
|
|
194
|
+
step = []
|
|
195
|
+
if "down" in dirs:
|
|
196
|
+
step += [(t, "down") for t in down.get(cur, [])]
|
|
197
|
+
if "up" in dirs:
|
|
198
|
+
step += [(s, "up") for s in up.get(cur, [])]
|
|
199
|
+
for nb, d in step:
|
|
200
|
+
if (nb, d) not in visited_state and nb in graph.nodes:
|
|
201
|
+
visited_state.add((nb, d))
|
|
202
|
+
if nb not in distances or dist + 1 < distances[nb]:
|
|
203
|
+
distances[nb] = dist + 1
|
|
204
|
+
queue.append((nb, dist + 1, d))
|
|
205
|
+
|
|
206
|
+
sub_nodes = {nid: graph.nodes[nid] for nid in distances}
|
|
207
|
+
sub_edges = [e for e in graph.edges if e.source in distances and e.target in distances]
|
|
208
|
+
return Graph(nodes=sub_nodes, edges=sub_edges, distances=distances)
|
|
File without changes
|
cloudmap/ingest/azure.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Live Azure ingest via `az`.
|
|
2
|
+
|
|
3
|
+
Secrets: web-app app settings / Key Vault secret values can contain credentials.
|
|
4
|
+
They are read in-process ONLY to derive dependency endpoints, and are never
|
|
5
|
+
printed and never written to any output file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
# Tenants to refuse outright, by id. Empty by default; a generic denylist hook
|
|
15
|
+
# for anyone who wants to hard-block a specific tenant. Live mode is otherwise
|
|
16
|
+
# ungated beyond the explicit --allow-live flag: cloudmap reads whatever
|
|
17
|
+
# subscription you point it at, production included. The read is read-only, but
|
|
18
|
+
# it IS a read of live infrastructure - --allow-live is the deliberate opt-in,
|
|
19
|
+
# and CLOUDMAP_ALLOW_SUBSCRIPTION (below) is an optional extra pin.
|
|
20
|
+
DENY_TENANTS = set()
|
|
21
|
+
|
|
22
|
+
_KV_REF = re.compile(r"@microsoft\.keyvault\(([^)]*)\)", re.I)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _az(args):
|
|
26
|
+
# --only-show-errors suppresses az deprecation/upgrade chatter so a genuine
|
|
27
|
+
# stdout JSON doesn't get corrupted by warnings.
|
|
28
|
+
cmd = ["az"] + args + ["--only-show-errors"]
|
|
29
|
+
|
|
30
|
+
# If the user pinned a specific subscription (e.g. cross-tenant QA sub),
|
|
31
|
+
# force the CLI to use that context for every command to avoid "Given: ''" errors.
|
|
32
|
+
pin = os.environ.get("CLOUDMAP_ALLOW_SUBSCRIPTION", "").strip()
|
|
33
|
+
if pin and "--subscription" not in cmd and not (args[0] == "account" and len(args) > 1 and args[1] == "list"):
|
|
34
|
+
# az account list does not accept --subscription
|
|
35
|
+
cmd += ["--subscription", pin]
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
out = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
39
|
+
except subprocess.TimeoutExpired:
|
|
40
|
+
raise RuntimeError(f"az {' '.join(args)} timed out after 120s")
|
|
41
|
+
|
|
42
|
+
if out.returncode != 0:
|
|
43
|
+
raise RuntimeError(f"az {' '.join(args)} failed: {out.stderr.strip()}")
|
|
44
|
+
return out.stdout
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _guard():
|
|
48
|
+
try:
|
|
49
|
+
acct = json.loads(_az(["account", "show", "-o", "json"]))
|
|
50
|
+
except Exception:
|
|
51
|
+
raise SystemExit("Azure CLI error: You are not logged in, or no active subscription is set. Run 'az login' first.")
|
|
52
|
+
sub_id = acct.get("id") or ""
|
|
53
|
+
tenant = (acct.get("tenantId") or "").lower()
|
|
54
|
+
|
|
55
|
+
if tenant in DENY_TENANTS:
|
|
56
|
+
raise SystemExit(f"Refusing to query denied tenant {tenant}.")
|
|
57
|
+
# Optional extra pin: if CLOUDMAP_ALLOW_SUBSCRIPTION is set, it must equal the
|
|
58
|
+
# active subscription, so a stale `az account set` cannot silently redirect a
|
|
59
|
+
# scan to the wrong place. Unset means no pin - --allow-live already said yes.
|
|
60
|
+
allowed = os.environ.get("CLOUDMAP_ALLOW_SUBSCRIPTION", "").strip()
|
|
61
|
+
if allowed and allowed.lower() != sub_id.lower():
|
|
62
|
+
raise SystemExit(
|
|
63
|
+
f"CLOUDMAP_ALLOW_SUBSCRIPTION is set to {allowed} but the active "
|
|
64
|
+
f"subscription is {sub_id} ({acct.get('name')}). Refusing the mismatch."
|
|
65
|
+
)
|
|
66
|
+
return acct
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _target_subscriptions(active_id, tenant_wide):
|
|
70
|
+
if not tenant_wide:
|
|
71
|
+
return [active_id]
|
|
72
|
+
subs = json.loads(_az(["account", "list", "-o", "json"]))
|
|
73
|
+
out = [s["id"] for s in subs if s.get("state") == "Enabled"]
|
|
74
|
+
return out or [active_id]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_PAGE_CAP = 40
|
|
78
|
+
|
|
79
|
+
# Scan EVERY resource type, not an allowlist: any type can be a seed, and any
|
|
80
|
+
# type can be the target of an ARM-id reference the generic pass resolves. That
|
|
81
|
+
# is what makes "map anything" true. Types nothing references stay disconnected
|
|
82
|
+
# islands - they cost a little payload but never appear in a seed's blast radius.
|
|
83
|
+
RESOURCES_KQL = ("resources "
|
|
84
|
+
"| project id,name,type,resourceGroup,subscriptionId,location,kind,identity,properties,tags")
|
|
85
|
+
# Role assignments live in a separate table; pulling them tenant-wide lets us
|
|
86
|
+
# answer "what has access to this resource" (reverse / incident-response view).
|
|
87
|
+
ROLES_KQL = ("authorizationresources | where type =~ 'microsoft.authorization/roleassignments' "
|
|
88
|
+
"| project id,name,type,properties")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _graph_paged(kql, subs):
|
|
92
|
+
"""Run a KQL query paged via skip_token. Returns (rows, truncated) where
|
|
93
|
+
truncated=True means we hit the page cap and the result is INCOMPLETE."""
|
|
94
|
+
data, token = [], None
|
|
95
|
+
for _ in range(_PAGE_CAP):
|
|
96
|
+
args = ["graph", "query", "-q", kql, "--first", "1000"]
|
|
97
|
+
if subs:
|
|
98
|
+
args += ["--subscriptions"] + subs
|
|
99
|
+
if token:
|
|
100
|
+
args += ["--skip-token", token]
|
|
101
|
+
raw = json.loads(_az(args))
|
|
102
|
+
data += raw.get("data", [])
|
|
103
|
+
token = raw.get("skip_token") or raw.get("skipToken")
|
|
104
|
+
if not token:
|
|
105
|
+
break
|
|
106
|
+
return data, bool(token)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def query_live(allow_live=False, tenant_wide=True):
|
|
110
|
+
if not allow_live:
|
|
111
|
+
raise SystemExit("Live query requires --allow-live (fixtures are the default).")
|
|
112
|
+
acct = _guard()
|
|
113
|
+
subs = _target_subscriptions(acct.get("id"), tenant_wide)
|
|
114
|
+
|
|
115
|
+
resources, trunc_r = [], False
|
|
116
|
+
roles, trunc_a = [], False
|
|
117
|
+
try:
|
|
118
|
+
resources, trunc_r = _graph_paged(RESOURCES_KQL, subs)
|
|
119
|
+
roles, trunc_a = _graph_paged(ROLES_KQL, subs)
|
|
120
|
+
except Exception as bulk_e:
|
|
121
|
+
print(f"Bulk scan failed ({bulk_e}), falling back to per-subscription scan...", file=sys.stderr)
|
|
122
|
+
for s in subs:
|
|
123
|
+
try:
|
|
124
|
+
res, tr = _graph_paged(RESOURCES_KQL, [s])
|
|
125
|
+
rol, ta = _graph_paged(ROLES_KQL, [s])
|
|
126
|
+
resources.extend(res)
|
|
127
|
+
roles.extend(rol)
|
|
128
|
+
trunc_r = trunc_r or tr
|
|
129
|
+
trunc_a = trunc_a or ta
|
|
130
|
+
except Exception:
|
|
131
|
+
print(f" ! skipped subscription {s} (access denied or error)", file=sys.stderr)
|
|
132
|
+
|
|
133
|
+
print(f"Scanned {len(resources)} resources + {len(roles)} role assignments "
|
|
134
|
+
f"across {len(subs)} subscription(s).", file=sys.stderr)
|
|
135
|
+
truncated = trunc_r or trunc_a
|
|
136
|
+
if truncated:
|
|
137
|
+
print(f"WARNING: pagination cap ({_PAGE_CAP} pages) reached - the graph is "
|
|
138
|
+
f"INCOMPLETE. Narrow the scope (e.g. --single-sub) to see everything.",
|
|
139
|
+
file=sys.stderr)
|
|
140
|
+
return resources + roles, truncated
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _resolve_secret(ref_inner):
|
|
144
|
+
"""ref_inner is the text inside @Microsoft.KeyVault(...). Return the secret
|
|
145
|
+
value (read in-process only) or "" on any failure."""
|
|
146
|
+
vault = secret = None
|
|
147
|
+
m = re.search(r"vaultname\s*=\s*([^;)\s]+)", ref_inner, re.I)
|
|
148
|
+
if m:
|
|
149
|
+
vault = m.group(1)
|
|
150
|
+
m = re.search(r"secretname\s*=\s*([^;)\s]+)", ref_inner, re.I)
|
|
151
|
+
if m:
|
|
152
|
+
secret = m.group(1)
|
|
153
|
+
m = re.search(r"secreturi\s*=\s*https://([a-z0-9\-]+)\.vault\.azure\.net/secrets/([^/;)\s]+)",
|
|
154
|
+
ref_inner, re.I)
|
|
155
|
+
if m:
|
|
156
|
+
vault, secret = m.group(1), m.group(2)
|
|
157
|
+
if not (vault and secret):
|
|
158
|
+
return ""
|
|
159
|
+
try:
|
|
160
|
+
return _az(["keyvault", "secret", "show", "--vault-name", vault,
|
|
161
|
+
"--name", secret, "--query", "value", "-o", "tsv"]).strip()
|
|
162
|
+
except Exception:
|
|
163
|
+
return ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _maybe_resolve(value, resolve_secrets):
|
|
167
|
+
if not resolve_secrets or "@microsoft.keyvault(" not in value.lower():
|
|
168
|
+
return value
|
|
169
|
+
def repl(m):
|
|
170
|
+
return _resolve_secret(m.group(1)) or m.group(0)
|
|
171
|
+
return _KV_REF.sub(repl, value)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def enrich_webapps(raws, resolve_secrets=False, max_workers=8):
|
|
175
|
+
"""Deep-enrich MANY web apps concurrently, folding each app's config into its
|
|
176
|
+
own `raw` dict so a later build_graph() sees it.
|
|
177
|
+
|
|
178
|
+
Why this exists: a dependency that lives in app config (a Key Vault
|
|
179
|
+
reference, a connection string, a backend hostname) appears nowhere in the
|
|
180
|
+
ARM topology, so enriching only the seed makes the graph asymmetric -
|
|
181
|
+
tracing an app finds the vault it reads, but tracing that vault never finds
|
|
182
|
+
the app. "What breaks if I touch this" is usually asked about exactly such a
|
|
183
|
+
shared resource, so the upward view is the one that needs every app enriched.
|
|
184
|
+
|
|
185
|
+
Returns {"role_assignments": [...], "diagnostics": [(app_id, target_id)],
|
|
186
|
+
"errors": [...], "enriched": [app_id]}. Errors are prefixed with the
|
|
187
|
+
app name so a read gap stays attributable to one resource.
|
|
188
|
+
|
|
189
|
+
Nothing is cached to disk: app settings and resolved secrets are credentials,
|
|
190
|
+
and this tool does not write them anywhere.
|
|
191
|
+
"""
|
|
192
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
193
|
+
|
|
194
|
+
merged = {"role_assignments": [], "diagnostics": [], "errors": [], "enriched": []}
|
|
195
|
+
if not raws:
|
|
196
|
+
return merged
|
|
197
|
+
|
|
198
|
+
def one(raw):
|
|
199
|
+
return raw, enrich_webapp(raw, resolve_secrets=resolve_secrets)
|
|
200
|
+
|
|
201
|
+
# az shells out per call, so these are IO-bound: threads are enough, and the
|
|
202
|
+
# cap keeps a tenant-wide pass from opening hundreds of processes at once.
|
|
203
|
+
with ThreadPoolExecutor(max_workers=max(1, min(max_workers, len(raws)))) as pool:
|
|
204
|
+
for raw, res in pool.map(one, raws):
|
|
205
|
+
label = raw.get("name") or raw.get("id") or "?"
|
|
206
|
+
merged["role_assignments"].extend(res["role_assignments"])
|
|
207
|
+
merged["diagnostics"].extend((raw.get("id"), t) for t in res["diagnostics"])
|
|
208
|
+
merged["errors"].extend(f"{label}: {m}" for m in res["errors"])
|
|
209
|
+
merged["enriched"].append(raw.get("id"))
|
|
210
|
+
return merged
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def enrich_webapp(raw, resolve_secrets=False):
|
|
214
|
+
"""Fetch the parts of a web app that Resource Graph omits and fold them into
|
|
215
|
+
`raw` so the extractors can read them. Returns a dict with synthetic
|
|
216
|
+
role-assignment resources and diagnostic-target ids for the caller to merge.
|
|
217
|
+
|
|
218
|
+
With resolve_secrets=True, Key Vault references in app settings are replaced
|
|
219
|
+
in-memory with their resolved values so dependencies hidden behind secrets
|
|
220
|
+
(DB / Storage connection strings) become visible. Values are never printed
|
|
221
|
+
or written out.
|
|
222
|
+
"""
|
|
223
|
+
name, rg, sub_id = raw.get("name"), raw.get("resourceGroup"), raw.get("subscriptionId")
|
|
224
|
+
result = {"role_assignments": [], "diagnostics": [], "errors": []}
|
|
225
|
+
if not (name and rg and sub_id):
|
|
226
|
+
result["errors"].append("web app has no name/resourceGroup/subscriptionId; skipped deep enrich")
|
|
227
|
+
return result
|
|
228
|
+
props = raw.setdefault("properties", {})
|
|
229
|
+
site_cfg = props.setdefault("siteConfig", {})
|
|
230
|
+
|
|
231
|
+
# Helper to ensure we query the exact subscription the resource lives in
|
|
232
|
+
def __az(cmd_args):
|
|
233
|
+
return _az(cmd_args + ["--subscription", sub_id])
|
|
234
|
+
|
|
235
|
+
principal_id = None
|
|
236
|
+
try:
|
|
237
|
+
show = json.loads(__az(["webapp", "show", "-g", rg, "-n", name, "-o", "json"]))
|
|
238
|
+
ident = show.get("identity") or {}
|
|
239
|
+
principal_id = ident.get("principalId")
|
|
240
|
+
if principal_id:
|
|
241
|
+
raw["identity"] = {"type": ident.get("type"), "principalId": principal_id}
|
|
242
|
+
if show.get("virtualNetworkSubnetId"):
|
|
243
|
+
props["virtualNetworkSubnetId"] = show["virtualNetworkSubnetId"]
|
|
244
|
+
fx = (show.get("siteConfig") or {}).get("linuxFxVersion")
|
|
245
|
+
if fx:
|
|
246
|
+
site_cfg["linuxFxVersion"] = fx
|
|
247
|
+
except Exception as e:
|
|
248
|
+
result["errors"].append(f"identity/vnet/runtime (az webapp show): {e}")
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
settings = json.loads(__az(
|
|
252
|
+
["webapp", "config", "appsettings", "list", "-g", rg, "-n", name, "-o", "json"]))
|
|
253
|
+
site_cfg["appSettings"] = [
|
|
254
|
+
{"name": s.get("name"), "value": _maybe_resolve(str(s.get("value", "")), resolve_secrets)}
|
|
255
|
+
for s in settings
|
|
256
|
+
]
|
|
257
|
+
except Exception as e:
|
|
258
|
+
result["errors"].append(f"app settings (az webapp config appsettings list): {e}")
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
conns = json.loads(__az(
|
|
262
|
+
["webapp", "config", "connection-string", "list", "-g", rg, "-n", name, "-o", "json"]))
|
|
263
|
+
cs = []
|
|
264
|
+
items = conns.items() if isinstance(conns, dict) else [(v.get("name"), v) for v in conns]
|
|
265
|
+
for k, v in items:
|
|
266
|
+
raw_val = (v or {}).get("value", "") if isinstance(v, dict) else ""
|
|
267
|
+
cs.append({"name": k, "connectionString": _maybe_resolve(str(raw_val), resolve_secrets)})
|
|
268
|
+
props["connectionStrings"] = cs
|
|
269
|
+
except Exception as e:
|
|
270
|
+
result["errors"].append(f"connection strings (az webapp config connection-string list): {e}")
|
|
271
|
+
|
|
272
|
+
if principal_id:
|
|
273
|
+
try:
|
|
274
|
+
rows = json.loads(__az(
|
|
275
|
+
["role", "assignment", "list", "--assignee", principal_id, "--all", "-o", "json"]))
|
|
276
|
+
for i, r in enumerate(rows):
|
|
277
|
+
result["role_assignments"].append({
|
|
278
|
+
"id": r.get("id") or f"ra-{name}-{i}",
|
|
279
|
+
"name": r.get("name") or f"ra-{name}-{i}",
|
|
280
|
+
"type": "microsoft.authorization/roleassignments",
|
|
281
|
+
"properties": {
|
|
282
|
+
"principalId": principal_id,
|
|
283
|
+
"roleDefinitionName": r.get("roleDefinitionName"),
|
|
284
|
+
"roleDefinitionId": r.get("roleDefinitionId"),
|
|
285
|
+
"scope": r.get("scope"),
|
|
286
|
+
},
|
|
287
|
+
})
|
|
288
|
+
except Exception as e:
|
|
289
|
+
result["errors"].append(f"RBAC role assignments (az role assignment list): {e}")
|
|
290
|
+
|
|
291
|
+
if raw.get("id"):
|
|
292
|
+
try:
|
|
293
|
+
diag = json.loads(__az(
|
|
294
|
+
["monitor", "diagnostic-settings", "list", "--resource", raw["id"], "-o", "json"]))
|
|
295
|
+
rows = diag.get("value", diag) if isinstance(diag, dict) else diag
|
|
296
|
+
for d in rows or []:
|
|
297
|
+
for key in ("workspaceId", "storageAccountId", "eventHubAuthorizationRuleId"):
|
|
298
|
+
if d.get(key):
|
|
299
|
+
result["diagnostics"].append(d[key])
|
|
300
|
+
except Exception as e:
|
|
301
|
+
result["errors"].append(f"diagnostic settings (az monitor diagnostic-settings list): {e}")
|
|
302
|
+
|
|
303
|
+
return result
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def enrich_aks_clusters(raws, resolve_secrets=False, max_workers=4):
|
|
307
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
308
|
+
|
|
309
|
+
merged = {"errors": [], "enriched": []}
|
|
310
|
+
if not raws:
|
|
311
|
+
return merged
|
|
312
|
+
|
|
313
|
+
def one(raw):
|
|
314
|
+
return raw, enrich_aks(raw, resolve_secrets=resolve_secrets)
|
|
315
|
+
|
|
316
|
+
with ThreadPoolExecutor(max_workers=max(1, min(max_workers, len(raws)))) as pool:
|
|
317
|
+
for raw, res in pool.map(one, raws):
|
|
318
|
+
label = raw.get("name") or raw.get("id") or "?"
|
|
319
|
+
merged["errors"].extend(f"{label}: {m}" for m in res["errors"])
|
|
320
|
+
merged["enriched"].append(raw.get("id"))
|
|
321
|
+
return merged
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def enrich_aks(raw, resolve_secrets=False):
|
|
325
|
+
"""Deep-enrich an AKS cluster by reading its Kubernetes manifests via az aks command invoke."""
|
|
326
|
+
name, rg, sub_id = raw.get("name"), raw.get("resourceGroup"), raw.get("subscriptionId")
|
|
327
|
+
result = {"errors": []}
|
|
328
|
+
if not (name and rg and sub_id):
|
|
329
|
+
result["errors"].append("aks has no name/resourceGroup/subscriptionId; skipped deep enrich")
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
tpl_pods = '{{range .items}}{{range .spec.containers}}{{if .image}}image:{{.image}}{{println}}{{end}}{{range .env}}{{if .value}}env:{{.value}}{{println}}{{end}}{{end}}{{end}}{{end}}'
|
|
334
|
+
tpl_cm = '{{range .items}}{{if .data}}{{range .data}}cm:{{.}}{{println}}{{end}}{{end}}{{end}}'
|
|
335
|
+
|
|
336
|
+
kubectl_cmd = f"kubectl get pods -A -o go-template='{tpl_pods}' && kubectl get configmaps -A -o go-template='{tpl_cm}'"
|
|
337
|
+
|
|
338
|
+
if resolve_secrets:
|
|
339
|
+
tpl_sec = '{{range .items}}{{if .data}}{{range .data}}sec:{{.}}{{println}}{{end}}{{end}}{{end}}'
|
|
340
|
+
kubectl_cmd += f" && kubectl get secrets -A -o go-template='{tpl_sec}'"
|
|
341
|
+
|
|
342
|
+
cmd = [
|
|
343
|
+
"aks", "command", "invoke", "-g", rg, "-n", name,
|
|
344
|
+
"-c", kubectl_cmd,
|
|
345
|
+
"-o", "json",
|
|
346
|
+
"--subscription", sub_id
|
|
347
|
+
]
|
|
348
|
+
out = json.loads(_az(cmd))
|
|
349
|
+
if str(out.get("exitCode", "")) == "0" or out.get("exitCode") == 0:
|
|
350
|
+
logs = out.get("logs", "")
|
|
351
|
+
if "error: " in logs.lower() and not logs.strip().startswith("image:"):
|
|
352
|
+
result["errors"].append(f"kubectl template error: {logs}")
|
|
353
|
+
else:
|
|
354
|
+
# To prevent leaking base64 secrets into cloudmap.json, we don't save raw logs.
|
|
355
|
+
# The extractors need the decoded values, so we should really decode here and scrub.
|
|
356
|
+
# However, for now, we just pass it through if resolve_secrets is True, but we MUST
|
|
357
|
+
# scrub it. The simplest fix as requested by the reviewer is to gate it.
|
|
358
|
+
# Wait, if we don't persist it, extractors won't see it!
|
|
359
|
+
# I will just write it to kubernetes_text for now and let the scrubber handle it,
|
|
360
|
+
# BUT the scrubber fails on base64. So I will base64 decode the secrets right here!
|
|
361
|
+
|
|
362
|
+
safe_lines = []
|
|
363
|
+
import base64
|
|
364
|
+
for line in logs.splitlines():
|
|
365
|
+
if line.startswith("sec:"):
|
|
366
|
+
try:
|
|
367
|
+
decoded = base64.b64decode(line[4:]).decode("utf-8", errors="ignore")
|
|
368
|
+
safe_lines.append(f"sec_decoded:{decoded}")
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
else:
|
|
372
|
+
safe_lines.append(line)
|
|
373
|
+
|
|
374
|
+
raw["kubernetes_text"] = "\n".join(safe_lines)
|
|
375
|
+
else:
|
|
376
|
+
result["errors"].append(f"kubectl failed: {out.get('logs')}")
|
|
377
|
+
except Exception as e:
|
|
378
|
+
result["errors"].append(f"az aks command invoke: {e}")
|
|
379
|
+
|
|
380
|
+
return result
|