certifyclouds 1.2.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.
cc_scanner/scanner.py ADDED
@@ -0,0 +1,558 @@
1
+ """Core Azure Key Vault scanning logic.
2
+
3
+ Extracted from the CertifyClouds backend discovery service. This module contains
4
+ the pure scanning logic with all database, caching, and settings dependencies removed.
5
+ It authenticates via Azure CLI or DefaultAzureCredential and enumerates subscriptions,
6
+ Key Vaults, and their secrets/certificates/keys.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import concurrent.futures
11
+ import logging
12
+ import os
13
+ from datetime import datetime, timezone
14
+ from typing import Any, Callable
15
+
16
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
17
+
18
+ from .thumbprint import normalize_thumbprint
19
+
20
+ try:
21
+ from azure.core.exceptions import AzureError # type: ignore
22
+ from azure.identity import AzureCliCredential, DefaultAzureCredential # type: ignore
23
+ from azure.keyvault.certificates import CertificateClient # type: ignore
24
+ from azure.keyvault.keys import KeyClient # type: ignore
25
+ from azure.keyvault.secrets import SecretClient # type: ignore
26
+ from azure.mgmt.keyvault import KeyVaultManagementClient # type: ignore
27
+ from azure.mgmt.resource import SubscriptionClient # type: ignore
28
+
29
+ AZURE_SDK_AVAILABLE = True
30
+ except ImportError:
31
+ DefaultAzureCredential = None # type: ignore
32
+ AzureCliCredential = None # type: ignore
33
+ SubscriptionClient = None # type: ignore
34
+ KeyVaultManagementClient = None # type: ignore
35
+ SecretClient = None # type: ignore
36
+ CertificateClient = None # type: ignore
37
+ KeyClient = None # type: ignore
38
+ AzureError = Exception # type: ignore
39
+
40
+ AZURE_SDK_AVAILABLE = False
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ def _utc_now() -> datetime:
46
+ """Return current UTC time (timezone-aware, Python 3.9 compatible)."""
47
+ return datetime.now(timezone.utc)
48
+
49
+
50
+ def _get_azure_credential(auth_method: str = "cli") -> Any:
51
+ """Get the appropriate Azure credential.
52
+
53
+ Args:
54
+ auth_method: "cli" for AzureCliCredential, "default" for DefaultAzureCredential
55
+ """
56
+ if auth_method == "cli" and AzureCliCredential is not None:
57
+ logger.debug("Using AzureCliCredential")
58
+ return AzureCliCredential()
59
+ else:
60
+ logger.debug("Using DefaultAzureCredential")
61
+ return DefaultAzureCredential(exclude_interactive_browser_credential=True)
62
+
63
+
64
+ def _azure_retry_decorator(
65
+ max_retries: int = 1,
66
+ retry_wait_min: int = 1,
67
+ retry_wait_max: int = 2,
68
+ ) -> Callable[[Callable], Callable]:
69
+ """Retry decorator for Azure API calls on transient failures."""
70
+ total_attempts = 1 + max_retries
71
+ retryable_exceptions = (AzureError, TimeoutError, ConnectionError, OSError)
72
+
73
+ return retry(
74
+ retry=retry_if_exception_type(retryable_exceptions),
75
+ stop=stop_after_attempt(total_attempts),
76
+ wait=wait_exponential(multiplier=1, min=retry_wait_min, max=retry_wait_max),
77
+ before_sleep=lambda retry_state: logger.warning(
78
+ "Azure API call failed, retrying... (attempt %s/%s)",
79
+ retry_state.attempt_number,
80
+ total_attempts,
81
+ ),
82
+ reraise=True,
83
+ )
84
+
85
+
86
+ def _log_azure_object_details(obj: Any, label: str, max_depth: int = 2) -> None:
87
+ """Log details about an Azure SDK object for debugging.
88
+
89
+ Only runs when AZURE_SDK_DEBUG=true environment variable is set.
90
+ """
91
+ if os.getenv("AZURE_SDK_DEBUG", "").lower() not in ("1", "true", "yes"):
92
+ return
93
+
94
+ if not obj:
95
+ logger.debug("[AZURE-SDK] %s: None", label)
96
+ return
97
+
98
+ attrs = {}
99
+ for attr_name in dir(obj):
100
+ if attr_name.startswith("_"):
101
+ continue
102
+ try:
103
+ value = getattr(obj, attr_name)
104
+ if callable(value):
105
+ continue
106
+
107
+ type_name = type(value).__name__
108
+ module = type(value).__module__ if hasattr(type(value), "__module__") else ""
109
+
110
+ if value is None:
111
+ formatted_value = "None"
112
+ elif isinstance(value, bytes):
113
+ formatted_value = f"bytes[{len(value)}] = {value.hex()[:32]}..."
114
+ elif isinstance(value, (list, tuple)):
115
+ formatted_value = f"{type_name}[{len(value)}] = {value[:3]}..." if len(value) > 3 else str(value)
116
+ elif isinstance(value, dict):
117
+ formatted_value = f"dict[{len(value)}] = {dict(list(value.items())[:3])}..." if len(value) > 3 else str(value)
118
+ elif isinstance(value, datetime):
119
+ formatted_value = value.isoformat()
120
+ elif hasattr(value, "value"):
121
+ formatted_value = f"{type_name}.{value.name if hasattr(value, 'name') else ''} = {value.value}"
122
+ elif max_depth > 0 and "azure" in str(module).lower():
123
+ formatted_value = f"<{type_name}> (nested Azure object)"
124
+ else:
125
+ formatted_value = str(value)[:100] if len(str(value)) > 100 else str(value)
126
+
127
+ attrs[attr_name] = {"type": f"{module}.{type_name}" if module and module != "builtins" else type_name, "value": formatted_value}
128
+ except Exception as e:
129
+ attrs[attr_name] = {"type": "error", "value": f"Error accessing: {str(e)[:50]}"}
130
+
131
+ logger.debug("[AZURE-SDK] %s (%s): attributes=%s", label, type(obj).__name__, list(attrs.keys()))
132
+ for attr_name, info in sorted(attrs.items()):
133
+ logger.debug("[AZURE-SDK] .%s [%s] = %s", attr_name, info["type"], info["value"])
134
+
135
+
136
+ def scan(
137
+ auth_method: str = "cli",
138
+ subscription_filter: list[str] | None = None,
139
+ vault_filter: list[str] | None = None,
140
+ max_workers: int = 5,
141
+ timeout: int = 300,
142
+ progress_callback: Callable[[str], None] | None = None,
143
+ ) -> dict[str, Any]:
144
+ """Scan Azure Key Vaults across subscriptions.
145
+
146
+ Args:
147
+ auth_method: "cli" for Azure CLI, "default" for DefaultAzureCredential
148
+ subscription_filter: List of subscription IDs to scan (None = all)
149
+ vault_filter: List of vault names to scan (None = all)
150
+ max_workers: Number of parallel scan workers
151
+ timeout: Total scan timeout in seconds
152
+ progress_callback: Optional callback for progress updates
153
+
154
+ Returns:
155
+ Dict with scan results: subscriptions, vaults, secrets, certificates, keys, errors
156
+ """
157
+ if not AZURE_SDK_AVAILABLE:
158
+ logger.error("Azure SDK not installed")
159
+ return {
160
+ "scannedAt": _utc_now().isoformat(),
161
+ "subscriptions": [],
162
+ "totalVaults": 0,
163
+ "totalSecrets": 0,
164
+ "totalCertificates": 0,
165
+ "totalKeys": 0,
166
+ "vaultsWithErrors": 0,
167
+ "error": "Azure SDK not installed. Run: pip install --force-reinstall cc-scanner",
168
+ }
169
+
170
+ try:
171
+ credential = _get_azure_credential(auth_method)
172
+ sub_client = SubscriptionClient(credential)
173
+
174
+ results: dict[str, Any] = {
175
+ "scannedAt": _utc_now().isoformat(),
176
+ "subscriptions": [],
177
+ "totalVaults": 0,
178
+ "totalSecrets": 0,
179
+ "totalCertificates": 0,
180
+ "totalKeys": 0,
181
+ "vaultsWithErrors": 0,
182
+ "errors": [],
183
+ }
184
+
185
+ # Build subscription filter set from CLI args
186
+ allowed_set = set(subscription_filter) if subscription_filter else None
187
+
188
+ # Discover subscriptions
189
+ if progress_callback:
190
+ progress_callback("Discovering subscriptions...")
191
+ discovered = list(sub_client.subscriptions.list())
192
+
193
+ if allowed_set:
194
+ subs_to_scan = [s for s in discovered if s.subscription_id in allowed_set]
195
+ if not subs_to_scan:
196
+ logger.warning("No discovered subscriptions matched filter; scanning none")
197
+ else:
198
+ subs_to_scan = discovered
199
+
200
+ logger.info("Found %s subscription(s) to scan", len(subs_to_scan))
201
+
202
+ # Normalize vault filter names for comparison (lowercase)
203
+ vault_filter_set = None
204
+ if vault_filter:
205
+ vault_filter_set = {name.lower() for name in vault_filter}
206
+ logger.info("Vault filter active: scanning only %s specific vault(s)", len(vault_filter_set))
207
+
208
+ retry_decorator = _azure_retry_decorator()
209
+
210
+ def scan_subscription(sub):
211
+ """Scan a single subscription for vaults and their contents."""
212
+ sub_id = sub.subscription_id
213
+ sub_name = getattr(sub, "display_name", sub_id)
214
+ logger.debug("Scanning subscription %s (%s)", sub_name, sub_id)
215
+
216
+ if progress_callback:
217
+ progress_callback(f"Scanning {sub_name}...")
218
+
219
+ sub_entry = {"id": sub_id, "name": sub_name, "vaults": []}
220
+
221
+ try:
222
+ vault_client = KeyVaultManagementClient(credential, sub_id)
223
+ except Exception as exc:
224
+ logger.error("Failed to create KeyVaultManagementClient for %s: %s", sub_id, exc)
225
+ return sub_entry
226
+
227
+ @retry_decorator
228
+ def list_vaults_with_retry():
229
+ return list(vault_client.vaults.list())
230
+
231
+ try:
232
+ vaults = list_vaults_with_retry()
233
+ except Exception as exc:
234
+ logger.error("Failed to list vaults for subscription %s: %s", sub_id, exc)
235
+ return sub_entry
236
+
237
+ # Filter vaults by name if specified
238
+ if vault_filter_set:
239
+ vaults = [v for v in vaults if getattr(v, "name", "").lower() in vault_filter_set]
240
+
241
+ for vault in vaults:
242
+ vault_name = getattr(vault, "name", None)
243
+ vault_uri = f"https://{vault_name}.vault.azure.net/" if vault_name else None
244
+ logger.debug("Scanning Key Vault %s", vault_name)
245
+
246
+ # Extract resource group from ARM resource ID
247
+ vault_resource_group = None
248
+ vault_id = getattr(vault, "id", None)
249
+ if vault_id:
250
+ try:
251
+ id_lower = vault_id.lower()
252
+ rg_start = id_lower.find("/resourcegroups/")
253
+ if rg_start != -1:
254
+ rg_start += len("/resourcegroups/")
255
+ rg_end = vault_id.find("/", rg_start)
256
+ if rg_end != -1:
257
+ vault_resource_group = vault_id[rg_start:rg_end]
258
+ except (AttributeError, IndexError):
259
+ pass
260
+
261
+ vault_props = getattr(vault, "properties", None)
262
+ vault_entry = {
263
+ "name": vault_name,
264
+ "uri": vault_uri,
265
+ "location": getattr(vault, "location", None),
266
+ "resource_group": vault_resource_group,
267
+ "secrets": [],
268
+ "certificates": [],
269
+ "keys": [],
270
+ "error": None,
271
+ "soft_delete_enabled": getattr(vault_props, "enable_soft_delete", None) if vault_props else None,
272
+ "purge_protection_enabled": getattr(vault_props, "enable_purge_protection", None) if vault_props else None,
273
+ "public_network_access": getattr(vault_props, "public_network_access", None) if vault_props else None,
274
+ "rbac_enabled": getattr(vault_props, "enable_rbac_authorization", None) if vault_props else None,
275
+ "sku": getattr(vault_props.sku, "name", None) if vault_props and hasattr(vault_props, "sku") and vault_props.sku else None,
276
+ }
277
+
278
+ try:
279
+ # --- SECRETS ---
280
+ secret_client = SecretClient(vault_url=vault_uri, credential=credential, verify_challenge_resource=False)
281
+
282
+ @retry_decorator
283
+ def list_secrets_with_retry(client=secret_client):
284
+ return list(client.list_properties_of_secrets())
285
+
286
+ try:
287
+ secret_properties = list_secrets_with_retry()
288
+ for prop in secret_properties:
289
+ _log_azure_object_details(prop, f"SecretProperties {vault_name}/{prop.name}")
290
+
291
+ # Filter out disk encryption keys
292
+ content_type = getattr(prop, "content_type", "") or ""
293
+ if "BEK" in content_type or "Wrapped BEK" in content_type:
294
+ logger.debug("Skipping disk encryption key: %s", prop.name)
295
+ continue
296
+
297
+ # Extract version from ID URL
298
+ version = getattr(prop, "version", None)
299
+ if not version and hasattr(prop, "id") and prop.id:
300
+ try:
301
+ parts = prop.id.rstrip("/").split("/")
302
+ if len(parts) >= 6:
303
+ version = parts[-1]
304
+ except (AttributeError, IndexError):
305
+ version = None
306
+
307
+ secret_details = {
308
+ "item_type": "secret",
309
+ "name": prop.name,
310
+ "version": version,
311
+ "enabled": getattr(prop, "enabled", None),
312
+ "expiresOn": prop.expires_on.isoformat() if getattr(prop, "expires_on", None) else None,
313
+ "createdOn": prop.created_on.isoformat() if getattr(prop, "created_on", None) else None,
314
+ "updatedOn": prop.updated_on.isoformat() if getattr(prop, "updated_on", None) else None,
315
+ "notBefore": prop.not_before.isoformat() if getattr(prop, "not_before", None) else None,
316
+ "contentType": content_type,
317
+ "tags": prop.tags or {},
318
+ "managed": getattr(prop, "managed", None),
319
+ "keyId": getattr(prop, "key_id", None),
320
+ "recoverableDays": getattr(prop, "recoverable_days", None),
321
+ "recoveryLevel": str(prop.recovery_level.value) if hasattr(prop, "recovery_level") and prop.recovery_level and hasattr(prop.recovery_level, "value") else None,
322
+ }
323
+ vault_entry["secrets"].append(secret_details)
324
+ except AzureError as exc:
325
+ cause = str(exc).lower()
326
+ if "forbiddenbyfirewall" in cause or "client address is not authorized" in cause or "firewall" in cause:
327
+ error_msg = "Firewall restriction"
328
+ logger.warning("Access denied to secrets in %s: firewall restriction", vault_name)
329
+ elif "forbidden" in cause or "access denied" in cause or "unauthorized" in cause:
330
+ error_msg = "Access denied (check RBAC permissions)"
331
+ logger.warning("Access denied to secrets in %s: RBAC restriction", vault_name)
332
+ else:
333
+ error_msg = f"Error: {str(exc)[:100]}"
334
+ logger.error("Failed to list secrets from %s: %s", vault_name, exc)
335
+ vault_entry["error"] = error_msg
336
+ results["errors"].append(f"{vault_name}: {error_msg}")
337
+
338
+ # --- CERTIFICATES ---
339
+ certificate_names: set[str] = set()
340
+
341
+ if CertificateClient is not None:
342
+ try:
343
+ cert_client = CertificateClient(vault_url=vault_uri, credential=credential, verify_challenge_resource=False)
344
+
345
+ @retry_decorator
346
+ def list_certificates_with_retry(client=cert_client):
347
+ return list(client.list_properties_of_certificates())
348
+
349
+ try:
350
+ cert_properties = list_certificates_with_retry()
351
+ for cert_prop in cert_properties:
352
+ try:
353
+ cert = cert_client.get_certificate(cert_prop.name)
354
+
355
+ _log_azure_object_details(cert, f"Certificate {vault_name}/{cert_prop.name}")
356
+ _log_azure_object_details(cert.properties if cert else None, f"Certificate.properties {vault_name}/{cert_prop.name}")
357
+ _log_azure_object_details(cert.policy if cert else None, f"Certificate.policy {vault_name}/{cert_prop.name}")
358
+
359
+ cert_version = getattr(cert.properties, "version", None)
360
+ if not cert_version and hasattr(cert, "id") and cert.id:
361
+ try:
362
+ parts = cert.id.rstrip("/").split("/")
363
+ if len(parts) >= 6:
364
+ cert_version = parts[-1]
365
+ except (AttributeError, IndexError):
366
+ cert_version = None
367
+
368
+ cert_details: dict[str, Any] = {
369
+ "item_type": "certificate",
370
+ "name": cert.name,
371
+ "version": cert_version,
372
+ "enabled": cert.properties.enabled,
373
+ "expiresOn": cert.properties.expires_on.isoformat() if cert.properties.expires_on else None,
374
+ "createdOn": cert.properties.created_on.isoformat() if cert.properties.created_on else None,
375
+ "updatedOn": cert.properties.updated_on.isoformat() if cert.properties.updated_on else None,
376
+ "notBefore": cert.properties.not_before.isoformat() if cert.properties.not_before else None,
377
+ "tags": cert.properties.tags or {},
378
+ "recoverableDays": getattr(cert.properties, "recoverable_days", None),
379
+ "recoveryLevel": str(cert.properties.recovery_level.value) if hasattr(cert.properties, "recovery_level") and cert.properties.recovery_level and hasattr(cert.properties.recovery_level, "value") else None,
380
+ }
381
+
382
+ if cert.policy:
383
+ cert_details["cert_subject"] = cert.policy.subject if cert.policy.subject else None
384
+ cert_details["cert_issuer"] = cert.policy.issuer_name if cert.policy.issuer_name else None
385
+
386
+ if cert.policy.key_type:
387
+ key_type = cert.policy.key_type
388
+ cert_details["cert_key_type"] = str(key_type.value) if hasattr(key_type, "value") else str(key_type)
389
+ else:
390
+ cert_details["cert_key_type"] = None
391
+
392
+ cert_details["cert_key_size"] = cert.policy.key_size if cert.policy.key_size else None
393
+
394
+ if cert.policy.content_type:
395
+ ct = cert.policy.content_type
396
+ cert_details["contentType"] = str(ct.value) if hasattr(ct, "value") else str(ct)
397
+
398
+ cert_details["validityInMonths"] = cert.policy.validity_in_months if hasattr(cert.policy, "validity_in_months") else None
399
+ cert_details["exportable"] = cert.policy.exportable if hasattr(cert.policy, "exportable") else None
400
+
401
+ if hasattr(cert.policy, "san_dns_names") and cert.policy.san_dns_names:
402
+ cert_details["cert_san"] = cert.policy.san_dns_names
403
+ else:
404
+ cert_details["cert_san"] = []
405
+
406
+ if hasattr(cert.policy, "key_usage") and cert.policy.key_usage:
407
+ cert_details["keyUsage"] = [
408
+ str(ku.value) if hasattr(ku, "value") else str(ku) for ku in cert.policy.key_usage
409
+ ]
410
+
411
+ if hasattr(cert.policy, "enhanced_key_usage") and cert.policy.enhanced_key_usage:
412
+ cert_details["enhancedKeyUsage"] = cert.policy.enhanced_key_usage
413
+
414
+ # Normalized thumbprint
415
+ if cert.properties.x509_thumbprint:
416
+ cert_details["cert_thumbprint"] = normalize_thumbprint(cert.properties.x509_thumbprint)
417
+ else:
418
+ cert_details["cert_thumbprint"] = None
419
+
420
+ # Self-signed detection
421
+ if cert.policy and cert.policy.issuer_name and cert.policy.subject:
422
+ cert_details["cert_is_self_signed"] = cert.policy.issuer_name.lower() == "self"
423
+ else:
424
+ cert_details["cert_is_self_signed"] = None
425
+
426
+ vault_entry["certificates"].append(cert_details)
427
+ certificate_names.add(cert.name.lower())
428
+ except Exception as cert_exc:
429
+ logger.debug("Failed to get certificate details for %s: %s", cert_prop.name, cert_exc)
430
+ fallback_version = getattr(cert_prop, "version", None)
431
+ if not fallback_version and hasattr(cert_prop, "id") and cert_prop.id:
432
+ try:
433
+ parts = cert_prop.id.rstrip("/").split("/")
434
+ if len(parts) >= 6:
435
+ fallback_version = parts[-1]
436
+ except (AttributeError, IndexError):
437
+ fallback_version = None
438
+ vault_entry["certificates"].append({
439
+ "item_type": "certificate",
440
+ "name": cert_prop.name,
441
+ "version": fallback_version,
442
+ "enabled": cert_prop.enabled,
443
+ "expiresOn": cert_prop.expires_on.isoformat() if cert_prop.expires_on else None,
444
+ "tags": cert_prop.tags or {},
445
+ })
446
+ certificate_names.add(cert_prop.name.lower())
447
+ except AzureError as exc:
448
+ logger.debug("Failed to list certificates from %s: %s", vault_name, exc)
449
+ except Exception as exc:
450
+ logger.debug("Failed to create CertificateClient for %s: %s", vault_name, exc)
451
+
452
+ # --- KEYS ---
453
+ if KeyClient is not None:
454
+ try:
455
+ key_client = KeyClient(vault_url=vault_uri, credential=credential, verify_challenge_resource=False)
456
+
457
+ @retry_decorator
458
+ def list_keys_with_retry(client=key_client):
459
+ return list(client.list_properties_of_keys())
460
+
461
+ try:
462
+ key_properties = list_keys_with_retry()
463
+ for key_prop in key_properties:
464
+ _log_azure_object_details(key_prop, f"KeyProperties {vault_name}/{key_prop.name}")
465
+
466
+ key_version = getattr(key_prop, "version", None)
467
+ if not key_version and hasattr(key_prop, "id") and key_prop.id:
468
+ try:
469
+ parts = key_prop.id.rstrip("/").split("/")
470
+ if len(parts) >= 6:
471
+ key_version = parts[-1]
472
+ except (AttributeError, IndexError):
473
+ key_version = None
474
+
475
+ key_details: dict[str, Any] = {
476
+ "item_type": "key",
477
+ "name": key_prop.name,
478
+ "version": key_version,
479
+ "enabled": key_prop.enabled,
480
+ "expiresOn": key_prop.expires_on.isoformat() if key_prop.expires_on else None,
481
+ "createdOn": key_prop.created_on.isoformat() if getattr(key_prop, "created_on", None) else None,
482
+ "updatedOn": key_prop.updated_on.isoformat() if getattr(key_prop, "updated_on", None) else None,
483
+ "notBefore": key_prop.not_before.isoformat() if getattr(key_prop, "not_before", None) else None,
484
+ "tags": key_prop.tags or {},
485
+ "managed": getattr(key_prop, "managed", None),
486
+ "recoverableDays": getattr(key_prop, "recoverable_days", None),
487
+ "recoveryLevel": str(key_prop.recovery_level.value) if hasattr(key_prop, "recovery_level") and key_prop.recovery_level and hasattr(key_prop.recovery_level, "value") else None,
488
+ "exportable": getattr(key_prop, "exportable", None),
489
+ "hsmPlatform": getattr(key_prop, "hsm_platform", None),
490
+ }
491
+
492
+ if hasattr(key_prop, "key_type") and key_prop.key_type:
493
+ kt = key_prop.key_type
494
+ key_details["key_type"] = str(kt.value) if hasattr(kt, "value") else str(kt)
495
+ else:
496
+ key_details["key_type"] = None
497
+
498
+ vault_entry["keys"].append(key_details)
499
+ except AzureError as exc:
500
+ logger.debug("Failed to list keys from %s: %s", vault_name, exc)
501
+ except Exception as exc:
502
+ logger.debug("Failed to create KeyClient for %s: %s", vault_name, exc)
503
+
504
+ # Filter out certificate-backed secrets (Azure creates a backing secret for each cert)
505
+ if certificate_names:
506
+ original_count = len(vault_entry["secrets"])
507
+ vault_entry["secrets"] = [
508
+ s for s in vault_entry["secrets"] if s["name"].lower() not in certificate_names
509
+ ]
510
+ filtered_count = original_count - len(vault_entry["secrets"])
511
+ if filtered_count > 0:
512
+ logger.debug("Filtered %s certificate-backed secrets from %s", filtered_count, vault_name)
513
+
514
+ except Exception as exc:
515
+ error_msg = f"Failed to connect: {str(exc)[:100]}"
516
+ logger.error("Failed to create SecretClient for %s: %s", vault_name, exc)
517
+ vault_entry["error"] = error_msg
518
+ results["errors"].append(f"{vault_name}: {error_msg}")
519
+
520
+ sub_entry["vaults"].append(vault_entry)
521
+
522
+ return sub_entry
523
+
524
+ # Execute subscription scans in parallel
525
+ worker_count = min(max_workers, len(subs_to_scan)) if subs_to_scan else 1
526
+
527
+ with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as executor:
528
+ future_to_sub = {executor.submit(scan_subscription, sub): sub for sub in subs_to_scan}
529
+
530
+ for future in concurrent.futures.as_completed(future_to_sub, timeout=timeout):
531
+ try:
532
+ sub_entry = future.result()
533
+ results["subscriptions"].append(sub_entry)
534
+ results["totalVaults"] += len(sub_entry["vaults"])
535
+ for vault in sub_entry["vaults"]:
536
+ results["totalSecrets"] += len(vault.get("secrets", []))
537
+ results["totalCertificates"] += len(vault.get("certificates", []))
538
+ results["totalKeys"] += len(vault.get("keys", []))
539
+ if vault.get("error"):
540
+ results["vaultsWithErrors"] += 1
541
+ except Exception as exc:
542
+ sub = future_to_sub[future]
543
+ logger.error("Scan failed for subscription %s: %s", getattr(sub, "display_name", sub.subscription_id), exc)
544
+
545
+ return results
546
+
547
+ except Exception as exc:
548
+ logger.error("Error during scan: %s", exc, exc_info=True)
549
+ return {
550
+ "scannedAt": _utc_now().isoformat(),
551
+ "subscriptions": [],
552
+ "totalVaults": 0,
553
+ "totalSecrets": 0,
554
+ "totalCertificates": 0,
555
+ "totalKeys": 0,
556
+ "vaultsWithErrors": 0,
557
+ "error": str(exc),
558
+ }