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.
- offsec_ai/__init__.py +91 -0
- offsec_ai/__main__.py +12 -0
- offsec_ai/cli.py +2764 -0
- offsec_ai/core/__init__.py +1 -0
- offsec_ai/core/ai_owasp_scanner.py +389 -0
- offsec_ai/core/cert_analyzer.py +721 -0
- offsec_ai/core/hybrid_identity_checker.py +585 -0
- offsec_ai/core/l7_detector.py +1628 -0
- offsec_ai/core/llm_judge.py +183 -0
- offsec_ai/core/mcp_attacker.py +384 -0
- offsec_ai/core/mcp_scanner.py +506 -0
- offsec_ai/core/mtls_checker.py +990 -0
- offsec_ai/core/owasp_scanner.py +653 -0
- offsec_ai/core/port_scanner.py +277 -0
- offsec_ai/core/security_headers.py +472 -0
- offsec_ai/models/__init__.py +1 -0
- offsec_ai/models/ai_owasp_result.py +161 -0
- offsec_ai/models/l7_result.py +231 -0
- offsec_ai/models/mcp_result.py +148 -0
- offsec_ai/models/mtls_result.py +95 -0
- offsec_ai/models/owasp_result.py +282 -0
- offsec_ai/models/scan_result.py +143 -0
- offsec_ai/py.typed +0 -0
- offsec_ai/utils/__init__.py +1 -0
- offsec_ai/utils/ai_owasp_payloads.py +283 -0
- offsec_ai/utils/ai_owasp_remediation.py +248 -0
- offsec_ai/utils/common_ports.py +316 -0
- offsec_ai/utils/exporters.py +441 -0
- offsec_ai/utils/l7_signatures.py +460 -0
- offsec_ai/utils/mcp_cve_db.py +263 -0
- offsec_ai/utils/mcp_payloads.py +121 -0
- offsec_ai/utils/owasp_remediation.py +787 -0
- offsec_ai-2.0.0.dist-info/METADATA +601 -0
- offsec_ai-2.0.0.dist-info/RECORD +37 -0
- offsec_ai-2.0.0.dist-info/WHEEL +4 -0
- offsec_ai-2.0.0.dist-info/entry_points.txt +2 -0
- offsec_ai-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|