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/__init__.py +3 -0
- cc_scanner/auth.py +93 -0
- cc_scanner/cli.py +287 -0
- cc_scanner/config.py +90 -0
- cc_scanner/filters.py +142 -0
- cc_scanner/formatters/__init__.py +19 -0
- cc_scanner/formatters/csv_fmt.py +74 -0
- cc_scanner/formatters/html.py +187 -0
- cc_scanner/formatters/json_fmt.py +36 -0
- cc_scanner/formatters/table.py +203 -0
- cc_scanner/registration.py +91 -0
- cc_scanner/scanner.py +558 -0
- cc_scanner/security.py +175 -0
- cc_scanner/thumbprint.py +98 -0
- certifyclouds-1.2.0.dist-info/METADATA +253 -0
- certifyclouds-1.2.0.dist-info/RECORD +19 -0
- certifyclouds-1.2.0.dist-info/WHEEL +4 -0
- certifyclouds-1.2.0.dist-info/entry_points.txt +2 -0
- certifyclouds-1.2.0.dist-info/licenses/LICENSE +190 -0
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
|
+
}
|