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/cli.py ADDED
@@ -0,0 +1,2764 @@
1
+ """
2
+ Command Line Interface for offsec-ai.
3
+
4
+ Comprehensive offensive-security CLI: port scanning, L7/WAF detection, mTLS, certificate
5
+ analysis, OWASP Top 10, AI/LLM OWASP Top 10 black-box probing, and MCP endpoint security.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import sys
11
+ import time
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import List, Optional, Dict, Any
15
+
16
+ import click
17
+ import dns.resolver
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+ from rich.progress import (
21
+ Progress,
22
+ SpinnerColumn,
23
+ TextColumn,
24
+ BarColumn,
25
+ TimeElapsedColumn,
26
+ )
27
+ from rich.panel import Panel
28
+ from rich.text import Text
29
+
30
+ from .core.port_scanner import PortChecker, ScanConfig
31
+ from .core.l7_detector import L7Detector
32
+ from .core.mtls_checker import MTLSChecker
33
+ from .core.cert_analyzer import CertificateAnalyzer
34
+ from .core.hybrid_identity_checker import HybridIdentityChecker, HybridIdentityResult
35
+ from .core.owasp_scanner import OwaspScanner
36
+ from .core.ai_owasp_scanner import LLMOwaspScanner
37
+ from .core.mcp_scanner import MCPScanner
38
+ from .core.mcp_attacker import MCPAttacker, AuthorizationRequired
39
+ from .core.llm_judge import LLMJudge
40
+ from .models.scan_result import ScanResult, BatchScanResult
41
+ from .models.l7_result import L7Result, BatchL7Result
42
+ from .models.mtls_result import MTLSResult, BatchMTLSResult
43
+ from .models.owasp_result import OwaspScanResult, SeverityLevel
44
+ from .models.ai_owasp_result import LLMScanResult, LLMScanMode, LLMSeverity
45
+ from .models.mcp_result import MCPScanResult, MCPAttackReport, MCPVulnSeverity
46
+ from .utils.common_ports import TOP_PORTS, get_service_name, get_port_description
47
+ from .utils.exporters import OwaspPdfExporter, export_to_csv, export_to_json
48
+ from . import __version__
49
+
50
+
51
+ console = Console()
52
+
53
+ LOGO = r"""[bold red]
54
+ ██████╗ ███████╗███████╗███████╗███████╗ ██████╗ █████╗ ██╗
55
+ ██╔═══██╗██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝ ██╔══██╗██║
56
+ ██║ ██║█████╗ █████╗ ███████╗█████╗ ██║ █████╗███████║██║
57
+ ██║ ██║██╔══╝ ██╔══╝ ╚════██║██╔══╝ ██║ ╚════╝██╔══██║██║
58
+ ╚██████╔╝██║ ██║ ███████║███████╗╚██████╗ ██║ ██║██║
59
+ ╚═════╝ ╚═╝ ╚═╝ ╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝[/bold red]
60
+ [dim] Offensive-Security Toolkit · AI/LLM · MCP · Red-Team [/dim]"""
61
+
62
+ def _print_logo() -> None:
63
+ console.print(LOGO)
64
+ console.print()
65
+
66
+
67
+ class LogoGroup(click.Group):
68
+ """Click Group that prints the ASCII logo before every invocation."""
69
+
70
+ def make_context(self, info_name, args, **kwargs):
71
+ _print_logo()
72
+ return super().make_context(info_name, args, **kwargs)
73
+
74
+
75
+ @click.group(cls=LogoGroup)
76
+ @click.version_option(version=__version__)
77
+ def main():
78
+ """offsec-ai — offensive-security toolkit for authorized red-team engagements."""
79
+ pass
80
+
81
+
82
+ @main.command()
83
+ @click.argument("targets", nargs=-1, required=True)
84
+ @click.option("--ports", "-p", help="Comma-separated list of ports to scan")
85
+ @click.option("--timeout", "-t", default=3, help="Connection timeout in seconds")
86
+ @click.option("--concurrent", "-c", default=100, help="Maximum concurrent connections")
87
+ @click.option("--output", "-o", help="Output file (JSON format)")
88
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
89
+ @click.option("--top-ports", is_flag=True, help="Scan top 25 most common ports")
90
+ def scan(targets, ports, timeout, concurrent, output, verbose, top_ports):
91
+ """Scan target hosts for open ports."""
92
+
93
+ # Parse ports
94
+ if top_ports:
95
+ port_list = TOP_PORTS[:25]
96
+ elif ports:
97
+ try:
98
+ port_list = [int(p.strip()) for p in ports.split(",")]
99
+ except ValueError:
100
+ console.print(
101
+ "[red]Error: Invalid port format. Use comma-separated numbers.[/red]"
102
+ )
103
+ sys.exit(1)
104
+ else:
105
+ port_list = TOP_PORTS
106
+
107
+ console.print(f"[blue]Starting port scan for {len(targets)} target(s)[/blue]")
108
+ console.print(f"[yellow]Ports to scan: {len(port_list)} ports[/yellow]")
109
+ console.print(f"[yellow]Timeout: {timeout}s, Concurrent: {concurrent}[/yellow]")
110
+
111
+ # Run scan
112
+ asyncio.run(
113
+ _run_port_scan(list(targets), port_list, timeout, concurrent, output, verbose)
114
+ )
115
+
116
+
117
+ @main.command("l7-check")
118
+ @click.argument("targets", nargs=-1, required=True)
119
+ @click.option("--timeout", "-t", default=10, help="Request timeout in seconds")
120
+ @click.option("--user-agent", "-u", help="Custom User-Agent string")
121
+ @click.option("--output", "-o", help="Output file (JSON format)")
122
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
123
+ @click.option("--port", "-p", type=int, help="Specific port to check")
124
+ @click.option("--path", default="/", help="URL path to test")
125
+ @click.option("--trace-dns", "-d", is_flag=True, help="Include DNS trace information in results")
126
+ def l7_check(targets, timeout, user_agent, output, verbose, port, path, trace_dns):
127
+ """Check for L7 protection services (WAF, CDN, etc.)."""
128
+
129
+ console.print(
130
+ f"[blue]Starting L7 protection check for {len(targets)} target(s)[/blue]"
131
+ )
132
+ console.print(f"[yellow]Timeout: {timeout}s[/yellow]")
133
+
134
+ if trace_dns:
135
+ console.print("[yellow]DNS trace enabled - will check DNS records and resolved IPs[/yellow]")
136
+
137
+ # Run L7 detection
138
+ asyncio.run(
139
+ _run_l7_detection(
140
+ list(targets), timeout, user_agent, output, verbose, port, path, trace_dns
141
+ )
142
+ )
143
+
144
+
145
+ @main.command("full-scan")
146
+ @click.argument("targets", nargs=-1, required=True)
147
+ @click.option("--ports", "-p", help="Comma-separated list of ports to scan")
148
+ @click.option("--timeout", "-t", default=5, help="Connection timeout in seconds")
149
+ @click.option("--concurrent", "-c", default=50, help="Maximum concurrent connections")
150
+ @click.option("--output", "-o", help="Output file (JSON format)")
151
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
152
+ def full_scan(targets, ports, timeout, concurrent, output, verbose):
153
+ """Perform both port scanning and L7 protection detection."""
154
+
155
+ console.print(f"[blue]Starting full scan for {len(targets)} target(s)[/blue]")
156
+
157
+ # Parse ports
158
+ if ports:
159
+ try:
160
+ port_list = [int(p.strip()) for p in ports.split(",")]
161
+ except ValueError:
162
+ console.print(
163
+ "[red]Error: Invalid port format. Use comma-separated numbers.[/red]"
164
+ )
165
+ sys.exit(1)
166
+ else:
167
+ port_list = TOP_PORTS
168
+
169
+ # Run full scan
170
+ asyncio.run(
171
+ _run_full_scan(list(targets), port_list, timeout, concurrent, output, verbose)
172
+ )
173
+
174
+
175
+ @main.command("dns-trace")
176
+ @click.argument("targets", nargs=-1, required=True)
177
+ @click.option("--timeout", "-t", default=5, help="Request timeout in seconds")
178
+ @click.option("--output", "-o", help="Output file (JSON format)")
179
+ @click.option("--check-protection", "-c", is_flag=True, help="Check each resolved IP for L7 protection")
180
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
181
+ def dns_trace(targets, timeout, output, check_protection, verbose):
182
+ """Trace DNS records and analyze L7 protection on resolved IPs."""
183
+
184
+ console.print(
185
+ f"[blue]Starting DNS trace for {len(targets)} target(s)[/blue]"
186
+ )
187
+ console.print(f"[yellow]Timeout: {timeout}s[/yellow]")
188
+
189
+ if check_protection:
190
+ console.print("[yellow]L7 protection analysis enabled[/yellow]")
191
+
192
+ # Run DNS trace analysis
193
+ asyncio.run(
194
+ _run_dns_trace_analysis(list(targets), timeout, output, check_protection, verbose)
195
+ )
196
+
197
+
198
+ @main.command("mtls-check")
199
+ @click.argument("targets", nargs=-1, required=True)
200
+ @click.option("--port", "-p", default=443, help="Target port (default: 443)")
201
+ @click.option("--timeout", "-t", default=10, help="Connection timeout in seconds (1-300)")
202
+ @click.option("--client-cert", help="Path to client certificate file (PEM format)")
203
+ @click.option("--client-key", help="Path to client private key file (PEM format)")
204
+ @click.option("--ca-bundle", help="Path to CA bundle file for certificate verification")
205
+ @click.option("--output", "-o", help="Output file (JSON format)")
206
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output with detailed certificate information")
207
+ @click.option("--no-verify", is_flag=True, help="Disable SSL certificate verification (use with caution)")
208
+ @click.option("--concurrent", "-c", default=10, help="Maximum concurrent connections (1-50)")
209
+ @click.option("--max-retries", default=3, help="Maximum retry attempts for failed connections (0-10)")
210
+ @click.option("--retry-delay", default=1.0, help="Delay between retries in seconds (0.1-10.0)")
211
+ def mtls_check(targets, port, timeout, client_cert, client_key, ca_bundle, output, verbose, no_verify, concurrent, max_retries, retry_delay):
212
+ """
213
+ Check for mTLS (Mutual TLS) authentication support and requirements.
214
+
215
+ This command performs comprehensive mTLS analysis including:
216
+ - Server certificate validation and parsing
217
+ - Client certificate requirement detection
218
+ - Mutual authentication testing (with client certificates)
219
+ - Performance and reliability metrics
220
+
221
+ Examples:
222
+ \b
223
+ # Basic mTLS check
224
+ offsec-ai mtls-check api.example.com
225
+
226
+ # Check with client certificates
227
+ offsec-ai mtls-check api.example.com --client-cert client.crt --client-key client.key
228
+
229
+ # Batch check multiple APIs
230
+ offsec-ai mtls-check api1.com api2.com:8443 --concurrent 10 --verbose
231
+
232
+ # Enterprise security audit
233
+ offsec-ai mtls-check $(cat production-apis.txt) --output audit-results.json
234
+
235
+ # Custom configuration
236
+ offsec-ai mtls-check api.example.com --timeout 30 --max-retries 5 --retry-delay 2.0
237
+
238
+ Exit Codes:
239
+ 0: All checks completed successfully
240
+ 1: Some checks failed or errors occurred
241
+ """
242
+
243
+ console.print(
244
+ f"[blue]Starting mTLS check for {len(targets)} target(s)[/blue]"
245
+ )
246
+ console.print(f"[yellow]Port: {port}, Timeout: {timeout}s, Retries: {max_retries}[/yellow]")
247
+
248
+ if client_cert and client_key:
249
+ console.print(f"[yellow]Using client certificate: {client_cert}[/yellow]")
250
+ else:
251
+ console.print("[yellow]No client certificates provided - checking server requirements only[/yellow]")
252
+
253
+ if no_verify:
254
+ console.print("[red]⚠️ SSL certificate verification disabled[/red]")
255
+
256
+ # Run mTLS check
257
+ asyncio.run(
258
+ _run_mtls_check(
259
+ list(targets), port, timeout, client_cert, client_key,
260
+ ca_bundle, output, verbose, not no_verify, concurrent, max_retries, retry_delay
261
+ )
262
+ )
263
+
264
+
265
+ @main.command("mtls-gen-cert")
266
+ @click.argument("hostname")
267
+ @click.option("--cert-path", default="client.crt", help="Output certificate file path")
268
+ @click.option("--key-path", default="client.key", help="Output private key file path")
269
+ @click.option("--days", default=365, help="Certificate validity in days (1-7300)")
270
+ @click.option("--key-size", default=2048, help="RSA key size in bits (2048, 3072, 4096)")
271
+ @click.option("--country", default="US", help="Country code for certificate subject")
272
+ @click.option("--organization", default="Test Org", help="Organization name for certificate subject")
273
+ def mtls_gen_cert(hostname, cert_path, key_path, days, key_size, country, organization):
274
+ """
275
+ Generate a self-signed certificate for mTLS testing.
276
+
277
+ Creates a production-grade self-signed certificate and private key suitable for
278
+ mTLS testing and development. The certificate includes proper subject alternative
279
+ names and modern cryptographic parameters.
280
+
281
+ Examples:
282
+ \b
283
+ # Basic certificate generation
284
+ offsec-ai mtls-gen-cert test-client.example.com
285
+
286
+ # Custom validity period and key size
287
+ offsec-ai mtls-gen-cert api-client.com --days 90 --key-size 4096
288
+
289
+ # Custom output paths
290
+ offsec-ai mtls-gen-cert client.internal --cert-path /etc/ssl/client.crt --key-path /etc/ssl/private/client.key
291
+
292
+ # Custom subject information
293
+ offsec-ai mtls-gen-cert test.company.com --country GB --organization "ACME Corp"
294
+
295
+ Security Notes:
296
+ - Use strong key sizes (2048+ bits) for production
297
+ - Store private keys securely with appropriate file permissions
298
+ - Regularly rotate certificates in production environments
299
+ - Self-signed certificates should only be used for testing
300
+ """
301
+
302
+ console.print(f"[blue]Generating self-signed certificate for {hostname}[/blue]")
303
+ console.print(f"[yellow]Key size: {key_size} bits, Valid for: {days} days[/yellow]")
304
+
305
+ from .core.mtls_checker import generate_self_signed_cert
306
+
307
+ if generate_self_signed_cert(hostname, cert_path, key_path, days):
308
+ console.print(f"[green]✅ Certificate generated successfully:[/green]")
309
+ console.print(f" 📄 Certificate: {cert_path}")
310
+ console.print(f" 🔑 Private key: {key_path}")
311
+ console.print(f" ⏰ Valid for: {days} days")
312
+ console.print(f" 🔒 Key size: {key_size} bits")
313
+
314
+ # Show file permissions reminder
315
+ console.print(f"\n[yellow]⚠️ Security reminder:[/yellow]")
316
+ console.print(f"[yellow]Set appropriate file permissions:[/yellow]")
317
+ console.print(f"[yellow] chmod 644 {cert_path}[/yellow]")
318
+ console.print(f"[yellow] chmod 600 {key_path}[/yellow]")
319
+ else:
320
+ console.print("[red]❌ Failed to generate certificate[/red]")
321
+ console.print("[red]Ensure cryptography library is installed: pip install cryptography[/red]")
322
+ sys.exit(1)
323
+
324
+
325
+ @main.command("mtls-validate-cert")
326
+ @click.argument("cert_path")
327
+ @click.argument("key_path")
328
+ @click.option("--check-expiry", is_flag=True, help="Check certificate expiration date")
329
+ @click.option("--check-chain", is_flag=True, help="Validate certificate chain (requires CA bundle)")
330
+ @click.option("--ca-bundle", help="Path to CA bundle for chain validation")
331
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed certificate information")
332
+ def mtls_validate_cert(cert_path, key_path, check_expiry, check_chain, ca_bundle, verbose):
333
+ """
334
+ Validate client certificate and private key files.
335
+
336
+ Performs comprehensive validation of certificate and key files including:
337
+ - File existence and readability
338
+ - Certificate and key format validation
339
+ - Certificate and private key matching
340
+ - Optional expiration and chain validation
341
+
342
+ Examples:
343
+ \b
344
+ # Basic validation
345
+ offsec-ai mtls-validate-cert client.crt client.key
346
+
347
+ # Check expiration date
348
+ offsec-ai mtls-validate-cert client.crt client.key --check-expiry
349
+
350
+ # Validate certificate chain
351
+ offsec-ai mtls-validate-cert client.crt client.key --check-chain --ca-bundle ca-bundle.pem
352
+
353
+ # Detailed output
354
+ offsec-ai mtls-validate-cert client.crt client.key --verbose --check-expiry
355
+
356
+ Exit Codes:
357
+ 0: Certificate and key are valid
358
+ 1: Validation failed or files are invalid
359
+ """
360
+
361
+ console.print(f"[blue]Validating certificate files[/blue]")
362
+ console.print(f"📄 Certificate: {cert_path}")
363
+ console.print(f"🔑 Private key: {key_path}")
364
+
365
+ from .core.mtls_checker import validate_certificate_files
366
+
367
+ is_valid, message = validate_certificate_files(cert_path, key_path)
368
+
369
+ if is_valid:
370
+ console.print(f"[green]✅ {message}[/green]")
371
+
372
+ if verbose:
373
+ # Show certificate details
374
+ try:
375
+ from cryptography import x509
376
+ with open(cert_path, 'rb') as f:
377
+ cert_data = f.read()
378
+ cert = x509.load_pem_x509_certificate(cert_data)
379
+
380
+ console.print(f"\n[cyan]📋 Certificate Details:[/cyan]")
381
+ console.print(f" Subject: {cert.subject.rfc4514_string()}")
382
+ console.print(f" Issuer: {cert.issuer.rfc4514_string()}")
383
+ console.print(f" Serial: {cert.serial_number}")
384
+ console.print(f" Valid from: {cert.not_valid_before}")
385
+ console.print(f" Valid until: {cert.not_valid_after}")
386
+ console.print(f" Algorithm: {cert.signature_algorithm_oid._name}")
387
+
388
+ except ImportError:
389
+ console.print(f"[yellow]⚠️ cryptography library not available for detailed certificate parsing[/yellow]")
390
+ except Exception as e:
391
+ console.print(f"[yellow]⚠️ Could not parse certificate details: {e}[/yellow]")
392
+
393
+ if check_expiry:
394
+ # Check certificate expiration
395
+ console.print(f"[blue]Checking certificate expiration...[/blue]")
396
+ # Implementation would go here
397
+
398
+ else:
399
+ console.print(f"[red]❌ {message}[/red]")
400
+ console.print(f"[red]Please check:[/red]")
401
+ console.print(f"[red] - File paths are correct[/red]")
402
+ console.print(f"[red] - Files are readable[/red]")
403
+ console.print(f"[red] - Certificate and key are in PEM format[/red]")
404
+ console.print(f"[red] - Certificate and key pair match[/red]")
405
+ sys.exit(1)
406
+
407
+
408
+ async def _run_port_scan(
409
+ targets: List[str],
410
+ ports: List[int],
411
+ timeout: int,
412
+ concurrent: int,
413
+ output: Optional[str],
414
+ verbose: bool,
415
+ ):
416
+ """Run port scanning with progress display."""
417
+
418
+ config = ScanConfig(timeout=timeout, concurrent_limit=concurrent)
419
+ scanner = PortChecker(config)
420
+
421
+ start_time = time.time()
422
+ results = []
423
+
424
+ with Progress(
425
+ SpinnerColumn(),
426
+ TextColumn("[progress.description]{task.description}"),
427
+ BarColumn(),
428
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
429
+ TimeElapsedColumn(),
430
+ console=console,
431
+ ) as progress:
432
+
433
+ scan_task = progress.add_task("Scanning hosts...", total=len(targets))
434
+
435
+ for target in targets:
436
+ progress.update(scan_task, description=f"Scanning {target}...")
437
+
438
+ try:
439
+ result = await scanner.scan_host(target, ports, timeout)
440
+ results.append(result)
441
+
442
+ if verbose:
443
+ _display_scan_result(result)
444
+
445
+ except Exception as e:
446
+ console.print(f"[red]Error scanning {target}: {e}[/red]")
447
+
448
+ progress.advance(scan_task)
449
+
450
+ total_time = time.time() - start_time
451
+ batch_result = BatchScanResult(results=results, total_scan_time=total_time)
452
+
453
+ # Display summary
454
+ _display_scan_summary(batch_result)
455
+
456
+ # Save output if requested
457
+ if output:
458
+ _save_results(batch_result, output)
459
+
460
+
461
+ async def _run_l7_detection(
462
+ targets: List[str],
463
+ timeout: int,
464
+ user_agent: Optional[str],
465
+ output: Optional[str],
466
+ verbose: bool,
467
+ port: Optional[int],
468
+ path: str,
469
+ trace_dns: bool,
470
+ ):
471
+ """Run L7 protection detection with progress display."""
472
+
473
+ detector = L7Detector(timeout=timeout, user_agent=user_agent)
474
+
475
+ start_time = time.time()
476
+ results = []
477
+
478
+ with Progress(
479
+ SpinnerColumn(),
480
+ TextColumn("[progress.description]{task.description}"),
481
+ BarColumn(),
482
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
483
+ TimeElapsedColumn(),
484
+ console=console,
485
+ ) as progress:
486
+
487
+ detect_task = progress.add_task("Checking L7 protection...", total=len(targets))
488
+
489
+ for target in targets:
490
+ progress.update(detect_task, description=f"Checking {target}...")
491
+
492
+ try:
493
+ # Pass the trace_dns parameter to the detect method
494
+ result = await detector.detect(target, port, path, trace_dns=trace_dns)
495
+ results.append(result)
496
+
497
+ if verbose:
498
+ # Display the result with DNS trace information if available
499
+ _display_l7_result(result, show_trace=trace_dns or verbose)
500
+
501
+ # If DNS trace is enabled and verbose is true, also show detailed DNS trace
502
+ if trace_dns and verbose:
503
+ _display_dns_trace(result)
504
+
505
+ except Exception as e:
506
+ console.print(f"[red]Error checking {target}: {e}[/red]")
507
+
508
+ progress.advance(detect_task)
509
+
510
+ total_time = time.time() - start_time
511
+ batch_result = BatchL7Result(results=results, total_scan_time=total_time)
512
+
513
+ # Display summary
514
+ _display_l7_summary(batch_result)
515
+
516
+ # Save output if requested
517
+ if output:
518
+ _save_results(batch_result, output)
519
+
520
+
521
+ async def _run_full_scan(
522
+ targets: List[str],
523
+ ports: List[int],
524
+ timeout: int,
525
+ concurrent: int,
526
+ output: Optional[str],
527
+ verbose: bool,
528
+ ):
529
+ """Run full scan combining port scanning and L7 detection."""
530
+
531
+ console.print("[yellow]Phase 1: Port Scanning[/yellow]")
532
+ await _run_port_scan(targets, ports, timeout, concurrent, None, verbose)
533
+
534
+ console.print("\n[yellow]Phase 2: L7 Protection Detection[/yellow]")
535
+ await _run_l7_detection(targets, timeout, None, None, verbose, None, "/", True)
536
+
537
+ console.print("\n[green]Full scan completed![/green]")
538
+
539
+
540
+ async def _run_dns_trace_analysis(targets, timeout, output, check_protection, verbose):
541
+ """Run DNS trace analysis for multiple targets."""
542
+
543
+ start_time = time.time()
544
+ detector = L7Detector(timeout=timeout)
545
+ results = []
546
+
547
+ with Progress(
548
+ SpinnerColumn(),
549
+ TextColumn("[progress.description]{task.description}"),
550
+ BarColumn(),
551
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
552
+ TimeElapsedColumn(),
553
+ console=console,
554
+ ) as progress:
555
+
556
+ trace_task = progress.add_task("Tracing DNS records...", total=len(targets))
557
+
558
+ for target in targets:
559
+ progress.update(trace_task, description=f"Tracing {target}...")
560
+
561
+ try:
562
+ # Get detailed DNS trace
563
+ dns_trace = await detector.get_dns_trace(target)
564
+
565
+ # Also get L7 detection for the domain
566
+ domain_result = await detector.detect(target)
567
+ results.append(domain_result)
568
+
569
+ # Display the DNS trace information
570
+ await _display_detailed_dns_trace(target, dns_trace, domain_result, check_protection, verbose)
571
+
572
+ except Exception as e:
573
+ console.print(f"[red]Error tracing {target}: {e}[/red]")
574
+
575
+ progress.advance(trace_task)
576
+
577
+ # Save to JSON if requested
578
+ if output:
579
+ try:
580
+ trace_data = []
581
+ for result in results:
582
+ trace_data.append({
583
+ "host": result.host,
584
+ "dns_trace": result.dns_trace,
585
+ "l7_result": result.to_dict()
586
+ })
587
+
588
+ with open(output, "w") as f:
589
+ json.dump(trace_data, f, indent=2)
590
+ console.print(f"[green]Results saved to {output}[/green]")
591
+ except Exception as e:
592
+ console.print(f"[red]Error saving results: {e}[/red]")
593
+
594
+ async def _display_detailed_dns_trace(target: str, dns_trace: dict, domain_result: L7Result, check_protection: bool, verbose: bool):
595
+ """Display detailed DNS trace information."""
596
+
597
+ console.print(f"\n[bold blue]DNS Trace for {target}[/bold blue]")
598
+
599
+ # Show CNAME chain
600
+ if dns_trace.get("cname_chain"):
601
+ console.print("[cyan]CNAME Chain:[/cyan]")
602
+ for cname in dns_trace["cname_chain"]:
603
+ console.print(f" [green]{cname['from']} → {cname['to']}[/green] (depth: {cname['depth']})")
604
+ else:
605
+ console.print("[yellow]No CNAME records found[/yellow]")
606
+
607
+ # Show resolved IPs
608
+ if dns_trace.get("resolved_ips"):
609
+ console.print("\n[cyan]Resolved IPs:[/cyan]")
610
+ for hostname, ips in dns_trace["resolved_ips"].items():
611
+ console.print(f" [bold]{hostname}:[/bold] {', '.join(ips)}")
612
+
613
+ # Show IP protection if check_protection is enabled
614
+ if check_protection and dns_trace.get("ip_protection"):
615
+ console.print("\n[cyan]IP Protection Analysis:[/cyan]")
616
+ for ip, protection in dns_trace["ip_protection"].items():
617
+ if "service" in protection:
618
+ console.print(f" [green]{ip}: {protection['service']} ({protection['confidence']:.1%}) via {protection['origin_host']}[/green]")
619
+ elif "error" in protection:
620
+ console.print(f" [red]{ip}: Failed to check ({protection['error']})[/red]")
621
+
622
+ # Show domain protection
623
+ if domain_result.is_protected and domain_result.primary_protection:
624
+ console.print("\n[cyan]Domain Protection:[/cyan]")
625
+ service = domain_result.primary_protection.service.value
626
+ confidence = domain_result.primary_protection.confidence
627
+ console.print(f" [yellow]{target}: {service} ({confidence:.1%})[/yellow]")
628
+
629
+ # Compare with IP protection if available
630
+ if check_protection and dns_trace.get("ip_protection"):
631
+ ip_services = set()
632
+ for prot in dns_trace["ip_protection"].values():
633
+ if "service" in prot:
634
+ ip_services.add(prot["service"])
635
+
636
+ if ip_services:
637
+ if service in ip_services:
638
+ console.print("[green] ✓ Domain and IP protection match[/green]")
639
+ else:
640
+ console.print("[yellow] ⚠ Domain and IP protection differ[/yellow]")
641
+ else:
642
+ console.print(f"\n[yellow]No L7 protection detected for {target}[/yellow]")
643
+
644
+ # Show verbose information if requested
645
+ if verbose and domain_result.detections:
646
+ console.print("\n[cyan]Detailed Detection Information:[/cyan]")
647
+ for detection in domain_result.detections:
648
+ console.print(f" [dim]Service: {detection.service.value}, Confidence: {detection.confidence:.1%}[/dim]")
649
+ if detection.indicators:
650
+ console.print(f" [dim]Indicators: {', '.join(detection.indicators[:3])}[/dim]")
651
+
652
+
653
+ def _display_scan_result(result: ScanResult):
654
+ """Display individual scan result."""
655
+
656
+ table = Table(title=f"Port Scan Results - {result.host}")
657
+ table.add_column("Port", style="cyan")
658
+ table.add_column("Status", style="green")
659
+ table.add_column("Service", style="yellow")
660
+ table.add_column("Banner", style="dim")
661
+
662
+ for port_result in result.ports:
663
+ status = "Open" if port_result.is_open else "Closed"
664
+ status_style = "green" if port_result.is_open else "red"
665
+
666
+ table.add_row(
667
+ str(port_result.port),
668
+ f"[{status_style}]{status}[/{status_style}]",
669
+ port_result.service,
670
+ (
671
+ port_result.banner[:50] + "..."
672
+ if len(port_result.banner) > 50
673
+ else port_result.banner
674
+ ),
675
+ )
676
+
677
+ console.print(table)
678
+ console.print()
679
+
680
+
681
+ def _display_l7_result(result: L7Result, show_trace: bool = False):
682
+ """Display individual L7 detection result."""
683
+
684
+ if result.error:
685
+ console.print(f"[red]L7 Check failed for {result.host}: {result.error}[/red]")
686
+ return
687
+
688
+ panel_content = []
689
+
690
+ if result.is_protected:
691
+ primary = result.primary_protection
692
+ panel_content.append(f"[green]✓ L7 Protection Detected[/green]")
693
+
694
+ # Check if there's a specific service name in detection details
695
+ if primary.details and "specific_service" in primary.details:
696
+ service_name = primary.details["specific_service"]
697
+ else:
698
+ service_name = primary.service.value
699
+
700
+ panel_content.append(f"[yellow]Primary: {service_name}[/yellow]")
701
+ panel_content.append(f"[yellow]Confidence: {primary.confidence:.1%}[/yellow]")
702
+
703
+ if len(result.detections) > 1:
704
+ panel_content.append(
705
+ f"[dim]Additional detections: {len(result.detections) - 1}[/dim]"
706
+ )
707
+ else:
708
+ panel_content.append("[red]✗ No L7 Protection Detected[/red]")
709
+ panel_content.append("[bold red]The endpoint is NOT protected by any L7 service (WAF/CDN)[/bold red]")
710
+
711
+ panel_content.append(f"[dim]Response time: {result.response_time:.2f}s[/dim]")
712
+
713
+ # Add DNS trace information if requested and available
714
+ if show_trace and result.dns_trace and any(result.dns_trace.values()):
715
+ panel_content.append("")
716
+ panel_content.append("[cyan]DNS Trace Information:[/cyan]")
717
+
718
+ # Show CNAME chain
719
+ if "cname_chain" in result.dns_trace and result.dns_trace["cname_chain"]:
720
+ panel_content.append("[cyan]CNAME Chain:[/cyan]")
721
+ for cname in result.dns_trace["cname_chain"]:
722
+ panel_content.append(f" [dim]{cname['from']} → {cname['to']}[/dim]")
723
+
724
+ # Show resolved IPs
725
+ if "resolved_ips" in result.dns_trace and result.dns_trace["resolved_ips"]:
726
+ panel_content.append("[cyan]Resolved IPs:[/cyan]")
727
+ for host, ips in result.dns_trace["resolved_ips"].items():
728
+ panel_content.append(f" [dim]{host}: {', '.join(ips)}[/dim]")
729
+
730
+ # Show IP protection
731
+ if "ip_protection" in result.dns_trace and result.dns_trace["ip_protection"]:
732
+ panel_content.append("[cyan]IP Protection Analysis:[/cyan]")
733
+ for ip, protection in result.dns_trace["ip_protection"].items():
734
+ if "service" in protection:
735
+ panel_content.append(f" [green]{ip}: {protection['service']} ({protection['confidence']:.1%})[/green]")
736
+ elif "error" in protection:
737
+ panel_content.append(f" [dim]{ip}: Failed to check ({protection['error']})[/dim]")
738
+
739
+ console.print(
740
+ Panel(
741
+ "\n".join(panel_content),
742
+ title=f"L7 Check - {result.host}",
743
+ border_style="blue",
744
+ )
745
+ )
746
+
747
+
748
+ def _display_scan_summary(batch_result: BatchScanResult):
749
+ """Display port scan summary."""
750
+
751
+ console.print("\n")
752
+ console.print(
753
+ Panel(
754
+ f"[green]Scan completed in {batch_result.total_scan_time:.2f} seconds[/green]\n"
755
+ f"[yellow]Hosts scanned: {len(batch_result.results)}[/yellow]\n"
756
+ f"[yellow]Successful scans: {len(batch_result.successful_scans)}[/yellow]\n"
757
+ f"[yellow]Failed scans: {len(batch_result.failed_scans)}[/yellow]\n"
758
+ f"[yellow]Total open ports found: {sum(len(r.open_ports) for r in batch_result.successful_scans)}[/yellow]",
759
+ title="Port Scan Summary",
760
+ border_style="green",
761
+ )
762
+ )
763
+
764
+ # Display top open ports
765
+ port_counts = {}
766
+ for result in batch_result.successful_scans:
767
+ for port in result.open_ports:
768
+ port_counts[port.port] = port_counts.get(port.port, 0) + 1
769
+
770
+ if port_counts:
771
+ console.print("\n[bold]Most Common Open Ports:[/bold]")
772
+ sorted_ports = sorted(port_counts.items(), key=lambda x: x[1], reverse=True)[
773
+ :10
774
+ ]
775
+
776
+ table = Table()
777
+ table.add_column("Port", style="cyan")
778
+ table.add_column("Service", style="yellow")
779
+ table.add_column("Count", style="green")
780
+
781
+ for port, count in sorted_ports:
782
+ service = get_service_name(port)
783
+ table.add_row(str(port), service, str(count))
784
+
785
+ console.print(table)
786
+
787
+
788
+ def _display_l7_summary(batch_result: BatchL7Result):
789
+ """Display L7 detection summary."""
790
+
791
+ console.print("\n")
792
+ console.print(
793
+ Panel(
794
+ f"[green]L7 check completed in {batch_result.total_scan_time:.2f} seconds[/green]\n"
795
+ f"[yellow]Hosts checked: {len(batch_result.results)}[/yellow]\n"
796
+ f"[yellow]Protected hosts: {len(batch_result.protected_hosts)}[/yellow]\n"
797
+ f"[bold red]Unprotected hosts: {len(batch_result.unprotected_hosts)}[/bold red]\n"
798
+ f"[yellow]Failed checks: {len(batch_result.failed_checks)}[/yellow]",
799
+ title="L7 Protection Summary",
800
+ border_style="blue",
801
+ )
802
+ )
803
+
804
+ # Display protection services summary
805
+ protection_summary = batch_result.get_protection_summary()
806
+ if protection_summary:
807
+ console.print("\n[bold]Detected Protection Services:[/bold]")
808
+
809
+ table = Table()
810
+ table.add_column("Service", style="cyan")
811
+ table.add_column("Count", style="green")
812
+
813
+ for service, count in sorted(protection_summary.items()):
814
+ table.add_row(service.replace("_", " ").title(), str(count))
815
+
816
+ console.print(table)
817
+
818
+ # Display unprotected hosts
819
+ if batch_result.unprotected_hosts:
820
+ console.print("\n[bold red]Unprotected Hosts (No L7 Protection):[/bold red]")
821
+
822
+ unprotected_table = Table()
823
+ unprotected_table.add_column("Host", style="red")
824
+ unprotected_table.add_column("Status", style="red")
825
+
826
+ for result in batch_result.unprotected_hosts:
827
+ unprotected_table.add_row(result.host, "NOT PROTECTED")
828
+
829
+ console.print(unprotected_table)
830
+
831
+
832
+ def _save_results(results, filename: str):
833
+ """Save results to file."""
834
+ try:
835
+ Path(filename).parent.mkdir(parents=True, exist_ok=True)
836
+
837
+ if hasattr(results, "to_json"):
838
+ with open(filename, "w") as f:
839
+ f.write(results.to_json())
840
+ else:
841
+ with open(filename, "w") as f:
842
+ json.dump(results, f, indent=2, default=str)
843
+
844
+ console.print(f"[green]Results saved to {filename}[/green]")
845
+
846
+ except Exception as e:
847
+ console.print(f"[red]Error saving results: {e}[/red]")
848
+
849
+
850
+ def _save_mtls_results(batch_result: BatchMTLSResult, output_file: str):
851
+ """Save mTLS results to JSON file."""
852
+ try:
853
+ with open(output_file, "w") as f:
854
+ json.dump(batch_result.dict(), f, indent=2)
855
+ console.print(f"[green]Results saved to {output_file}[/green]")
856
+ except Exception as e:
857
+ console.print(f"[red]Failed to save results: {e}[/red]")
858
+
859
+
860
+ @main.command()
861
+ @click.argument("target")
862
+ @click.option("--port", "-p", type=int, help="Specific port for service detection")
863
+ def service_detect(target, port):
864
+ """Detect service version and information for a specific host/port."""
865
+
866
+ console.print(f"[blue]Detecting service information for {target}[/blue]")
867
+
868
+ if port:
869
+ console.print(f"[yellow]Target port: {port}[/yellow]")
870
+
871
+ asyncio.run(_run_service_detection(target, port))
872
+
873
+
874
+ async def _run_service_detection(target: str, port: Optional[int]):
875
+ """Run service detection."""
876
+
877
+ scanner = PortChecker()
878
+
879
+ if port:
880
+ # Check specific port
881
+ service_info = await scanner.check_service_version(target, port)
882
+ _display_service_info(target, port, service_info)
883
+ else:
884
+ # Scan common ports first, then detect services
885
+ result = await scanner.scan_host(target, TOP_PORTS[:10])
886
+
887
+ if result.error:
888
+ console.print(f"[red]Error: {result.error}[/red]")
889
+ return
890
+
891
+ console.print(f"[green]Found {len(result.open_ports)} open ports[/green]")
892
+
893
+ for port_result in result.open_ports:
894
+ service_info = await scanner.check_service_version(
895
+ target, port_result.port, port_result.service
896
+ )
897
+ _display_service_info(target, port_result.port, service_info)
898
+
899
+
900
+ def _display_service_info(target: str, port: int, service_info: dict):
901
+ """Display service information."""
902
+
903
+ table = Table(title=f"Service Information - {target}:{port}")
904
+ table.add_column("Property", style="cyan")
905
+ table.add_column("Value", style="yellow")
906
+
907
+ table.add_row("Port", str(port))
908
+ table.add_row("Service", service_info.get("service", "unknown"))
909
+ table.add_row("Version", service_info.get("version", "unknown"))
910
+ table.add_row("Banner", service_info.get("banner", "none")[:100])
911
+
912
+ if service_info.get("headers"):
913
+ table.add_row("Headers", str(len(service_info["headers"])) + " found")
914
+
915
+ if service_info.get("error"):
916
+ table.add_row("Error", service_info["error"])
917
+
918
+ console.print(table)
919
+ console.print()
920
+
921
+
922
+ @main.command()
923
+ @click.argument("target")
924
+ @click.option("--port", "-p", type=int, default=443, help="Target port (default: 443)")
925
+ @click.option("--timeout", "-t", type=int, default=10, help="Connection timeout in seconds")
926
+ @click.option("--output", "-o", type=str, help="Output file for results (JSON)")
927
+ @click.option("--verify-hostname/--no-verify-hostname", default=True, help="Verify hostname against certificate")
928
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
929
+ def cert_check(target, port, timeout, output, verify_hostname, verbose):
930
+ """Analyze SSL/TLS certificate chain for a target host."""
931
+
932
+ console.print(f"[blue]🔒 Analyzing SSL/TLS certificate chain for {target}:{port}[/blue]")
933
+
934
+ if verbose:
935
+ console.print(f"[yellow]Configuration:[/yellow]")
936
+ console.print(f" Target: {target}:{port}")
937
+ console.print(f" Timeout: {timeout}s")
938
+ console.print(f" Hostname verification: {'enabled' if verify_hostname else 'disabled'}")
939
+
940
+ asyncio.run(_run_certificate_analysis(target, port, timeout, output, verify_hostname, verbose))
941
+
942
+
943
+ @main.command()
944
+ @click.argument("target")
945
+ @click.option("--port", "-p", type=int, default=443, help="Target port (default: 443)")
946
+ @click.option("--timeout", "-t", type=int, default=10, help="Connection timeout in seconds")
947
+ @click.option("--output", "-o", type=str, help="Output file for results (JSON)")
948
+ @click.option("--check-revocation/--no-check-revocation", default=False, help="Check certificate revocation status")
949
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
950
+ def cert_chain(target, port, timeout, output, check_revocation, verbose):
951
+ """Analyze complete certificate chain and trust path."""
952
+
953
+ console.print(f"[blue]🔗 Analyzing certificate chain and trust path for {target}:{port}[/blue]")
954
+
955
+ if verbose:
956
+ console.print(f"[yellow]Configuration:[/yellow]")
957
+ console.print(f" Target: {target}:{port}")
958
+ console.print(f" Timeout: {timeout}s")
959
+ console.print(f" Revocation check: {'enabled' if check_revocation else 'disabled'}")
960
+
961
+ asyncio.run(_run_certificate_chain_analysis(target, port, timeout, output, check_revocation, verbose))
962
+
963
+
964
+ @main.command()
965
+ @click.argument("target")
966
+ @click.option("--port", "-p", type=int, default=443, help="Target port (default: 443)")
967
+ @click.option("--timeout", "-t", type=int, default=10, help="Connection timeout in seconds")
968
+ @click.option("--output", "-o", type=str, help="Output file for results (JSON)")
969
+ @click.option("--show-pem/--no-show-pem", default=False, help="Show certificate in PEM format")
970
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
971
+ def cert_info(target, port, timeout, output, show_pem, verbose):
972
+ """Show detailed certificate information and who signed it."""
973
+
974
+ console.print(f"[blue]📋 Retrieving certificate information for {target}:{port}[/blue]")
975
+
976
+ if verbose:
977
+ console.print(f"[yellow]Configuration:[/yellow]")
978
+ console.print(f" Target: {target}:{port}")
979
+ console.print(f" Timeout: {timeout}s")
980
+ console.print(f" Show PEM: {'yes' if show_pem else 'no'}")
981
+
982
+ asyncio.run(_run_certificate_info_analysis(target, port, timeout, output, show_pem, verbose))
983
+
984
+
985
+ @main.command("hybrid-identity")
986
+ @click.argument("targets", nargs=-1, required=True)
987
+ @click.option("--timeout", "-t", default=10, help="Request timeout in seconds")
988
+ @click.option("--output", "-o", help="Output file (JSON format)")
989
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
990
+ @click.option("--concurrent", "-c", default=10, help="Maximum concurrent checks")
991
+ def hybrid_identity(targets, timeout, output, verbose, concurrent):
992
+ """
993
+ Check if FQDNs have hybrid identity setup (Azure AD/ADFS integration).
994
+
995
+ This command checks for:
996
+ - ADFS endpoints (/adfs/ls)
997
+ - Federation metadata
998
+ - Azure AD integration
999
+ - OpenID Connect configuration
1000
+ - DNS records indicating Microsoft services
1001
+
1002
+ Examples:
1003
+ \b
1004
+ # Check single domain
1005
+ offsec-ai hybrid-identity example.com
1006
+
1007
+ # Check multiple domains
1008
+ offsec-ai hybrid-identity domain1.com domain2.com domain3.com
1009
+
1010
+ # Batch check with output
1011
+ offsec-ai hybrid-identity $(cat domains.txt) --output results.json
1012
+
1013
+ # Verbose output with DNS details
1014
+ offsec-ai hybrid-identity company.com --verbose
1015
+
1016
+ The tool will identify:
1017
+ - Hybrid identity deployments
1018
+ - ADFS federation services
1019
+ - Azure AD integration
1020
+ - Microsoft 365 mail services
1021
+ - Domain verification records
1022
+ """
1023
+
1024
+ console.print(
1025
+ f"[blue]🔍 Checking hybrid identity for {len(targets)} domain(s)[/blue]"
1026
+ )
1027
+ console.print(f"[yellow]Timeout: {timeout}s[/yellow]")
1028
+
1029
+ if verbose:
1030
+ console.print("[yellow]Verbose mode: Detailed DNS and endpoint information will be shown[/yellow]")
1031
+
1032
+ # Run hybrid identity check
1033
+ asyncio.run(
1034
+ _run_hybrid_identity_check(
1035
+ list(targets), timeout, output, verbose, concurrent
1036
+ )
1037
+ )
1038
+
1039
+
1040
+ async def _run_certificate_analysis(target: str, port: int, timeout: int, output: Optional[str],
1041
+ verify_hostname: bool, verbose: bool):
1042
+ """Run certificate analysis."""
1043
+
1044
+ try:
1045
+ analyzer = CertificateAnalyzer(timeout=timeout)
1046
+
1047
+ with Progress(
1048
+ TextColumn("[progress.description]{task.description}"),
1049
+ BarColumn(),
1050
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
1051
+ TimeElapsedColumn(),
1052
+ ) as progress:
1053
+ task = progress.add_task("Analyzing certificate...", total=100)
1054
+
1055
+ # Get certificate chain
1056
+ progress.update(task, advance=30)
1057
+ cert_chain = await analyzer.analyze_certificate_chain(target, port)
1058
+ progress.update(task, advance=40)
1059
+
1060
+ # Validate hostname if requested
1061
+ hostname_valid = True
1062
+ if verify_hostname:
1063
+ hostname_valid = analyzer.validate_hostname(cert_chain.server_cert.raw_cert, target)
1064
+ progress.update(task, advance=20)
1065
+
1066
+ progress.update(task, advance=10, description="Analysis complete")
1067
+
1068
+ # Display results
1069
+ _display_certificate_analysis(cert_chain, target, hostname_valid, verify_hostname, verbose)
1070
+
1071
+ # Save to file if requested
1072
+ if output:
1073
+ await _save_certificate_results(cert_chain, output, hostname_valid)
1074
+ console.print(f"[green]Results saved to {output}[/green]")
1075
+
1076
+ except Exception as e:
1077
+ console.print(f"[red]Certificate analysis failed: {e}[/red]")
1078
+ if verbose:
1079
+ import traceback
1080
+ console.print(f"[red]{traceback.format_exc()}[/red]")
1081
+
1082
+
1083
+ async def _run_certificate_chain_analysis(target: str, port: int, timeout: int, output: Optional[str],
1084
+ check_revocation: bool, verbose: bool):
1085
+ """Run certificate chain analysis."""
1086
+
1087
+ try:
1088
+ analyzer = CertificateAnalyzer(timeout=timeout)
1089
+
1090
+ with Progress(
1091
+ TextColumn("[progress.description]{task.description}"),
1092
+ BarColumn(),
1093
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
1094
+ TimeElapsedColumn(),
1095
+ ) as progress:
1096
+ task = progress.add_task("Analyzing certificate chain...", total=100)
1097
+
1098
+ cert_chain = await analyzer.analyze_certificate_chain(target, port)
1099
+ progress.update(task, advance=80)
1100
+
1101
+ # Check revocation if requested
1102
+ revocation_results = {}
1103
+ if check_revocation and cert_chain.ocsp_urls:
1104
+ progress.update(task, description="Checking revocation status...")
1105
+ # This would be implemented when OCSP checking is fully available
1106
+ revocation_results = {"status": "not_implemented"}
1107
+ progress.update(task, advance=20)
1108
+ else:
1109
+ progress.update(task, advance=20)
1110
+
1111
+ # Display chain analysis
1112
+ _display_certificate_chain_analysis(cert_chain, revocation_results, verbose)
1113
+
1114
+ # Save to file if requested
1115
+ if output:
1116
+ await _save_certificate_chain_results(cert_chain, revocation_results, output)
1117
+ console.print(f"[green]Results saved to {output}[/green]")
1118
+
1119
+ except Exception as e:
1120
+ console.print(f"[red]Certificate chain analysis failed: {e}[/red]")
1121
+ if verbose:
1122
+ import traceback
1123
+ console.print(f"[red]{traceback.format_exc()}[/red]")
1124
+
1125
+
1126
+ async def _run_certificate_info_analysis(target: str, port: int, timeout: int, output: Optional[str],
1127
+ show_pem: bool, verbose: bool):
1128
+ """Run certificate information analysis."""
1129
+
1130
+ try:
1131
+ analyzer = CertificateAnalyzer(timeout=timeout)
1132
+
1133
+ with Progress(
1134
+ TextColumn("[progress.description]{task.description}"),
1135
+ BarColumn(),
1136
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
1137
+ TimeElapsedColumn(),
1138
+ ) as progress:
1139
+ task = progress.add_task("Retrieving certificate info...", total=100)
1140
+
1141
+ cert_chain = await analyzer.analyze_certificate_chain(target, port)
1142
+ progress.update(task, advance=100, description="Analysis complete")
1143
+
1144
+ # Display certificate information
1145
+ _display_certificate_info(cert_chain, show_pem, verbose)
1146
+
1147
+ # Save to file if requested
1148
+ if output:
1149
+ await _save_certificate_info_results(cert_chain, output, show_pem)
1150
+ console.print(f"[green]Results saved to {output}[/green]")
1151
+
1152
+ except Exception as e:
1153
+ console.print(f"[red]Certificate information retrieval failed: {e}[/red]")
1154
+ if verbose:
1155
+ import traceback
1156
+ console.print(f"[red]{traceback.format_exc()}[/red]")
1157
+
1158
+
1159
+ def _display_certificate_analysis(cert_chain, target: str, hostname_valid: bool,
1160
+ verify_hostname: bool, verbose: bool):
1161
+ """Display certificate analysis results."""
1162
+
1163
+ # Server certificate summary
1164
+ server_cert = cert_chain.server_cert
1165
+
1166
+ # Create main certificate table
1167
+ cert_table = Table(title=f"🔒 SSL/TLS Certificate Analysis - {target}")
1168
+ cert_table.add_column("Property", style="cyan")
1169
+ cert_table.add_column("Value", style="yellow")
1170
+
1171
+ # Certificate status
1172
+ status_color = "green" if server_cert.is_valid_now else "red"
1173
+ cert_table.add_row("Certificate Status", f"[{status_color}]{'Valid' if server_cert.is_valid_now else 'Invalid/Expired'}[/{status_color}]")
1174
+
1175
+ # Hostname validation
1176
+ if verify_hostname:
1177
+ hostname_color = "green" if hostname_valid else "red"
1178
+ cert_table.add_row("Hostname Match", f"[{hostname_color}]{'✅ Valid' if hostname_valid else '❌ Invalid'}[/{hostname_color}]")
1179
+
1180
+ # Basic certificate info
1181
+ cert_table.add_row("Subject", server_cert.subject)
1182
+ cert_table.add_row("Issuer", server_cert.issuer)
1183
+ cert_table.add_row("Serial Number", server_cert.serial_number)
1184
+ cert_table.add_row("Valid From", server_cert.not_before.strftime("%Y-%m-%d %H:%M:%S UTC"))
1185
+ cert_table.add_row("Valid Until", server_cert.not_after.strftime("%Y-%m-%d %H:%M:%S UTC"))
1186
+
1187
+ # Security details
1188
+ cert_table.add_row("Key Algorithm", f"{server_cert.public_key_algorithm} ({server_cert.key_size} bits)")
1189
+ cert_table.add_row("Signature Algorithm", server_cert.signature_algorithm)
1190
+
1191
+ # Certificate type
1192
+ cert_type = "CA Certificate" if server_cert.is_ca else "Server Certificate"
1193
+ if server_cert.is_self_signed:
1194
+ cert_type += " (Self-Signed)"
1195
+ cert_table.add_row("Certificate Type", cert_type)
1196
+
1197
+ console.print(cert_table)
1198
+ console.print()
1199
+
1200
+ # SAN domains
1201
+ if server_cert.san_domains:
1202
+ san_table = Table(title="📋 Subject Alternative Names")
1203
+ san_table.add_column("Domain", style="green")
1204
+ for domain in server_cert.san_domains:
1205
+ san_table.add_row(domain)
1206
+ console.print(san_table)
1207
+ console.print()
1208
+
1209
+ # Chain information
1210
+ chain_table = Table(title="🔗 Certificate Chain Information")
1211
+ chain_table.add_column("Property", style="cyan")
1212
+ chain_table.add_column("Value", style="yellow")
1213
+
1214
+ chain_status_color = "green" if cert_chain.chain_valid else "red"
1215
+ chain_table.add_row("Chain Valid", f"[{chain_status_color}]{'✅ Yes' if cert_chain.chain_valid else '❌ No'}[/{chain_status_color}]")
1216
+
1217
+ complete_color = "green" if cert_chain.chain_complete else "orange"
1218
+ chain_table.add_row("Chain Complete", f"[{complete_color}]{'✅ Yes' if cert_chain.chain_complete else '⚠️ No'}[/{complete_color}]")
1219
+
1220
+ chain_table.add_row("Intermediate Certificates", str(len(cert_chain.intermediate_certs)))
1221
+ chain_table.add_row("Root Certificate", "✅ Found" if cert_chain.root_cert else "❌ Not found")
1222
+
1223
+ console.print(chain_table)
1224
+ console.print()
1225
+
1226
+ # Missing intermediates warning
1227
+ if cert_chain.missing_intermediates:
1228
+ console.print("[orange]⚠️ Missing Intermediate Certificates:[/orange]")
1229
+ for missing in cert_chain.missing_intermediates:
1230
+ console.print(f"[orange] • {missing}[/orange]")
1231
+ console.print()
1232
+
1233
+ # Trust issues
1234
+ if cert_chain.trust_issues:
1235
+ console.print("[red]❌ Trust Issues Found:[/red]")
1236
+ for issue in cert_chain.trust_issues:
1237
+ console.print(f"[red] • {issue}[/red]")
1238
+ console.print()
1239
+
1240
+ # Fingerprints (if verbose)
1241
+ if verbose:
1242
+ fingerprint_table = Table(title="🔐 Certificate Fingerprints")
1243
+ fingerprint_table.add_column("Type", style="cyan")
1244
+ fingerprint_table.add_column("Fingerprint", style="yellow")
1245
+ fingerprint_table.add_row("SHA-1", server_cert.fingerprint_sha1)
1246
+ fingerprint_table.add_row("SHA-256", server_cert.fingerprint_sha256)
1247
+ console.print(fingerprint_table)
1248
+ console.print()
1249
+
1250
+
1251
+ def _display_certificate_chain_analysis(cert_chain, revocation_results: dict, verbose: bool):
1252
+ """Display detailed certificate chain analysis."""
1253
+
1254
+ console.print("[bold]🔗 Certificate Chain Analysis[/bold]")
1255
+ console.print()
1256
+
1257
+ # Chain overview
1258
+ overview_table = Table(title="Chain Overview")
1259
+ overview_table.add_column("Level", style="cyan")
1260
+ overview_table.add_column("Certificate", style="yellow")
1261
+ overview_table.add_column("Type", style="green")
1262
+ overview_table.add_column("Valid", style="magenta")
1263
+
1264
+ # Server certificate
1265
+ server_cert = cert_chain.server_cert
1266
+ valid_icon = "✅" if server_cert.is_valid_now else "❌"
1267
+ overview_table.add_row("0", server_cert.subject.split(',')[0], "Server", f"{valid_icon} {server_cert.is_valid_now}")
1268
+
1269
+ # Intermediate certificates
1270
+ for i, intermediate in enumerate(cert_chain.intermediate_certs, 1):
1271
+ valid_icon = "✅" if intermediate.is_valid_now else "❌"
1272
+ overview_table.add_row(str(i), intermediate.subject.split(',')[0], "Intermediate CA", f"{valid_icon} {intermediate.is_valid_now}")
1273
+
1274
+ # Root certificate
1275
+ if cert_chain.root_cert:
1276
+ valid_icon = "✅" if cert_chain.root_cert.is_valid_now else "❌"
1277
+ overview_table.add_row(str(len(cert_chain.intermediate_certs) + 1),
1278
+ cert_chain.root_cert.subject.split(',')[0],
1279
+ "Root CA",
1280
+ f"{valid_icon} {cert_chain.root_cert.is_valid_now}")
1281
+
1282
+ console.print(overview_table)
1283
+ console.print()
1284
+
1285
+ # Chain validation details
1286
+ validation_table = Table(title="🔍 Chain Validation Details")
1287
+ validation_table.add_column("Check", style="cyan")
1288
+ validation_table.add_column("Status", style="yellow")
1289
+ validation_table.add_column("Details", style="white")
1290
+
1291
+ # Chain completeness
1292
+ complete_status = "✅ Pass" if cert_chain.chain_complete else "⚠️ Warning"
1293
+ complete_details = "Complete chain to root CA" if cert_chain.chain_complete else "Missing certificates in chain"
1294
+ validation_table.add_row("Chain Completeness", complete_status, complete_details)
1295
+
1296
+ # Chain validity
1297
+ valid_status = "✅ Pass" if cert_chain.chain_valid else "❌ Fail"
1298
+ valid_details = "All signatures valid" if cert_chain.chain_valid else f"{len(cert_chain.trust_issues)} issues found"
1299
+ validation_table.add_row("Chain Validity", valid_status, valid_details)
1300
+
1301
+ # Certificate expiration
1302
+ all_valid = all([server_cert.is_valid_now] + [cert.is_valid_now for cert in cert_chain.intermediate_certs])
1303
+ if cert_chain.root_cert:
1304
+ all_valid = all_valid and cert_chain.root_cert.is_valid_now
1305
+
1306
+ exp_status = "✅ Pass" if all_valid else "❌ Fail"
1307
+ exp_details = "All certificates valid" if all_valid else "One or more certificates expired"
1308
+ validation_table.add_row("Expiration Check", exp_status, exp_details)
1309
+
1310
+ console.print(validation_table)
1311
+ console.print()
1312
+
1313
+ # Missing intermediates
1314
+ if cert_chain.missing_intermediates:
1315
+ console.print("[orange]⚠️ Missing Intermediate Certificates:[/orange]")
1316
+ for missing in cert_chain.missing_intermediates:
1317
+ console.print(f"[orange] • {missing}[/orange]")
1318
+ console.print("[orange]This may cause compatibility issues with some browsers/clients.[/orange]")
1319
+ console.print()
1320
+
1321
+ # Revocation information
1322
+ if cert_chain.ocsp_urls or cert_chain.crl_urls:
1323
+ revocation_table = Table(title="🔄 Certificate Revocation Information")
1324
+ revocation_table.add_column("Type", style="cyan")
1325
+ revocation_table.add_column("URLs", style="yellow")
1326
+
1327
+ if cert_chain.ocsp_urls:
1328
+ revocation_table.add_row("OCSP", "\n".join(cert_chain.ocsp_urls))
1329
+
1330
+ if cert_chain.crl_urls:
1331
+ revocation_table.add_row("CRL", "\n".join(cert_chain.crl_urls))
1332
+
1333
+ console.print(revocation_table)
1334
+ console.print()
1335
+
1336
+ # Detailed certificate information (if verbose)
1337
+ if verbose:
1338
+ console.print("[bold]📋 Detailed Certificate Information[/bold]")
1339
+ console.print()
1340
+
1341
+ for i, cert in enumerate([server_cert] + cert_chain.intermediate_certs):
1342
+ level = "Server" if i == 0 else f"Intermediate {i}"
1343
+ console.print(f"[bold]{level} Certificate:[/bold]")
1344
+
1345
+ detail_table = Table()
1346
+ detail_table.add_column("Property", style="cyan")
1347
+ detail_table.add_column("Value", style="yellow")
1348
+
1349
+ detail_table.add_row("Subject", cert.subject)
1350
+ detail_table.add_row("Issuer", cert.issuer)
1351
+ detail_table.add_row("Serial", cert.serial_number)
1352
+ detail_table.add_row("Valid From", cert.not_before.strftime("%Y-%m-%d %H:%M:%S UTC"))
1353
+ detail_table.add_row("Valid Until", cert.not_after.strftime("%Y-%m-%d %H:%M:%S UTC"))
1354
+ detail_table.add_row("Key Algorithm", f"{cert.public_key_algorithm} ({cert.key_size} bits)")
1355
+ detail_table.add_row("Signature", cert.signature_algorithm)
1356
+ detail_table.add_row("SHA-1 Fingerprint", cert.fingerprint_sha1)
1357
+ detail_table.add_row("SHA-256 Fingerprint", cert.fingerprint_sha256)
1358
+
1359
+ console.print(detail_table)
1360
+ console.print()
1361
+
1362
+
1363
+ def _display_certificate_info(cert_chain, show_pem: bool, verbose: bool):
1364
+ """Display certificate information and signing details."""
1365
+
1366
+ server_cert = cert_chain.server_cert
1367
+
1368
+ console.print("[bold]📋 Certificate Information[/bold]")
1369
+ console.print()
1370
+
1371
+ # Who signed this certificate
1372
+ signing_table = Table(title="🔏 Certificate Signing Information")
1373
+ signing_table.add_column("Property", style="cyan")
1374
+ signing_table.add_column("Value", style="yellow")
1375
+
1376
+ signing_table.add_row("Certificate Subject", server_cert.subject)
1377
+ signing_table.add_row("Signed By (Issuer)", server_cert.issuer)
1378
+ signing_table.add_row("Self-Signed", "✅ Yes" if server_cert.is_self_signed else "❌ No")
1379
+ signing_table.add_row("Certificate Authority", "✅ Yes" if server_cert.is_ca else "❌ No")
1380
+ signing_table.add_row("Signature Algorithm", server_cert.signature_algorithm)
1381
+
1382
+ console.print(signing_table)
1383
+ console.print()
1384
+
1385
+ # Certificate hierarchy
1386
+ if not server_cert.is_self_signed:
1387
+ console.print("[bold]🏗️ Certificate Hierarchy (Chain of Trust)[/bold]")
1388
+ hierarchy_table = Table()
1389
+ hierarchy_table.add_column("Level", style="cyan")
1390
+ hierarchy_table.add_column("Certificate", style="yellow")
1391
+ hierarchy_table.add_column("Signed By", style="green")
1392
+
1393
+ # Server certificate
1394
+ first_issuer = cert_chain.intermediate_certs[0].subject if cert_chain.intermediate_certs else "Unknown"
1395
+ hierarchy_table.add_row("🖥️ Server", server_cert.subject.split(',')[0], first_issuer.split(',')[0])
1396
+
1397
+ # Intermediate certificates
1398
+ for i, intermediate in enumerate(cert_chain.intermediate_certs):
1399
+ next_issuer = cert_chain.intermediate_certs[i+1].subject if i+1 < len(cert_chain.intermediate_certs) else (
1400
+ cert_chain.root_cert.subject if cert_chain.root_cert else "Unknown Root"
1401
+ )
1402
+ hierarchy_table.add_row(f"🏢 Intermediate {i+1}", intermediate.subject.split(',')[0], next_issuer.split(',')[0])
1403
+
1404
+ # Root certificate
1405
+ if cert_chain.root_cert:
1406
+ hierarchy_table.add_row("🏛️ Root CA", cert_chain.root_cert.subject.split(',')[0], "Self-signed")
1407
+
1408
+ console.print(hierarchy_table)
1409
+ console.print()
1410
+
1411
+ # Certificate details
1412
+ details_table = Table(title="🔍 Certificate Details")
1413
+ details_table.add_column("Property", style="cyan")
1414
+ details_table.add_column("Value", style="yellow")
1415
+
1416
+ details_table.add_row("Serial Number", server_cert.serial_number)
1417
+ details_table.add_row("Valid From", server_cert.not_before.strftime("%Y-%m-%d %H:%M:%S UTC"))
1418
+ details_table.add_row("Valid Until", server_cert.not_after.strftime("%Y-%m-%d %H:%M:%S UTC"))
1419
+
1420
+ # Calculate days until expiration
1421
+ from datetime import datetime, timezone
1422
+ now = datetime.now(timezone.utc).replace(tzinfo=None)
1423
+ days_until_expiry = (server_cert.not_after - now).days
1424
+ expiry_color = "green" if days_until_expiry > 30 else "orange" if days_until_expiry > 7 else "red"
1425
+ details_table.add_row("Days Until Expiry", f"[{expiry_color}]{days_until_expiry}[/{expiry_color}]")
1426
+
1427
+ details_table.add_row("Public Key", f"{server_cert.public_key_algorithm} ({server_cert.key_size} bits)")
1428
+ details_table.add_row("Key Usage", ", ".join(server_cert.extensions.get("keyUsage", [])))
1429
+
1430
+ console.print(details_table)
1431
+ console.print()
1432
+
1433
+ # Subject Alternative Names
1434
+ if server_cert.san_domains:
1435
+ san_table = Table(title="🌐 Subject Alternative Names (SAN)")
1436
+ san_table.add_column("Domain", style="green")
1437
+ for domain in server_cert.san_domains:
1438
+ san_table.add_row(domain)
1439
+ console.print(san_table)
1440
+ console.print()
1441
+
1442
+ # Extensions (if verbose)
1443
+ if verbose and server_cert.extensions:
1444
+ ext_table = Table(title="🔧 Certificate Extensions")
1445
+ ext_table.add_column("Extension", style="cyan")
1446
+ ext_table.add_column("Value", style="yellow")
1447
+
1448
+ for ext_name, ext_value in server_cert.extensions.items():
1449
+ if isinstance(ext_value, list):
1450
+ ext_value = ", ".join(str(v) for v in ext_value)
1451
+ elif isinstance(ext_value, dict):
1452
+ ext_value = str(ext_value)
1453
+ ext_table.add_row(ext_name, str(ext_value)[:100])
1454
+
1455
+ console.print(ext_table)
1456
+ console.print()
1457
+
1458
+ # PEM certificate (if requested)
1459
+ if show_pem:
1460
+ console.print("[bold]📄 Certificate in PEM Format[/bold]")
1461
+ console.print()
1462
+ console.print("[green]" + server_cert.pem_data + "[/green]")
1463
+
1464
+
1465
+ async def _save_certificate_results(cert_chain, output_file: str, hostname_valid: bool):
1466
+ """Save certificate analysis results to file."""
1467
+ result = {
1468
+ "server_certificate": {
1469
+ "subject": cert_chain.server_cert.subject,
1470
+ "issuer": cert_chain.server_cert.issuer,
1471
+ "serial_number": cert_chain.server_cert.serial_number,
1472
+ "valid_from": cert_chain.server_cert.not_before.isoformat(),
1473
+ "valid_until": cert_chain.server_cert.not_after.isoformat(),
1474
+ "is_valid": cert_chain.server_cert.is_valid_now,
1475
+ "is_expired": cert_chain.server_cert.is_expired,
1476
+ "fingerprint_sha256": cert_chain.server_cert.fingerprint_sha256,
1477
+ "key_algorithm": cert_chain.server_cert.public_key_algorithm,
1478
+ "key_size": cert_chain.server_cert.key_size,
1479
+ "signature_algorithm": cert_chain.server_cert.signature_algorithm,
1480
+ "san_domains": cert_chain.server_cert.san_domains
1481
+ },
1482
+ "chain_analysis": {
1483
+ "chain_valid": cert_chain.chain_valid,
1484
+ "chain_complete": cert_chain.chain_complete,
1485
+ "intermediate_count": len(cert_chain.intermediate_certs),
1486
+ "has_root": cert_chain.root_cert is not None,
1487
+ "missing_intermediates": cert_chain.missing_intermediates,
1488
+ "trust_issues": cert_chain.trust_issues
1489
+ },
1490
+ "hostname_validation": {
1491
+ "hostname_valid": hostname_valid
1492
+ },
1493
+ "revocation_info": {
1494
+ "ocsp_urls": cert_chain.ocsp_urls,
1495
+ "crl_urls": cert_chain.crl_urls
1496
+ }
1497
+ }
1498
+
1499
+ with open(output_file, 'w') as f:
1500
+ json.dump(result, f, indent=2)
1501
+
1502
+
1503
+ async def _save_certificate_chain_results(cert_chain, revocation_results: dict, output_file: str):
1504
+ """Save certificate chain analysis results to file."""
1505
+ result = {
1506
+ "chain_analysis": {
1507
+ "valid": cert_chain.chain_valid,
1508
+ "complete": cert_chain.chain_complete,
1509
+ "missing_intermediates": cert_chain.missing_intermediates,
1510
+ "trust_issues": cert_chain.trust_issues
1511
+ },
1512
+ "certificates": [],
1513
+ "revocation_check": revocation_results
1514
+ }
1515
+
1516
+ # Add server certificate
1517
+ result["certificates"].append({
1518
+ "type": "server",
1519
+ "subject": cert_chain.server_cert.subject,
1520
+ "issuer": cert_chain.server_cert.issuer,
1521
+ "serial_number": cert_chain.server_cert.serial_number,
1522
+ "valid_from": cert_chain.server_cert.not_before.isoformat(),
1523
+ "valid_until": cert_chain.server_cert.not_after.isoformat(),
1524
+ "is_valid": cert_chain.server_cert.is_valid_now,
1525
+ "fingerprint_sha256": cert_chain.server_cert.fingerprint_sha256
1526
+ })
1527
+
1528
+ # Add intermediate certificates
1529
+ for cert in cert_chain.intermediate_certs:
1530
+ result["certificates"].append({
1531
+ "type": "intermediate",
1532
+ "subject": cert.subject,
1533
+ "issuer": cert.issuer,
1534
+ "serial_number": cert.serial_number,
1535
+ "valid_from": cert.not_before.isoformat(),
1536
+ "valid_until": cert.not_after.isoformat(),
1537
+ "is_valid": cert.is_valid_now,
1538
+ "fingerprint_sha256": cert.fingerprint_sha256
1539
+ })
1540
+
1541
+ # Add root certificate if found
1542
+ if cert_chain.root_cert:
1543
+ result["certificates"].append({
1544
+ "type": "root",
1545
+ "subject": cert_chain.root_cert.subject,
1546
+ "issuer": cert_chain.root_cert.issuer,
1547
+ "serial_number": cert_chain.root_cert.serial_number,
1548
+ "valid_from": cert_chain.root_cert.not_before.isoformat(),
1549
+ "valid_until": cert_chain.root_cert.not_after.isoformat(),
1550
+ "is_valid": cert_chain.root_cert.is_valid_now,
1551
+ "fingerprint_sha256": cert_chain.root_cert.fingerprint_sha256
1552
+ })
1553
+
1554
+ with open(output_file, 'w') as f:
1555
+ json.dump(result, f, indent=2)
1556
+
1557
+
1558
+ async def _save_certificate_info_results(cert_chain, output_file: str, include_pem: bool):
1559
+ """Save certificate information results to file."""
1560
+ result = {
1561
+ "certificate_info": {
1562
+ "subject": cert_chain.server_cert.subject,
1563
+ "issuer": cert_chain.server_cert.issuer,
1564
+ "serial_number": cert_chain.server_cert.serial_number,
1565
+ "valid_from": cert_chain.server_cert.not_before.isoformat(),
1566
+ "valid_until": cert_chain.server_cert.not_after.isoformat(),
1567
+ "is_self_signed": cert_chain.server_cert.is_self_signed,
1568
+ "is_ca": cert_chain.server_cert.is_ca,
1569
+ "fingerprint_sha1": cert_chain.server_cert.fingerprint_sha1,
1570
+ "fingerprint_sha256": cert_chain.server_cert.fingerprint_sha256,
1571
+ "signature_algorithm": cert_chain.server_cert.signature_algorithm,
1572
+ "public_key_algorithm": cert_chain.server_cert.public_key_algorithm,
1573
+ "key_size": cert_chain.server_cert.key_size,
1574
+ "san_domains": cert_chain.server_cert.san_domains,
1575
+ "extensions": cert_chain.server_cert.extensions
1576
+ },
1577
+ "signing_hierarchy": []
1578
+ }
1579
+
1580
+ # Add signing hierarchy
1581
+ if not cert_chain.server_cert.is_self_signed:
1582
+ result["signing_hierarchy"].append({
1583
+ "level": "server",
1584
+ "certificate": cert_chain.server_cert.subject,
1585
+ "signed_by": cert_chain.server_cert.issuer
1586
+ })
1587
+
1588
+ for i, intermediate in enumerate(cert_chain.intermediate_certs):
1589
+ result["signing_hierarchy"].append({
1590
+ "level": f"intermediate_{i+1}",
1591
+ "certificate": intermediate.subject,
1592
+ "signed_by": intermediate.issuer
1593
+ })
1594
+
1595
+ if cert_chain.root_cert:
1596
+ result["signing_hierarchy"].append({
1597
+ "level": "root",
1598
+ "certificate": cert_chain.root_cert.subject,
1599
+ "signed_by": "self-signed"
1600
+ })
1601
+
1602
+ if include_pem:
1603
+ result["certificate_info"]["pem_data"] = cert_chain.server_cert.pem_data
1604
+
1605
+ with open(output_file, 'w') as f:
1606
+ json.dump(result, f, indent=2)
1607
+
1608
+
1609
+ def _display_dns_trace(result: L7Result):
1610
+ """Display DNS trace information."""
1611
+
1612
+ if not result.dns_trace or not any(result.dns_trace.values()):
1613
+ console.print(f"[yellow]No DNS trace information available for {result.host}[/yellow]")
1614
+ return
1615
+
1616
+ dns_trace = result.dns_trace
1617
+
1618
+ # Prepare the trace panel content
1619
+ trace_content = []
1620
+ trace_content.append(f"[bold]DNS Trace for {result.host}[/bold]")
1621
+ trace_content.append("")
1622
+
1623
+ # Show CNAME chain
1624
+ if "cname_chain" in dns_trace and dns_trace["cname_chain"]:
1625
+ trace_content.append("[bold cyan]CNAME Chain:[/bold cyan]")
1626
+ for cname in dns_trace["cname_chain"]:
1627
+ trace_content.append(f" {cname['from']} → [cyan]{cname['to']}[/cyan] (depth: {cname['depth']})")
1628
+ trace_content.append("")
1629
+ else:
1630
+ trace_content.append("[yellow]No CNAME records found[/yellow]")
1631
+ trace_content.append("")
1632
+
1633
+ # Show resolved IPs
1634
+ if "resolved_ips" in dns_trace and dns_trace["resolved_ips"]:
1635
+ trace_content.append("[bold cyan]Resolved IPs:[/bold cyan]")
1636
+ for host, ips in dns_trace["resolved_ips"].items():
1637
+ trace_content.append(f" [bold]{host}:[/bold] {', '.join(ips)}")
1638
+ trace_content.append("")
1639
+
1640
+ # Show IP protection
1641
+ if "ip_protection" in dns_trace and dns_trace["ip_protection"]:
1642
+ trace_content.append("[bold cyan]IP Protection Analysis:[/bold cyan]")
1643
+ for ip, protection in dns_trace["ip_protection"].items():
1644
+ if "service" in protection:
1645
+ trace_content.append(f" [green]{ip}: {protection['service']} ({protection['confidence']:.1%}) via {protection['origin_host']}[/green]")
1646
+ elif "error" in protection:
1647
+ trace_content.append(f" [red]{ip}: Failed to check ({protection['error']})[/red]")
1648
+
1649
+ # Display primary protection and IP protection comparison
1650
+ if result.is_protected:
1651
+ trace_content.append("")
1652
+ trace_content.append("[bold]Protection Analysis:[/bold]")
1653
+ trace_content.append(f" Domain: [cyan]{result.primary_protection.service.value}[/cyan] ({result.primary_protection.confidence:.1%})")
1654
+
1655
+ # Check if the IP protection matches the domain protection
1656
+ ip_services = set()
1657
+ for protection in dns_trace.get("ip_protection", {}).values():
1658
+ if "service" in protection:
1659
+ ip_services.add(protection["service"])
1660
+
1661
+ if ip_services:
1662
+ trace_content.append(f" IP services: [cyan]{', '.join(ip_services)}[/cyan]")
1663
+
1664
+ # Compare domain protection with IP protection
1665
+ domain_service = result.primary_protection.service.value
1666
+ if domain_service in ip_services:
1667
+ trace_content.append("[green] ✓ Domain and IP protection match[/green]")
1668
+ else:
1669
+ trace_content.append("[yellow] ⚠ Domain and IP protection differ[/yellow]")
1670
+
1671
+ # Display the panel
1672
+ console.print(
1673
+ Panel(
1674
+ "\n".join(trace_content),
1675
+ title=f"DNS Trace - {result.host}",
1676
+ border_style="blue",
1677
+ )
1678
+ )
1679
+
1680
+
1681
+ async def _run_mtls_check(
1682
+ targets: List[str],
1683
+ port: int,
1684
+ timeout: int,
1685
+ client_cert: Optional[str],
1686
+ client_key: Optional[str],
1687
+ ca_bundle: Optional[str],
1688
+ output: Optional[str],
1689
+ verbose: bool,
1690
+ verify_ssl: bool,
1691
+ concurrent: int,
1692
+ max_retries: int = 3,
1693
+ retry_delay: float = 1.0,
1694
+ ):
1695
+ """Run mTLS checking with progress display and enhanced configuration."""
1696
+
1697
+ mtls_checker = MTLSChecker(
1698
+ timeout=timeout,
1699
+ verify_ssl=verify_ssl,
1700
+ max_retries=max_retries,
1701
+ retry_delay=retry_delay,
1702
+ enable_logging=verbose
1703
+ )
1704
+ start_time = time.time()
1705
+ results = []
1706
+
1707
+ with Progress(
1708
+ SpinnerColumn(),
1709
+ TextColumn("[progress.description]{task.description}"),
1710
+ BarColumn(),
1711
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
1712
+ TimeElapsedColumn(),
1713
+ console=console,
1714
+ ) as progress:
1715
+
1716
+ mtls_task = progress.add_task("Checking mTLS support...", total=len(targets))
1717
+
1718
+ # Prepare target list with ports
1719
+ target_ports = []
1720
+ for target in targets:
1721
+ if ':' in target and not target.startswith('['): # Handle IPv6 addresses
1722
+ host, port_str = target.rsplit(':', 1)
1723
+ try:
1724
+ target_port = int(port_str)
1725
+ target_ports.append((host, target_port))
1726
+ except ValueError:
1727
+ target_ports.append((target, port))
1728
+ else:
1729
+ target_ports.append((target, port))
1730
+
1731
+ try:
1732
+ # Use batch checking for efficiency with progress callback
1733
+ def progress_callback(completed, total, result):
1734
+ progress.update(mtls_task, completed=completed)
1735
+
1736
+ results = await mtls_checker.batch_check_mtls(
1737
+ target_ports,
1738
+ client_cert_path=client_cert,
1739
+ client_key_path=client_key,
1740
+ ca_bundle_path=ca_bundle,
1741
+ max_concurrent=concurrent,
1742
+ progress_callback=progress_callback
1743
+ )
1744
+
1745
+ progress.update(mtls_task, completed=len(targets))
1746
+
1747
+ if verbose:
1748
+ for result in results:
1749
+ _display_mtls_result(result)
1750
+
1751
+ except Exception as e:
1752
+ console.print(f"[red]Error during mTLS check: {e}[/red]")
1753
+ return
1754
+
1755
+ # Display summary
1756
+ duration = time.time() - start_time
1757
+ _display_mtls_summary(results, duration)
1758
+
1759
+ # Show performance metrics
1760
+ metrics = mtls_checker.get_metrics()
1761
+ if verbose and metrics['total_requests'] > 0:
1762
+ _display_mtls_metrics(metrics)
1763
+
1764
+ # Save results if output file specified
1765
+ if output:
1766
+ batch_result = BatchMTLSResult.from_results(results)
1767
+ _save_mtls_results(batch_result, output)
1768
+
1769
+
1770
+ def _display_mtls_result(result: MTLSResult):
1771
+ """Display mTLS check result for a single target."""
1772
+
1773
+ # Create a table for the result
1774
+ table = Table(title=f"mTLS Check - {result.target}:{result.port}")
1775
+ table.add_column("Property", style="cyan", no_wrap=True)
1776
+ table.add_column("Value", style="white")
1777
+
1778
+ # Basic connectivity
1779
+ if result.error_message:
1780
+ table.add_row("Status", f"[red]Failed: {result.error_message}[/red]")
1781
+ console.print(table)
1782
+ console.print()
1783
+ return
1784
+
1785
+ table.add_row("Status", "[green]Connected[/green]")
1786
+ table.add_row("Supports mTLS", "[green]Yes[/green]" if result.supports_mtls else "[red]No[/red]")
1787
+ table.add_row("Requires Client Cert", "[red]Required[/red]" if result.requires_client_cert else "[yellow]Optional[/yellow]")
1788
+ table.add_row("Client Cert Requested", "[green]Yes[/green]" if result.client_cert_requested else "[red]No[/red]")
1789
+
1790
+ # Connection details
1791
+ if result.handshake_successful:
1792
+ table.add_row("mTLS Handshake", "[green]Successful[/green]")
1793
+ if result.cipher_suite:
1794
+ table.add_row("Cipher Suite", result.cipher_suite)
1795
+ if result.tls_version:
1796
+ table.add_row("TLS Version", result.tls_version)
1797
+ else:
1798
+ table.add_row("mTLS Handshake", "[red]Failed[/red]")
1799
+
1800
+ # Certificate information
1801
+ if result.server_cert_info:
1802
+ cert = result.server_cert_info
1803
+ table.add_row("Server Certificate", "")
1804
+ table.add_row(" Subject", cert.subject)
1805
+ table.add_row(" Issuer", cert.issuer)
1806
+ table.add_row(" Valid From", cert.not_valid_before)
1807
+ table.add_row(" Valid Until", cert.not_valid_after)
1808
+ table.add_row(" Algorithm", f"{cert.key_algorithm} ({cert.key_size} bits)" if cert.key_size else cert.key_algorithm)
1809
+
1810
+ if cert.san_dns_names:
1811
+ table.add_row(" SAN DNS", ", ".join(cert.san_dns_names[:3]) + ("..." if len(cert.san_dns_names) > 3 else ""))
1812
+
1813
+ if cert.is_self_signed:
1814
+ table.add_row(" Self-Signed", "[yellow]Yes[/yellow]")
1815
+
1816
+ console.print(table)
1817
+ console.print()
1818
+
1819
+
1820
+ def _display_mtls_summary(results: List[MTLSResult], duration: float):
1821
+ """Display summary of mTLS check results."""
1822
+
1823
+ # Count different result types
1824
+ total = len(results)
1825
+ successful = sum(1 for r in results if r.error_message is None)
1826
+ failed = total - successful
1827
+ supports_mtls = sum(1 for r in results if r.supports_mtls)
1828
+ requires_client_cert = sum(1 for r in results if r.requires_client_cert)
1829
+ handshake_success = sum(1 for r in results if r.handshake_successful)
1830
+
1831
+ # Create summary table
1832
+ summary_table = Table(title="mTLS Check Summary", show_header=True)
1833
+ summary_table.add_column("Metric", style="cyan")
1834
+ summary_table.add_column("Count", style="white", justify="right")
1835
+ summary_table.add_column("Percentage", style="yellow", justify="right")
1836
+
1837
+ summary_table.add_row("Total Targets", str(total), "100%")
1838
+ summary_table.add_row("Successful Checks", str(successful), f"{(successful/total)*100:.1f}%" if total > 0 else "0%")
1839
+ summary_table.add_row("Failed Checks", str(failed), f"{(failed/total)*100:.1f}%" if total > 0 else "0%")
1840
+ summary_table.add_row("mTLS Supported", str(supports_mtls), f"{(supports_mtls/total)*100:.1f}%" if total > 0 else "0%")
1841
+ summary_table.add_row("Client Cert Required", str(requires_client_cert), f"{(requires_client_cert/total)*100:.1f}%" if total > 0 else "0%")
1842
+ summary_table.add_row("Handshake Successful", str(handshake_success), f"{(handshake_success/total)*100:.1f}%" if total > 0 else "0%")
1843
+
1844
+ console.print(summary_table)
1845
+ console.print(f"\n[blue]Scan completed in {duration:.2f} seconds[/blue]")
1846
+
1847
+ # Show notable findings
1848
+ if requires_client_cert > 0:
1849
+ console.print(f"\n[yellow]⚠ {requires_client_cert} target(s) require client certificates for authentication[/yellow]")
1850
+
1851
+ if supports_mtls > 0:
1852
+ console.print(f"[green]✓ {supports_mtls} target(s) support mTLS authentication[/green]")
1853
+
1854
+
1855
+ def _display_mtls_metrics(metrics: Dict[str, Any]):
1856
+ """Display detailed mTLS performance metrics."""
1857
+
1858
+ # Create metrics table
1859
+ metrics_table = Table(title="📊 mTLS Performance Metrics", show_header=True)
1860
+ metrics_table.add_column("Metric", style="cyan")
1861
+ metrics_table.add_column("Value", style="white", justify="right")
1862
+
1863
+ total_requests = metrics.get('total_requests', 0)
1864
+ if total_requests > 0:
1865
+ avg_time = metrics.get('total_time', 0) / total_requests
1866
+ success_rate = (metrics.get('successful_connections', 0) / total_requests) * 100
1867
+
1868
+ metrics_table.add_row("Total Requests", str(total_requests))
1869
+ metrics_table.add_row("Successful Connections", str(metrics.get('successful_connections', 0)))
1870
+ metrics_table.add_row("Failed Connections", str(metrics.get('failed_connections', 0)))
1871
+ metrics_table.add_row("Success Rate", f"{success_rate:.1f}%")
1872
+ metrics_table.add_row("Average Time", f"{avg_time:.3f}s")
1873
+ metrics_table.add_row("Total Time", f"{metrics.get('total_time', 0):.3f}s")
1874
+
1875
+ # Error breakdown
1876
+ if metrics.get('network_errors', 0) > 0:
1877
+ metrics_table.add_row("Network Errors", str(metrics.get('network_errors', 0)))
1878
+ if metrics.get('timeout_errors', 0) > 0:
1879
+ metrics_table.add_row("Timeout Errors", str(metrics.get('timeout_errors', 0)))
1880
+ if metrics.get('certificate_errors', 0) > 0:
1881
+ metrics_table.add_row("Certificate Errors", str(metrics.get('certificate_errors', 0)))
1882
+
1883
+ # mTLS specific metrics
1884
+ metrics_table.add_row("mTLS Supported", str(metrics.get('mtls_supported', 0)))
1885
+ metrics_table.add_row("Client Cert Required", str(metrics.get('client_cert_required', 0)))
1886
+ metrics_table.add_row("Handshake Failures", str(metrics.get('handshake_failures', 0)))
1887
+
1888
+ console.print(metrics_table)
1889
+
1890
+
1891
+ async def _run_hybrid_identity_check(
1892
+ targets: List[str],
1893
+ timeout: int,
1894
+ output: Optional[str],
1895
+ verbose: bool,
1896
+ concurrent: int,
1897
+ ):
1898
+ """Run hybrid identity check with progress display."""
1899
+
1900
+ checker = HybridIdentityChecker(timeout=timeout)
1901
+
1902
+ start_time = time.time()
1903
+ results = []
1904
+
1905
+ with Progress(
1906
+ SpinnerColumn(),
1907
+ TextColumn("[progress.description]{task.description}"),
1908
+ BarColumn(),
1909
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
1910
+ TimeElapsedColumn(),
1911
+ console=console,
1912
+ ) as progress:
1913
+
1914
+ check_task = progress.add_task("Checking hybrid identity...", total=len(targets))
1915
+
1916
+ # Process in batches for concurrent limit
1917
+ batch_size = concurrent
1918
+ for i in range(0, len(targets), batch_size):
1919
+ batch = targets[i:i+batch_size]
1920
+ batch_results = await checker.batch_check(batch)
1921
+ results.extend(batch_results)
1922
+
1923
+ # Display individual results if verbose
1924
+ if verbose:
1925
+ for result in batch_results:
1926
+ _display_hybrid_identity_result(result)
1927
+
1928
+ progress.update(check_task, advance=len(batch))
1929
+
1930
+ total_time = time.time() - start_time
1931
+
1932
+ # Display summary
1933
+ _display_hybrid_identity_summary(results, total_time)
1934
+
1935
+ # Save output if requested
1936
+ if output:
1937
+ output_data = {
1938
+ "scan_time": datetime.now().isoformat(),
1939
+ "total_time": total_time,
1940
+ "total_targets": len(results),
1941
+ "results": [r.to_dict() for r in results]
1942
+ }
1943
+ with open(output, "w") as f:
1944
+ json.dump(output_data, f, indent=2)
1945
+ console.print(f"\n[green]✅ Results saved to {output}[/green]")
1946
+
1947
+
1948
+ def _display_hybrid_identity_result(result: HybridIdentityResult):
1949
+ """Display hybrid identity check result for a single domain."""
1950
+
1951
+ # Create result table
1952
+ table = Table(title=f"🔐 Hybrid Identity Check - {result.fqdn}", show_header=True)
1953
+ table.add_column("Property", style="cyan", width=25)
1954
+ table.add_column("Value", style="white")
1955
+
1956
+ # Overall status
1957
+ if result.error:
1958
+ table.add_row("Status", f"[red]❌ Error: {result.error}[/red]")
1959
+ elif result.has_hybrid_identity:
1960
+ table.add_row("Status", "[green]✅ Hybrid Identity Detected[/green]")
1961
+ else:
1962
+ table.add_row("Status", "[yellow]⚠️ No Hybrid Identity Found[/yellow]")
1963
+
1964
+ # ADFS Detection
1965
+ if result.has_adfs:
1966
+ table.add_row("ADFS Endpoint", f"[green]✅ Found[/green]")
1967
+ if result.adfs_endpoint:
1968
+ table.add_row(" Endpoint URL", result.adfs_endpoint)
1969
+ if result.adfs_status_code:
1970
+ table.add_row(" Status Code", str(result.adfs_status_code))
1971
+ else:
1972
+ table.add_row("ADFS Endpoint", "[red]❌ Not Found[/red]")
1973
+
1974
+ # Federation Metadata
1975
+ if result.federation_metadata_found:
1976
+ table.add_row("Federation Metadata", "[green]✅ Found[/green]")
1977
+ else:
1978
+ table.add_row("Federation Metadata", "[red]❌ Not Found[/red]")
1979
+
1980
+ # Azure AD Integration
1981
+ if result.azure_ad_detected:
1982
+ table.add_row("Azure AD Integration", "[green]✅ Detected[/green]")
1983
+ else:
1984
+ table.add_row("Azure AD Integration", "[red]❌ Not Detected[/red]")
1985
+
1986
+ # OpenID Connect
1987
+ if result.openid_config_found:
1988
+ table.add_row("OpenID Configuration", "[green]✅ Found[/green]")
1989
+ else:
1990
+ table.add_row("OpenID Configuration", "[red]❌ Not Found[/red]")
1991
+
1992
+ # DNS Records
1993
+ if result.dns_records:
1994
+ dns_info = []
1995
+ if result.dns_records.get('A'):
1996
+ dns_info.append(f"A: {len(result.dns_records['A'])} records")
1997
+ if result.dns_records.get('CNAME'):
1998
+ dns_info.append(f"CNAME: {', '.join(result.dns_records['CNAME'][:2])}")
1999
+ if result.dns_records.get('microsoft_verification'):
2000
+ dns_info.append("[green]MS Verification ✓[/green]")
2001
+ if result.dns_records.get('microsoft_mail'):
2002
+ dns_info.append("[green]MS Mail ✓[/green]")
2003
+ if result.dns_records.get('adfs_subdomains'):
2004
+ dns_info.append(f"ADFS subdomains: {', '.join(result.dns_records['adfs_subdomains'])}")
2005
+
2006
+ if dns_info:
2007
+ table.add_row("DNS Records", "\n".join(dns_info))
2008
+
2009
+ # Response time
2010
+ table.add_row("Response Time", f"{result.response_time:.2f}s")
2011
+
2012
+ console.print(table)
2013
+ console.print()
2014
+
2015
+
2016
+ def _display_hybrid_identity_summary(results: List[HybridIdentityResult], duration: float):
2017
+ """Display summary of hybrid identity check results."""
2018
+
2019
+ # Count different result types
2020
+ total = len(results)
2021
+ has_hybrid = sum(1 for r in results if r.has_hybrid_identity)
2022
+ has_adfs = sum(1 for r in results if r.has_adfs)
2023
+ has_federation = sum(1 for r in results if r.federation_metadata_found)
2024
+ has_azure_ad = sum(1 for r in results if r.azure_ad_detected)
2025
+ has_openid = sum(1 for r in results if r.openid_config_found)
2026
+ errors = sum(1 for r in results if r.error)
2027
+
2028
+ # Create summary table
2029
+ summary_table = Table(title="🔐 Hybrid Identity Check Summary", show_header=True)
2030
+ summary_table.add_column("Metric", style="cyan")
2031
+ summary_table.add_column("Count", style="white", justify="right")
2032
+ summary_table.add_column("Percentage", style="yellow", justify="right")
2033
+
2034
+ summary_table.add_row("Total Domains", str(total), "100%")
2035
+ summary_table.add_row("Hybrid Identity Found", str(has_hybrid), f"{(has_hybrid/total)*100:.1f}%" if total > 0 else "0%")
2036
+ summary_table.add_row("ADFS Detected", str(has_adfs), f"{(has_adfs/total)*100:.1f}%" if total > 0 else "0%")
2037
+ summary_table.add_row("Federation Metadata", str(has_federation), f"{(has_federation/total)*100:.1f}%" if total > 0 else "0%")
2038
+ summary_table.add_row("Azure AD Integration", str(has_azure_ad), f"{(has_azure_ad/total)*100:.1f}%" if total > 0 else "0%")
2039
+ summary_table.add_row("OpenID Config", str(has_openid), f"{(has_openid/total)*100:.1f}%" if total > 0 else "0%")
2040
+ summary_table.add_row("Errors", str(errors), f"{(errors/total)*100:.1f}%" if total > 0 else "0%")
2041
+
2042
+ console.print(summary_table)
2043
+ console.print(f"\n[blue]✅ Scan completed in {duration:.2f} seconds[/blue]")
2044
+
2045
+ # Show notable findings
2046
+ if has_hybrid > 0:
2047
+ console.print(f"\n[green]✅ {has_hybrid} domain(s) have hybrid identity setup[/green]")
2048
+
2049
+ if has_adfs > 0:
2050
+ console.print(f"[green]🔒 {has_adfs} domain(s) have ADFS endpoints[/green]")
2051
+
2052
+ if has_azure_ad > 0:
2053
+ console.print(f"[blue]☁️ {has_azure_ad} domain(s) integrate with Azure AD[/blue]")
2054
+
2055
+ # Show domains with hybrid identity
2056
+ if has_hybrid > 0:
2057
+ console.print("\n[cyan]Domains with Hybrid Identity:[/cyan]")
2058
+ for result in results:
2059
+ if result.has_hybrid_identity:
2060
+ indicators = []
2061
+ if result.has_adfs:
2062
+ indicators.append("ADFS")
2063
+ if result.federation_metadata_found:
2064
+ indicators.append("Federation")
2065
+ if result.azure_ad_detected:
2066
+ indicators.append("Azure AD")
2067
+ if result.openid_config_found:
2068
+ indicators.append("OpenID")
2069
+ console.print(f" • {result.fqdn} - {', '.join(indicators)}")
2070
+
2071
+
2072
+ @main.command("owasp-scan")
2073
+ @click.argument("targets", nargs=-1, required=True)
2074
+ @click.option("--deep/--safe-mode", default=False, help="Enable deep scan with active probing (default: safe-mode)")
2075
+ @click.option("--categories", "-c", help="Comma-separated OWASP categories to scan (e.g., A01,A02,A05)")
2076
+ @click.option("--tech-stack", "-t", type=click.Choice(["apache", "nginx", "iis", "cloudflare", "generic"]), default="generic", help="Technology stack for remediation examples")
2077
+ @click.option("--format", "-f", type=click.Choice(["console", "json", "csv", "pdf"]), default="console", help="Output format")
2078
+ @click.option("--output", "-o", type=click.Path(), help="Output file path (required for json/csv/pdf formats)")
2079
+ @click.option("--severity", type=click.Choice(["CRITICAL", "HIGH", "MEDIUM", "LOW"]), help="Filter by minimum severity level")
2080
+ @click.option("--verbose/--quiet", default=False, help="Verbose (full findings) or quiet (grade summary only)")
2081
+ @click.option("--timeout", default=10, help="Request timeout in seconds")
2082
+ def owasp_scan(targets, deep, categories, tech_stack, format, output, severity, verbose, timeout):
2083
+ """
2084
+ Perform OWASP Top 10 2021/2025 security vulnerability scan.
2085
+
2086
+ By default, runs in safe-mode with passive checks only on categories:
2087
+ A02 (Cryptographic Failures), A05 (Security Misconfiguration),
2088
+ A06 (Vulnerable Components), A07 (Authentication Failures).
2089
+
2090
+ OWASP 2025 new categories: A03_2025 (Software Supply Chain), A10_2025 (Exception Handling).
2091
+
2092
+ Use --deep flag to enable active probing across all categories.
2093
+
2094
+ Examples:
2095
+
2096
+ # Basic scan with console output
2097
+ offsec-ai owasp-scan example.com
2098
+
2099
+ # Deep scan with PDF report
2100
+ offsec-ai owasp-scan example.com --deep -f pdf -o report.pdf
2101
+
2102
+ # Scan specific categories with JSON output
2103
+ offsec-ai owasp-scan example.com -c A02,A05 -f json -o results.json
2104
+
2105
+ # OWASP 2025 categories
2106
+ offsec-ai owasp-scan example.com -c A03_2025,A10_2025 --verbose
2107
+
2108
+ # Multiple targets with severity filter
2109
+ offsec-ai owasp-scan site1.com site2.com --severity HIGH --verbose
2110
+ """
2111
+
2112
+ # Validate output file for non-console formats
2113
+ if format != "console" and not output:
2114
+ console.print("[red]Error: --output (-o) is required for json/csv/pdf formats[/red]")
2115
+ sys.exit(1)
2116
+
2117
+ # Parse categories
2118
+ category_list = None
2119
+ if categories:
2120
+ category_list = [c.strip().upper() for c in categories.split(",")]
2121
+ # Validate categories
2122
+ valid_categories = ["A01", "A02", "A03", "A04", "A05", "A06", "A07", "A08", "A09", "A10", "A03_2025", "A10_2025"]
2123
+ invalid = [c for c in category_list if c not in valid_categories]
2124
+ if invalid:
2125
+ console.print(f"[red]Error: Invalid categories: {', '.join(invalid)}[/red]")
2126
+ console.print(f"[yellow]Valid categories: {', '.join(valid_categories)}[/yellow]")
2127
+ sys.exit(1)
2128
+
2129
+ scan_mode = "deep" if deep else "safe"
2130
+
2131
+ console.print(f"[blue]Starting OWASP Top 10 2021/2025 security scan[/blue]")
2132
+ console.print(f"[yellow]Scan Mode: {scan_mode.upper()}[/yellow]")
2133
+ console.print(f"[yellow]Targets: {len(targets)}[/yellow]")
2134
+ if category_list:
2135
+ console.print(f"[yellow]Categories: {', '.join(category_list)}[/yellow]")
2136
+ console.print()
2137
+
2138
+ # Run scan
2139
+ asyncio.run(
2140
+ _run_owasp_scan(
2141
+ list(targets),
2142
+ scan_mode,
2143
+ category_list,
2144
+ tech_stack,
2145
+ format,
2146
+ output,
2147
+ severity,
2148
+ verbose,
2149
+ timeout,
2150
+ )
2151
+ )
2152
+
2153
+
2154
+ async def _run_owasp_scan(
2155
+ targets: List[str],
2156
+ scan_mode: str,
2157
+ categories: Optional[List[str]],
2158
+ tech_stack: str,
2159
+ output_format: str,
2160
+ output_file: Optional[str],
2161
+ severity_filter: Optional[str],
2162
+ verbose: bool,
2163
+ timeout: float,
2164
+ ):
2165
+ """Run OWASP vulnerability scan."""
2166
+
2167
+ # Initialize scanner
2168
+ scanner = OwaspScanner(
2169
+ mode=scan_mode,
2170
+ categories=categories,
2171
+ timeout=timeout,
2172
+ )
2173
+
2174
+ # Scan targets
2175
+ results = []
2176
+
2177
+ with Progress(
2178
+ SpinnerColumn(),
2179
+ TextColumn("[progress.description]{task.description}"),
2180
+ BarColumn(),
2181
+ TimeElapsedColumn(),
2182
+ console=console,
2183
+ ) as progress:
2184
+ task = progress.add_task(f"Scanning {len(targets)} target(s)...", total=len(targets))
2185
+
2186
+ for target in targets:
2187
+ try:
2188
+ result = await scanner.scan(target)
2189
+ results.append(result)
2190
+ progress.update(task, advance=1)
2191
+ except Exception as e:
2192
+ console.print(f"[red]Error scanning {target}: {str(e)}[/red]")
2193
+ progress.update(task, advance=1)
2194
+
2195
+ console.print()
2196
+
2197
+ # Filter by severity if specified
2198
+ if severity_filter:
2199
+ severity_level = SeverityLevel(severity_filter)
2200
+ severity_values = {
2201
+ SeverityLevel.CRITICAL: 4,
2202
+ SeverityLevel.HIGH: 3,
2203
+ SeverityLevel.MEDIUM: 2,
2204
+ SeverityLevel.LOW: 1,
2205
+ }
2206
+ min_severity_value = severity_values[severity_level]
2207
+
2208
+ for result in results:
2209
+ for category in result.categories:
2210
+ category.findings = [
2211
+ f for f in category.findings
2212
+ if severity_values[f.severity] >= min_severity_value
2213
+ ]
2214
+
2215
+ # Output results
2216
+ if output_format == "console":
2217
+ _display_owasp_results(results, verbose)
2218
+ elif output_format == "json":
2219
+ for result in results:
2220
+ export_to_json(result, output_file, include_remediation=True, tech_stack=tech_stack)
2221
+ console.print(f"[green]✓ Results exported to {output_file}[/green]")
2222
+ elif output_format == "csv":
2223
+ for result in results:
2224
+ export_to_csv(result, output_file, tech_stack=tech_stack)
2225
+ console.print(f"[green]✓ Results exported to {output_file}[/green]")
2226
+ elif output_format == "pdf":
2227
+ exporter = OwaspPdfExporter(tech_stack=tech_stack)
2228
+ for result in results:
2229
+ exporter.export(result, output_file)
2230
+ console.print(f"[green]✓ PDF report generated: {output_file}[/green]")
2231
+
2232
+
2233
+ def _display_owasp_results(results: List[OwaspScanResult], verbose: bool):
2234
+ """Display OWASP scan results in console."""
2235
+
2236
+ for result in results:
2237
+ # Header
2238
+ console.print()
2239
+ console.print(Panel(
2240
+ f"[bold]OWASP Top 10 2021 Security Assessment[/bold]\n"
2241
+ f"Target: {result.target}\n"
2242
+ f"Scan Mode: {result.scan_mode.value.upper()}\n"
2243
+ f"Duration: {result.scan_duration:.2f}s",
2244
+ title="Security Scan Report",
2245
+ border_style="blue",
2246
+ ))
2247
+ console.print()
2248
+
2249
+ # Overall grade
2250
+ grade_color = _get_grade_color(result.overall_grade)
2251
+ console.print(f"[bold]Overall Security Grade: [{grade_color}]{result.overall_grade}[/{grade_color}][/bold]")
2252
+ console.print(f"Total Score: {result.overall_score}")
2253
+ console.print(f"Total Findings: {len(result.all_findings)}")
2254
+
2255
+ if result.has_critical:
2256
+ console.print(f"[bold red]⚠ CRITICAL: {len(result.critical_findings)} critical finding(s) detected![/bold red]")
2257
+
2258
+ console.print()
2259
+
2260
+ # Quiet mode: just show grades
2261
+ if not verbose:
2262
+ _display_category_summary(result)
2263
+ else:
2264
+ # Verbose mode: show all findings
2265
+ _display_detailed_findings(result)
2266
+
2267
+
2268
+ def _display_category_summary(result: OwaspScanResult):
2269
+ """Display category summary table."""
2270
+
2271
+ table = Table(title="Category Grades", show_header=True, header_style="bold magenta")
2272
+ table.add_column("ID", style="cyan", width=4)
2273
+ table.add_column("Category", style="white", width=40)
2274
+ table.add_column("Grade", justify="center", width=6)
2275
+ table.add_column("Findings", justify="center", width=8)
2276
+ table.add_column("Score", justify="center", width=6)
2277
+
2278
+ for category in result.categories:
2279
+ grade_color = _get_grade_color(category.grade)
2280
+
2281
+ if not category.testable:
2282
+ table.add_row(
2283
+ category.category_id,
2284
+ category.category_name,
2285
+ "[dim]N/A[/dim]",
2286
+ "[dim]Not Testable[/dim]",
2287
+ "[dim]-[/dim]",
2288
+ )
2289
+ else:
2290
+ findings_count = str(len(category.findings))
2291
+ findings_style = "red" if len(category.findings) > 0 else "green"
2292
+
2293
+ table.add_row(
2294
+ category.category_id,
2295
+ category.category_name,
2296
+ f"[{grade_color}]{category.grade}[/{grade_color}]",
2297
+ f"[{findings_style}]{findings_count}[/{findings_style}]",
2298
+ str(category.category_score),
2299
+ )
2300
+
2301
+ console.print(table)
2302
+ console.print()
2303
+ console.print("[dim]Run with --verbose flag to see detailed findings[/dim]")
2304
+
2305
+
2306
+ def _display_detailed_findings(result: OwaspScanResult):
2307
+ """Display detailed findings for each category."""
2308
+
2309
+ for category in result.categories:
2310
+ # Category header
2311
+ console.print(f"\n[bold cyan]{category.category_id}: {category.category_name}[/bold cyan]")
2312
+ console.print(f"Grade: [{_get_grade_color(category.grade)}]{category.grade}[/{_get_grade_color(category.grade)}]")
2313
+
2314
+ if not category.testable:
2315
+ console.print(f"[dim italic]{category.not_testable_reason}[/dim italic]")
2316
+ continue
2317
+
2318
+ if not category.findings:
2319
+ console.print("[green]✓ No issues found[/green]")
2320
+ continue
2321
+
2322
+ # Findings table
2323
+ table = Table(show_header=True, header_style="bold yellow", box=None)
2324
+ table.add_column("Severity", width=10)
2325
+ table.add_column("Finding", width=50)
2326
+ table.add_column("Evidence", width=30)
2327
+
2328
+ for finding in category.findings:
2329
+ severity_color = _get_severity_color(finding.severity)
2330
+ table.add_row(
2331
+ f"[{severity_color}]{finding.severity.value}[/{severity_color}]",
2332
+ f"[bold]{finding.title}[/bold]\n{finding.description}",
2333
+ finding.evidence or "-",
2334
+ )
2335
+
2336
+ console.print(table)
2337
+
2338
+
2339
+ def _get_grade_color(grade: str) -> str:
2340
+ """Get Rich color for grade."""
2341
+ colors = {
2342
+ "A": "green",
2343
+ "B": "bright_green",
2344
+ "C": "yellow",
2345
+ "D": "orange1",
2346
+ "F": "red",
2347
+ "N/A": "dim",
2348
+ }
2349
+ return colors.get(grade, "white")
2350
+
2351
+
2352
+ def _get_severity_color(severity: SeverityLevel) -> str:
2353
+ """Get Rich color for severity."""
2354
+ colors = {
2355
+ SeverityLevel.CRITICAL: "bold red",
2356
+ SeverityLevel.HIGH: "red",
2357
+ SeverityLevel.MEDIUM: "yellow",
2358
+ SeverityLevel.LOW: "cyan",
2359
+ }
2360
+ return colors.get(severity, "white")
2361
+
2362
+
2363
+
2364
+ # ============================================================================
2365
+ # ai-owasp-scan — AI/LLM OWASP Top 10 black-box scanner
2366
+ # ============================================================================
2367
+
2368
+ @main.command("ai-owasp-scan")
2369
+ @click.argument("target_url")
2370
+ @click.option("--mode", type=click.Choice(["safe", "deep"]), default="safe", show_default=True,
2371
+ help="safe: benign probes only. deep: full adversarial suite.")
2372
+ @click.option("--categories", multiple=True, metavar="LLMxx",
2373
+ help="Limit to specific LLM categories e.g. --categories LLM01 --categories LLM07")
2374
+ @click.option("--api-format", type=click.Choice(["openai", "generic"]), default="openai",
2375
+ show_default=True, help="API request/response format.")
2376
+ @click.option("--model", default="gpt-3.5-turbo", show_default=True,
2377
+ help="Model name to pass in OpenAI-format requests.")
2378
+ @click.option("--header", "extra_headers", multiple=True, metavar="KEY:VALUE",
2379
+ help="Extra HTTP headers, e.g. --header 'Authorization:Bearer sk-...'")
2380
+ @click.option("--judge", is_flag=True, default=False,
2381
+ help="Use LLM judge (requires OPENAI_API_KEY or ANTHROPIC_API_KEY env var).")
2382
+ @click.option("--format", "output_format", type=click.Choice(["console", "json"]),
2383
+ default="console", show_default=True)
2384
+ @click.option("--output", "-o", type=click.Path(), default=None,
2385
+ help="Save JSON result to file.")
2386
+ def ai_owasp_scan(target_url, mode, categories, api_format, model, extra_headers,
2387
+ judge, output_format, output):
2388
+ """Probe a live LLM/AI endpoint for AI OWASP Top 10 (2025) vulnerabilities.
2389
+
2390
+ TARGET_URL is the full chat completions endpoint URL,
2391
+ e.g. https://api.openai.com/v1/chat/completions
2392
+ """
2393
+ asyncio.run(_run_ai_owasp_scan(
2394
+ target_url=target_url, mode=mode,
2395
+ categories=list(categories), api_format=api_format,
2396
+ model=model, extra_headers=list(extra_headers),
2397
+ use_judge=judge, output_format=output_format, output=output,
2398
+ ))
2399
+
2400
+
2401
+ async def _run_ai_owasp_scan(
2402
+ target_url, mode, categories, api_format, model, extra_headers,
2403
+ use_judge, output_format, output
2404
+ ):
2405
+ headers = {}
2406
+ for h in extra_headers:
2407
+ if ":" in h:
2408
+ k, v = h.split(":", 1)
2409
+ headers[k.strip()] = v.strip()
2410
+
2411
+ judge = None
2412
+ if use_judge:
2413
+ j = LLMJudge.from_env()
2414
+ if j.is_available():
2415
+ judge = j
2416
+ console.print("[bold cyan]LLM judge enabled.[/bold cyan]")
2417
+ else:
2418
+ console.print("[yellow]Warning: --judge flag set but no provider API key found. "
2419
+ "Set OPENAI_API_KEY or ANTHROPIC_API_KEY.[/yellow]")
2420
+
2421
+ scanner = LLMOwaspScanner(
2422
+ endpoint=target_url,
2423
+ mode=mode,
2424
+ categories=categories or None,
2425
+ api_format=api_format,
2426
+ headers=headers,
2427
+ model=model,
2428
+ judge=judge,
2429
+ )
2430
+
2431
+ with Progress(
2432
+ SpinnerColumn(),
2433
+ TextColumn("[progress.description]{task.description}"),
2434
+ TimeElapsedColumn(),
2435
+ console=console,
2436
+ ) as progress:
2437
+ task = progress.add_task(f"Probing {target_url}...", total=None)
2438
+ result: LLMScanResult = await scanner.scan()
2439
+ progress.stop_task(task)
2440
+
2441
+ if output_format == "json" or output:
2442
+ import json
2443
+ data = result.model_dump(mode="json")
2444
+ if output:
2445
+ Path(output).write_text(json.dumps(data, indent=2, default=str))
2446
+ console.print(f"[green]Results saved to {output}[/green]")
2447
+ if output_format == "json":
2448
+ console.print_json(json.dumps(data, default=str))
2449
+ return
2450
+
2451
+ _display_ai_owasp_result(result)
2452
+
2453
+
2454
+ def _display_ai_owasp_result(result: LLMScanResult) -> None:
2455
+ grade_color = {"A": "green", "B": "green", "C": "yellow", "D": "red", "F": "bold red"}
2456
+ color = grade_color.get(result.overall_grade, "white")
2457
+
2458
+ console.print(Panel(
2459
+ f"[bold]Target:[/bold] {result.target}\n"
2460
+ f"[bold]Mode:[/bold] {result.scan_mode.value}\n"
2461
+ f"[bold]Grade:[/bold] [{color}]{result.overall_grade}[/{color}] "
2462
+ f"[bold]Score:[/bold] {result.overall_score:.1f}/10 "
2463
+ f"[bold]Duration:[/bold] {result.scan_duration:.1f}s\n"
2464
+ f"[bold]Critical:[/bold] [red]{len(result.critical_findings)}[/red] "
2465
+ f"[bold]High:[/bold] [yellow]{len(result.high_findings)}[/yellow] "
2466
+ f"[bold]Total findings:[/bold] {len(result.all_findings)}",
2467
+ title="[bold cyan]AI/LLM OWASP Top 10 Scan Results[/bold cyan]",
2468
+ border_style="cyan",
2469
+ ))
2470
+
2471
+ for cat in result.categories:
2472
+ if not cat.testable:
2473
+ console.print(f" [dim]{cat.category_id} {cat.category_name}: "
2474
+ f"Not testable — {cat.not_testable_reason}[/dim]")
2475
+ continue
2476
+
2477
+ cat_color = grade_color.get(cat.grade, "white")
2478
+ console.print(
2479
+ f"\n [{cat_color}][{cat.grade}][/{cat_color}] "
2480
+ f"[bold]{cat.category_id}[/bold] {cat.category_name} "
2481
+ f"({len(cat.findings)} finding(s))"
2482
+ )
2483
+
2484
+ for finding in cat.findings:
2485
+ sev_color = {
2486
+ LLMSeverity.CRITICAL: "bold red",
2487
+ LLMSeverity.HIGH: "red",
2488
+ LLMSeverity.MEDIUM: "yellow",
2489
+ LLMSeverity.LOW: "cyan",
2490
+ }.get(finding.severity, "white")
2491
+ console.print(f" [{sev_color}]{finding.severity.value.upper()}[/{sev_color}] "
2492
+ f"{finding.title}")
2493
+ if finding.evidence:
2494
+ console.print(f" [dim]Evidence: {finding.evidence[:120]}[/dim]")
2495
+
2496
+
2497
+ # ============================================================================
2498
+ # mcp-scan — MCP endpoint security scanner
2499
+ # ============================================================================
2500
+
2501
+ @main.command("mcp-scan")
2502
+ @click.argument("target")
2503
+ @click.option("--transport", type=click.Choice(["http", "sse", "stdio"]), default="http",
2504
+ show_default=True, help="MCP transport protocol.")
2505
+ @click.option("--cmd", multiple=True, metavar="ARG",
2506
+ help="Command to launch MCP server for stdio transport, "
2507
+ "e.g. --cmd python --cmd server.py")
2508
+ @click.option("--header", "extra_headers", multiple=True, metavar="KEY:VALUE",
2509
+ help="Extra HTTP headers.")
2510
+ @click.option("--timeout", default=15.0, show_default=True, help="Request timeout (seconds).")
2511
+ @click.option("--format", "output_format", type=click.Choice(["console", "json"]),
2512
+ default="console", show_default=True)
2513
+ @click.option("--output", "-o", type=click.Path(), default=None,
2514
+ help="Save JSON result to file.")
2515
+ def mcp_scan(target, transport, cmd, extra_headers, timeout, output_format, output):
2516
+ """Scan an MCP (Model Context Protocol) endpoint for security vulnerabilities and CVEs.
2517
+
2518
+ TARGET is the MCP endpoint URL (HTTP/SSE) or 'stdio://local' for a local server.
2519
+
2520
+ Examples:
2521
+ offsec-ai mcp-scan https://mcp.example.com/mcp
2522
+ offsec-ai mcp-scan stdio://local --transport stdio --cmd python server.py
2523
+ """
2524
+ asyncio.run(_run_mcp_scan(
2525
+ target=target, transport=transport, cmd=list(cmd),
2526
+ extra_headers=list(extra_headers), timeout=timeout,
2527
+ output_format=output_format, output=output,
2528
+ ))
2529
+
2530
+
2531
+ async def _run_mcp_scan(target, transport, cmd, extra_headers, timeout, output_format, output):
2532
+ headers = {}
2533
+ for h in extra_headers:
2534
+ if ":" in h:
2535
+ k, v = h.split(":", 1)
2536
+ headers[k.strip()] = v.strip()
2537
+
2538
+ scanner = MCPScanner(
2539
+ target=target,
2540
+ transport=transport,
2541
+ cmd=cmd,
2542
+ headers=headers,
2543
+ timeout=timeout,
2544
+ )
2545
+
2546
+ with Progress(
2547
+ SpinnerColumn(),
2548
+ TextColumn("[progress.description]{task.description}"),
2549
+ TimeElapsedColumn(),
2550
+ console=console,
2551
+ ) as progress:
2552
+ task = progress.add_task(f"Scanning MCP endpoint {target}...", total=None)
2553
+ result: MCPScanResult = await scanner.scan()
2554
+ progress.stop_task(task)
2555
+
2556
+ if result.error:
2557
+ console.print(f"[bold red]Error:[/bold red] {result.error}")
2558
+ if result.auth_posture and result.auth_posture.requires_auth:
2559
+ console.print(
2560
+ f"[yellow]Auth required[/yellow] — type: [bold]{result.auth_posture.auth_type}[/bold]. "
2561
+ "Use [bold]--header[/bold] to pass credentials, e.g. "
2562
+ "[bold]--header 'Authorization: Bearer <token>'[/bold]"
2563
+ )
2564
+ if output:
2565
+ import json
2566
+ data = result.model_dump(mode="json")
2567
+ Path(output).write_text(json.dumps(data, indent=2, default=str))
2568
+ console.print(f"[green]Partial results saved to {output}[/green]")
2569
+ return
2570
+
2571
+ if output_format == "json" or output:
2572
+ import json
2573
+ data = result.model_dump(mode="json")
2574
+ if output:
2575
+ Path(output).write_text(json.dumps(data, indent=2, default=str))
2576
+ console.print(f"[green]Results saved to {output}[/green]")
2577
+ if output_format == "json":
2578
+ console.print_json(json.dumps(data, default=str))
2579
+ return
2580
+
2581
+ _display_mcp_scan_result(result)
2582
+
2583
+
2584
+ def _display_mcp_scan_result(result: MCPScanResult) -> None:
2585
+ all_vulns = result.all_vulns
2586
+ critical = [v for v in all_vulns if v.severity == MCPVulnSeverity.CRITICAL]
2587
+ high = [v for v in all_vulns if v.severity == MCPVulnSeverity.HIGH]
2588
+
2589
+ panel_color = "red" if critical else ("yellow" if high else "green")
2590
+ console.print(Panel(
2591
+ f"[bold]Target:[/bold] {result.target}\n"
2592
+ f"[bold]Transport:[/bold] {result.transport.value}\n"
2593
+ f"[bold]Server:[/bold] {result.server_info.name} {result.server_info.version} "
2594
+ f"(protocol {result.server_info.protocol_version})\n"
2595
+ f"[bold]Tools:[/bold] {len(result.tools)} "
2596
+ f"[bold]Resources:[/bold] {len(result.resources)} "
2597
+ f"[bold]Prompts:[/bold] {len(result.prompts)}\n"
2598
+ f"[bold]Auth:[/bold] {'[red]NONE[/red]' if result.auth_posture.unauthenticated_access else '[green]Required[/green]'}\n"
2599
+ f"[bold]Vulnerabilities:[/bold] [red]{len(critical)} critical[/red] "
2600
+ f"[yellow]{len(high)} high[/yellow] {len(all_vulns)} total "
2601
+ f"[bold]CVE matches:[/bold] {len(result.cve_matches)}\n"
2602
+ f"[bold]Duration:[/bold] {result.scan_duration:.1f}s",
2603
+ title="[bold cyan]MCP Security Scan Results[/bold cyan]",
2604
+ border_style=panel_color,
2605
+ ))
2606
+
2607
+ # Tools table
2608
+ if result.tools:
2609
+ table = Table(title="Enumerated Tools", show_header=True, header_style="bold blue")
2610
+ table.add_column("Name", style="cyan")
2611
+ table.add_column("Risky?", justify="center")
2612
+ table.add_column("Description (truncated)")
2613
+ for tool in result.tools:
2614
+ risky = "[red]YES[/red]" if tool.has_dangerous_keywords else "[green]no[/green]"
2615
+ table.add_row(tool.name, risky, tool.description[:80])
2616
+ console.print(table)
2617
+
2618
+ # Vulnerabilities
2619
+ if all_vulns:
2620
+ console.print("\n[bold]Vulnerabilities Found:[/bold]")
2621
+ for vuln in all_vulns:
2622
+ sev_color = {
2623
+ MCPVulnSeverity.CRITICAL: "bold red",
2624
+ MCPVulnSeverity.HIGH: "red",
2625
+ MCPVulnSeverity.MEDIUM: "yellow",
2626
+ MCPVulnSeverity.LOW: "cyan",
2627
+ }.get(vuln.severity, "white")
2628
+ cve = f" [{vuln.cve_id}]" if vuln.cve_id else ""
2629
+ console.print(
2630
+ f" [{sev_color}]{vuln.severity.value.upper()}[/{sev_color}] "
2631
+ f"[bold]{vuln.vuln_id}[/bold]{cve}: {vuln.title}"
2632
+ )
2633
+ if vuln.evidence:
2634
+ console.print(f" [dim]Evidence: {vuln.evidence[:100]}[/dim]")
2635
+ if vuln.remediation:
2636
+ console.print(f" [green]Fix: {vuln.remediation[:100]}[/green]")
2637
+
2638
+
2639
+ # ============================================================================
2640
+ # mcp-attack — MCP endpoint attacker (gated, authorized use only)
2641
+ # ============================================================================
2642
+
2643
+ @main.command("mcp-attack")
2644
+ @click.argument("target")
2645
+ @click.option("--i-have-authorization", "authorized", is_flag=True, default=False, required=True,
2646
+ help="REQUIRED: Confirms you have explicit written authorization to test this target.")
2647
+ @click.option("--transport", type=click.Choice(["http", "sse", "stdio"]), default="http",
2648
+ show_default=True)
2649
+ @click.option("--cmd", multiple=True, metavar="ARG",
2650
+ help="Command for stdio transport.")
2651
+ @click.option("--mode", type=click.Choice(["safe", "deep"]), default="safe", show_default=True,
2652
+ help="safe: auth-bypass probes only. deep: full attack suite.")
2653
+ @click.option("--header", "extra_headers", multiple=True, metavar="KEY:VALUE")
2654
+ @click.option("--timeout", default=15.0, show_default=True)
2655
+ @click.option("--format", "output_format", type=click.Choice(["console", "json"]),
2656
+ default="console", show_default=True)
2657
+ @click.option("--output", "-o", type=click.Path(), default=None)
2658
+ def mcp_attack(target, authorized, transport, cmd, mode, extra_headers, timeout,
2659
+ output_format, output):
2660
+ """Perform authorized active security testing against an MCP endpoint.
2661
+
2662
+ \b
2663
+ ⚠ WARNING: This command sends active attack payloads.
2664
+ Only run against systems you have EXPLICIT WRITTEN AUTHORIZATION to test.
2665
+ Unauthorized use is illegal.
2666
+
2667
+ \b
2668
+ Required flag: --i-have-authorization
2669
+
2670
+ Recommend running mcp-scan first to enumerate the target before attacking:
2671
+ offsec-ai mcp-scan https://mcp.example.com/mcp
2672
+ offsec-ai mcp-attack https://mcp.example.com/mcp --i-have-authorization --mode deep
2673
+ """
2674
+ if not authorized:
2675
+ console.print("[bold red]Error:[/bold red] --i-have-authorization flag is required. "
2676
+ "Only use this against systems you are authorized to test.")
2677
+ raise SystemExit(1)
2678
+
2679
+ asyncio.run(_run_mcp_attack(
2680
+ target=target, transport=transport, cmd=list(cmd),
2681
+ mode=mode, extra_headers=list(extra_headers),
2682
+ timeout=timeout, output_format=output_format, output=output,
2683
+ ))
2684
+
2685
+
2686
+ async def _run_mcp_attack(target, transport, cmd, mode, extra_headers, timeout,
2687
+ output_format, output):
2688
+ headers = {}
2689
+ for h in extra_headers:
2690
+ if ":" in h:
2691
+ k, v = h.split(":", 1)
2692
+ headers[k.strip()] = v.strip()
2693
+
2694
+ # First run a scan to guide attacks
2695
+ scan_result = None
2696
+ if mode == "deep":
2697
+ console.print("[cyan]Running reconnaissance scan first...[/cyan]")
2698
+ scanner = MCPScanner(target=target, transport=transport, cmd=cmd,
2699
+ headers=headers, timeout=timeout)
2700
+ try:
2701
+ scan_result = await scanner.scan()
2702
+ except Exception:
2703
+ pass
2704
+
2705
+ attacker = MCPAttacker(authorized=True)
2706
+
2707
+ with Progress(
2708
+ SpinnerColumn(),
2709
+ TextColumn("[progress.description]{task.description}"),
2710
+ TimeElapsedColumn(),
2711
+ console=console,
2712
+ ) as progress:
2713
+ task = progress.add_task(f"Attacking {target} ({mode} mode)...", total=None)
2714
+ report: MCPAttackReport = await attacker.attack(
2715
+ target=target, transport=transport, mode=mode,
2716
+ headers=headers, timeout=timeout, scan_result=scan_result,
2717
+ )
2718
+ progress.stop_task(task)
2719
+
2720
+ if output_format == "json" or output:
2721
+ import json
2722
+ data = report.model_dump(mode="json")
2723
+ if output:
2724
+ Path(output).write_text(json.dumps(data, indent=2, default=str))
2725
+ console.print(f"[green]Results saved to {output}[/green]")
2726
+ if output_format == "json":
2727
+ console.print_json(json.dumps(data, default=str))
2728
+ return
2729
+
2730
+ _display_mcp_attack_report(report)
2731
+
2732
+
2733
+ def _display_mcp_attack_report(report: MCPAttackReport) -> None:
2734
+ triggered = report.triggered_results
2735
+ panel_color = "red" if triggered else "green"
2736
+
2737
+ console.print(Panel(
2738
+ f"[bold]Target:[/bold] {report.target}\n"
2739
+ f"[bold]Transport:[/bold] {report.transport.value}\n"
2740
+ f"[bold]Attacks run:[/bold] {report.attacks_run} "
2741
+ f"[bold]Triggered:[/bold] [{'red' if triggered else 'green'}]{report.attacks_triggered}[/{'red' if triggered else 'green'}]\n"
2742
+ f"[bold]Duration:[/bold] {report.scan_duration:.1f}s\n"
2743
+ f"[dim]{report.authorization_note}[/dim]",
2744
+ title="[bold red]MCP Attack Report[/bold red]",
2745
+ border_style=panel_color,
2746
+ ))
2747
+
2748
+ if triggered:
2749
+ console.print("\n[bold red]Triggered Attacks:[/bold red]")
2750
+ for r in triggered:
2751
+ sev_color = {
2752
+ MCPVulnSeverity.CRITICAL: "bold red",
2753
+ MCPVulnSeverity.HIGH: "red",
2754
+ MCPVulnSeverity.MEDIUM: "yellow",
2755
+ }.get(r.severity, "white")
2756
+ component = r.tool_name or r.resource_uri or "endpoint"
2757
+ console.print(
2758
+ f" [{sev_color}]{r.severity.value.upper()}[/{sev_color}] "
2759
+ f"[bold]{r.attack_id}[/bold] on {component}: {r.title}"
2760
+ )
2761
+ if r.evidence:
2762
+ console.print(f" [dim]Evidence: {r.evidence[:120]}[/dim]")
2763
+ else:
2764
+ console.print("\n[green]No attacks triggered. Target appears resilient to tested probes.[/green]")