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.
@@ -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
@@ -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