offsec-ai 2.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,585 @@
1
+ """
2
+ Hybrid Identity and ADFS Checker module.
3
+
4
+ This module provides functionality to check if a domain has hybrid identity setup
5
+ and detect ADFS endpoints.
6
+ """
7
+
8
+ import asyncio
9
+ import time
10
+ from typing import Optional, Dict, Any, List
11
+ from urllib.parse import urlparse
12
+
13
+ import aiohttp
14
+ import dns.resolver
15
+ import warnings
16
+ from urllib3.exceptions import InsecureRequestWarning
17
+
18
+ warnings.filterwarnings('ignore', category=InsecureRequestWarning)
19
+
20
+
21
+ class HybridIdentityResult:
22
+ """Result of hybrid identity check."""
23
+
24
+ def __init__(
25
+ self,
26
+ fqdn: str,
27
+ has_hybrid_identity: bool = False,
28
+ has_adfs: bool = False,
29
+ adfs_endpoint: Optional[str] = None,
30
+ adfs_status_code: Optional[int] = None,
31
+ federation_metadata_found: bool = False,
32
+ azure_ad_detected: bool = False,
33
+ openid_config_found: bool = False,
34
+ dns_records: Optional[Dict[str, List[str]]] = None,
35
+ error: Optional[str] = None,
36
+ response_time: float = 0.0
37
+ ):
38
+ """
39
+ Initialize hybrid identity result.
40
+
41
+ Args:
42
+ fqdn: Fully Qualified Domain Name checked
43
+ has_hybrid_identity: Whether hybrid identity setup was detected
44
+ has_adfs: Whether ADFS endpoint was found
45
+ adfs_endpoint: Full ADFS endpoint URL if found
46
+ adfs_status_code: HTTP status code from ADFS endpoint
47
+ federation_metadata_found: Whether federation metadata was found
48
+ azure_ad_detected: Whether Azure AD integration was detected
49
+ openid_config_found: Whether OpenID Connect configuration was found
50
+ dns_records: DNS records found for the domain
51
+ error: Error message if check failed
52
+ response_time: Total response time in seconds
53
+ """
54
+ self.fqdn = fqdn
55
+ self.has_hybrid_identity = has_hybrid_identity
56
+ self.has_adfs = has_adfs
57
+ self.adfs_endpoint = adfs_endpoint
58
+ self.adfs_status_code = adfs_status_code
59
+ self.federation_metadata_found = federation_metadata_found
60
+ self.azure_ad_detected = azure_ad_detected
61
+ self.openid_config_found = openid_config_found
62
+ self.dns_records = dns_records or {}
63
+ self.error = error
64
+ self.response_time = response_time
65
+
66
+ def to_dict(self) -> Dict[str, Any]:
67
+ """Convert result to dictionary."""
68
+ return {
69
+ "fqdn": self.fqdn,
70
+ "has_hybrid_identity": self.has_hybrid_identity,
71
+ "has_adfs": self.has_adfs,
72
+ "adfs_endpoint": self.adfs_endpoint,
73
+ "adfs_status_code": self.adfs_status_code,
74
+ "federation_metadata_found": self.federation_metadata_found,
75
+ "azure_ad_detected": self.azure_ad_detected,
76
+ "openid_config_found": self.openid_config_found,
77
+ "dns_records": self.dns_records,
78
+ "error": self.error,
79
+ "response_time": self.response_time
80
+ }
81
+
82
+
83
+ class HybridIdentityChecker:
84
+ """Checker for hybrid identity and ADFS endpoints."""
85
+
86
+ def __init__(self, timeout: float = 10.0):
87
+ """
88
+ Initialize the hybrid identity checker.
89
+
90
+ Args:
91
+ timeout: Request timeout in seconds
92
+ """
93
+ self.timeout = timeout
94
+ self.user_agent = "SimplePortChecker/1.0 (Hybrid Identity Scanner)"
95
+
96
+ async def check(self, fqdn: str) -> HybridIdentityResult:
97
+ """
98
+ Check if FQDN has hybrid identity setup.
99
+
100
+ This checks for:
101
+ - ADFS endpoints via Azure AD login flow (most reliable method)
102
+ - ADFS endpoints (/adfs/ls)
103
+ - Federation metadata
104
+ - Azure AD integration
105
+ - OpenID Connect configuration
106
+
107
+ Args:
108
+ fqdn: Fully Qualified Domain Name to check
109
+
110
+ Returns:
111
+ HybridIdentityResult with detection results
112
+ """
113
+ start_time = time.time()
114
+
115
+ # Clean FQDN (remove protocol if present)
116
+ fqdn = fqdn.replace("https://", "").replace("http://", "").split("/")[0].split(":")[0]
117
+
118
+ # Initialize result flags
119
+ has_adfs = False
120
+ adfs_endpoint = None
121
+ adfs_status_code = None
122
+ federation_metadata_found = False
123
+ azure_ad_detected = False
124
+ openid_config_found = False
125
+ error = None
126
+ dns_records = {}
127
+
128
+ try:
129
+ # Check DNS records first
130
+ dns_records = await self._check_dns_records(fqdn)
131
+
132
+ # PRIORITY 1: Check ADFS via Azure AD login flow (most reliable method)
133
+ # This simulates what happens when you try to login at portal.azure.com
134
+ azure_flow_result = await self._check_adfs_via_azure_login(fqdn)
135
+ if azure_flow_result["found"]:
136
+ has_adfs = True
137
+ adfs_endpoint = azure_flow_result["endpoint"]
138
+ adfs_status_code = azure_flow_result["status_code"]
139
+
140
+ # PRIORITY 2: If not found via Azure flow, check direct ADFS endpoints
141
+ if not has_adfs:
142
+ adfs_result = await self._check_adfs_endpoint(fqdn)
143
+ has_adfs = adfs_result["found"]
144
+ adfs_endpoint = adfs_result["endpoint"]
145
+ adfs_status_code = adfs_result["status_code"]
146
+
147
+ # Check for federation metadata
148
+ federation_metadata_found = await self._check_federation_metadata(fqdn)
149
+
150
+ # Check for Azure AD integration
151
+ azure_ad_detected = await self._check_azure_ad_integration(fqdn)
152
+
153
+ # Check for OpenID Connect configuration
154
+ openid_config_found = await self._check_openid_config(fqdn)
155
+
156
+ # Determine if hybrid identity is present
157
+ has_hybrid_identity = (
158
+ has_adfs or
159
+ federation_metadata_found or
160
+ azure_ad_detected or
161
+ openid_config_found
162
+ )
163
+
164
+ except Exception as e:
165
+ error = str(e)
166
+
167
+ response_time = time.time() - start_time
168
+
169
+ return HybridIdentityResult(
170
+ fqdn=fqdn,
171
+ has_hybrid_identity=has_hybrid_identity,
172
+ has_adfs=has_adfs,
173
+ adfs_endpoint=adfs_endpoint,
174
+ adfs_status_code=adfs_status_code,
175
+ federation_metadata_found=federation_metadata_found,
176
+ azure_ad_detected=azure_ad_detected,
177
+ openid_config_found=openid_config_found,
178
+ dns_records=dns_records,
179
+ error=error,
180
+ response_time=response_time
181
+ )
182
+
183
+ async def _check_dns_records(self, fqdn: str) -> Dict[str, List[str]]:
184
+ """
185
+ Check DNS records for hybrid identity indicators.
186
+
187
+ Args:
188
+ fqdn: Domain to check
189
+
190
+ Returns:
191
+ Dictionary of DNS records
192
+ """
193
+ dns_records = {}
194
+
195
+ try:
196
+ # Check A records
197
+ try:
198
+ answers = dns.resolver.resolve(fqdn, 'A')
199
+ dns_records['A'] = [str(rdata) for rdata in answers]
200
+ except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
201
+ pass
202
+
203
+ # Check CNAME records
204
+ try:
205
+ answers = dns.resolver.resolve(fqdn, 'CNAME')
206
+ dns_records['CNAME'] = [str(rdata) for rdata in answers]
207
+ except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
208
+ pass
209
+
210
+ # Check TXT records (often contain MS verification)
211
+ try:
212
+ answers = dns.resolver.resolve(fqdn, 'TXT')
213
+ txt_records = [str(rdata).strip('"') for rdata in answers]
214
+ dns_records['TXT'] = txt_records
215
+
216
+ # Check for Microsoft verification records
217
+ for txt in txt_records:
218
+ if 'MS=' in txt or 'ms-domain-verification' in txt.lower():
219
+ dns_records['microsoft_verification'] = True
220
+ break
221
+ except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
222
+ pass
223
+
224
+ # Check MX records (may indicate Microsoft 365)
225
+ try:
226
+ answers = dns.resolver.resolve(fqdn, 'MX')
227
+ mx_records = [str(rdata.exchange) for rdata in answers]
228
+ dns_records['MX'] = mx_records
229
+
230
+ # Check for Microsoft mail servers
231
+ for mx in mx_records:
232
+ if 'outlook.com' in mx.lower() or 'microsoft.com' in mx.lower():
233
+ dns_records['microsoft_mail'] = True
234
+ break
235
+ except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
236
+ pass
237
+
238
+ # Check for common ADFS subdomains
239
+ adfs_subdomains = ['adfs', 'sts', 'federation', 'fs']
240
+ for subdomain in adfs_subdomains:
241
+ try:
242
+ test_fqdn = f"{subdomain}.{fqdn}"
243
+ answers = dns.resolver.resolve(test_fqdn, 'A')
244
+ if not dns_records.get('adfs_subdomains'):
245
+ dns_records['adfs_subdomains'] = []
246
+ dns_records['adfs_subdomains'].append(subdomain)
247
+ except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
248
+ pass
249
+
250
+ except Exception as e:
251
+ dns_records['error'] = str(e)
252
+
253
+ return dns_records
254
+
255
+ async def _check_adfs_via_azure_login(self, fqdn: str) -> Dict[str, Any]:
256
+ """
257
+ Check for ADFS endpoint by following Azure AD login flow.
258
+
259
+ This simulates what happens when you try to login at Azure Portal:
260
+ 1. Navigate to login.microsoftonline.com
261
+ 2. Submit username like test@fqdn
262
+ 3. Azure AD checks if domain is federated
263
+ 4. If federated, redirects to ADFS endpoint
264
+
265
+ Args:
266
+ fqdn: Domain to check
267
+
268
+ Returns:
269
+ Dictionary with found status, endpoint, and status code
270
+ """
271
+ result = {
272
+ "found": False,
273
+ "endpoint": None,
274
+ "status_code": None
275
+ }
276
+
277
+ connector = aiohttp.TCPConnector(ssl=False)
278
+ timeout = aiohttp.ClientTimeout(total=self.timeout)
279
+
280
+ async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
281
+ try:
282
+ # Step 1: Get the Azure AD login page to obtain necessary tokens/cookies
283
+ # This mimics the initial Azure Portal login flow
284
+ login_url = "https://login.microsoftonline.com/common/oauth2/authorize"
285
+ params = {
286
+ "client_id": "00000002-0000-0000-c000-000000000000", # Azure Management Portal client ID
287
+ "response_type": "code",
288
+ "redirect_uri": "https://portal.azure.com/",
289
+ "resource": "https://management.core.windows.net/",
290
+ }
291
+ headers = {
292
+ "User-Agent": self.user_agent,
293
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
294
+ }
295
+
296
+ async with session.get(login_url, params=params, headers=headers, allow_redirects=True) as response:
297
+ # Get any cookies or context we need
298
+ pass
299
+
300
+ # Step 2: Check user realm for the domain
301
+ # This API tells us if a domain is managed or federated
302
+ realm_url = f"https://login.microsoftonline.com/common/userrealm/test@{fqdn}"
303
+ params_realm = {
304
+ "api-version": "2.0",
305
+ "checkForMicrosoftAccount": "false"
306
+ }
307
+
308
+ async with session.get(realm_url, params=params_realm, headers=headers) as response:
309
+ if response.status == 200:
310
+ try:
311
+ realm_data = await response.json()
312
+
313
+ # Check if domain is federated
314
+ account_type = realm_data.get("NameSpaceType", "")
315
+ is_federated = account_type == "Federated"
316
+
317
+ if is_federated:
318
+ # Extract ADFS endpoint from realm data
319
+ auth_url = realm_data.get("AuthURL", "")
320
+ federation_brand_name = realm_data.get("FederationBrandName", "")
321
+
322
+ if auth_url:
323
+ result["found"] = True
324
+ result["endpoint"] = auth_url
325
+ result["status_code"] = 200
326
+ result["federation_brand"] = federation_brand_name
327
+
328
+ # Try to verify the ADFS endpoint is actually accessible
329
+ try:
330
+ async with session.get(auth_url, headers=headers, timeout=aiohttp.ClientTimeout(total=5)) as adfs_response:
331
+ result["status_code"] = adfs_response.status
332
+ except:
333
+ pass # Endpoint found but might not be publicly accessible
334
+
335
+ return result
336
+
337
+ except Exception:
338
+ pass # JSON parsing failed, continue to other methods
339
+
340
+ # Step 3: Alternative method - check GetCredentialType API
341
+ # This is used by the modern Azure AD login experience
342
+ cred_type_url = "https://login.microsoftonline.com/common/GetCredentialType"
343
+ cred_data = {
344
+ "username": f"test@{fqdn}",
345
+ "isOtherIdpSupported": True,
346
+ "checkPhones": False,
347
+ "isRemoteNGCSupported": True,
348
+ "isCookieBannerShown": False,
349
+ "isFidoSupported": False,
350
+ "originalRequest": "",
351
+ "flowToken": ""
352
+ }
353
+ headers["Content-Type"] = "application/json"
354
+
355
+ async with session.post(cred_type_url, json=cred_data, headers=headers) as response:
356
+ if response.status == 200:
357
+ try:
358
+ cred_type_data = await response.json()
359
+
360
+ # Check for federation information
361
+ if cred_type_data.get("ThrottleStatus") != 1: # Not throttled
362
+ # Check if federated
363
+ if cred_type_data.get("IfExistsResult") == 0: # User exists
364
+ # Look for federation redirect URL
365
+ federation_redirect = cred_type_data.get("Credentials", {}).get("FederationRedirectUrl")
366
+ if federation_redirect:
367
+ result["found"] = True
368
+ result["endpoint"] = federation_redirect
369
+ result["status_code"] = 200
370
+ return result
371
+
372
+ except Exception:
373
+ pass
374
+
375
+ except (aiohttp.ClientError, asyncio.TimeoutError):
376
+ pass
377
+
378
+ return result
379
+
380
+ async def _check_adfs_endpoint(self, fqdn: str) -> Dict[str, Any]:
381
+ """
382
+ Check for ADFS endpoint at /adfs/ls.
383
+
384
+ Args:
385
+ fqdn: Domain to check
386
+
387
+ Returns:
388
+ Dictionary with found status, endpoint, and status code
389
+ """
390
+ result = {
391
+ "found": False,
392
+ "endpoint": None,
393
+ "status_code": None
394
+ }
395
+
396
+ # Common ADFS paths to check
397
+ adfs_paths = [
398
+ "/adfs/ls",
399
+ "/adfs/ls/IdpInitiatedSignOn.aspx",
400
+ "/adfs/ls/IdpInitiatedSignon.aspx",
401
+ ]
402
+
403
+ # Try with potential ADFS subdomains and the main domain
404
+ hosts_to_check = [fqdn]
405
+ adfs_subdomains = ['adfs', 'sts', 'federation', 'fs']
406
+ for subdomain in adfs_subdomains:
407
+ hosts_to_check.append(f"{subdomain}.{fqdn}")
408
+
409
+ connector = aiohttp.TCPConnector(ssl=False)
410
+ timeout = aiohttp.ClientTimeout(total=self.timeout)
411
+
412
+ async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
413
+ for host in hosts_to_check:
414
+ for path in adfs_paths:
415
+ try:
416
+ url = f"https://{host}{path}"
417
+ headers = {"User-Agent": self.user_agent}
418
+
419
+ async with session.get(url, headers=headers, allow_redirects=True) as response:
420
+ # ADFS typically returns 200, 302, or 401
421
+ if response.status in [200, 302, 401, 403]:
422
+ content = await response.text()
423
+
424
+ # Check for ADFS indicators in response
425
+ adfs_indicators = [
426
+ 'adfs',
427
+ 'microsoft.identityserver',
428
+ 'idpinitiated',
429
+ 'federationmetadata',
430
+ 'claimsawareness',
431
+ 'ws-federation'
432
+ ]
433
+
434
+ content_lower = content.lower()
435
+ if any(indicator in content_lower for indicator in adfs_indicators):
436
+ result["found"] = True
437
+ result["endpoint"] = url
438
+ result["status_code"] = response.status
439
+ return result
440
+
441
+ except (aiohttp.ClientError, asyncio.TimeoutError):
442
+ continue
443
+
444
+ return result
445
+
446
+ async def _check_federation_metadata(self, fqdn: str) -> bool:
447
+ """
448
+ Check for WS-Federation metadata endpoint.
449
+
450
+ Args:
451
+ fqdn: Domain to check
452
+
453
+ Returns:
454
+ True if federation metadata found
455
+ """
456
+ metadata_paths = [
457
+ "/FederationMetadata/2007-06/FederationMetadata.xml",
458
+ "/adfs/services/trust/2005/windowstransport",
459
+ "/adfs/services/trust/mex",
460
+ ]
461
+
462
+ hosts_to_check = [fqdn, f"adfs.{fqdn}", f"sts.{fqdn}", f"federation.{fqdn}"]
463
+
464
+ connector = aiohttp.TCPConnector(ssl=False)
465
+ timeout = aiohttp.ClientTimeout(total=self.timeout)
466
+
467
+ async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
468
+ for host in hosts_to_check:
469
+ for path in metadata_paths:
470
+ try:
471
+ url = f"https://{host}{path}"
472
+ headers = {"User-Agent": self.user_agent}
473
+
474
+ async with session.get(url, headers=headers) as response:
475
+ if response.status == 200:
476
+ content = await response.text()
477
+
478
+ # Check for federation metadata indicators
479
+ if 'EntityDescriptor' in content or 'federationmetadata' in content.lower():
480
+ return True
481
+
482
+ except (aiohttp.ClientError, asyncio.TimeoutError):
483
+ continue
484
+
485
+ return False
486
+
487
+ async def _check_azure_ad_integration(self, fqdn: str) -> bool:
488
+ """
489
+ Check for Azure AD integration indicators.
490
+
491
+ Args:
492
+ fqdn: Domain to check
493
+
494
+ Returns:
495
+ True if Azure AD integration detected
496
+ """
497
+ # Check login endpoints that redirect to Azure AD
498
+ azure_paths = [
499
+ "/",
500
+ "/login",
501
+ "/signin",
502
+ "/_login",
503
+ ]
504
+
505
+ connector = aiohttp.TCPConnector(ssl=False)
506
+ timeout = aiohttp.ClientTimeout(total=self.timeout)
507
+
508
+ async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
509
+ for path in azure_paths:
510
+ try:
511
+ url = f"https://{fqdn}{path}"
512
+ headers = {"User-Agent": self.user_agent}
513
+
514
+ async with session.get(url, headers=headers, allow_redirects=False) as response:
515
+ # Check for Azure AD redirect
516
+ if 'location' in response.headers:
517
+ location = response.headers['location'].lower()
518
+ if 'login.microsoftonline.com' in location or 'login.windows.net' in location:
519
+ return True
520
+
521
+ # Check response headers for Azure AD indicators
522
+ for header, value in response.headers.items():
523
+ if 'azure' in header.lower() or 'azure' in str(value).lower():
524
+ if 'ad' in str(value).lower() or 'activedirectory' in str(value).lower():
525
+ return True
526
+
527
+ except (aiohttp.ClientError, asyncio.TimeoutError):
528
+ continue
529
+
530
+ return False
531
+
532
+ async def _check_openid_config(self, fqdn: str) -> bool:
533
+ """
534
+ Check for OpenID Connect configuration endpoint.
535
+
536
+ Args:
537
+ fqdn: Domain to check
538
+
539
+ Returns:
540
+ True if OpenID configuration found
541
+ """
542
+ openid_paths = [
543
+ "/.well-known/openid-configuration",
544
+ "/adfs/.well-known/openid-configuration",
545
+ ]
546
+
547
+ hosts_to_check = [fqdn, f"adfs.{fqdn}", f"sts.{fqdn}"]
548
+
549
+ connector = aiohttp.TCPConnector(ssl=False)
550
+ timeout = aiohttp.ClientTimeout(total=self.timeout)
551
+
552
+ async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
553
+ for host in hosts_to_check:
554
+ for path in openid_paths:
555
+ try:
556
+ url = f"https://{host}{path}"
557
+ headers = {"User-Agent": self.user_agent}
558
+
559
+ async with session.get(url, headers=headers) as response:
560
+ if response.status == 200:
561
+ try:
562
+ data = await response.json()
563
+ # Check for OpenID configuration fields
564
+ if 'issuer' in data and 'authorization_endpoint' in data:
565
+ return True
566
+ except:
567
+ pass
568
+
569
+ except (aiohttp.ClientError, asyncio.TimeoutError):
570
+ continue
571
+
572
+ return False
573
+
574
+ async def batch_check(self, fqdns: List[str]) -> List[HybridIdentityResult]:
575
+ """
576
+ Check multiple FQDNs for hybrid identity.
577
+
578
+ Args:
579
+ fqdns: List of FQDNs to check
580
+
581
+ Returns:
582
+ List of HybridIdentityResult
583
+ """
584
+ tasks = [self.check(fqdn) for fqdn in fqdns]
585
+ return await asyncio.gather(*tasks)