offsec-ai 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- offsec_ai/__init__.py +91 -0
- offsec_ai/__main__.py +12 -0
- offsec_ai/cli.py +2764 -0
- offsec_ai/core/__init__.py +1 -0
- offsec_ai/core/ai_owasp_scanner.py +389 -0
- offsec_ai/core/cert_analyzer.py +721 -0
- offsec_ai/core/hybrid_identity_checker.py +585 -0
- offsec_ai/core/l7_detector.py +1628 -0
- offsec_ai/core/llm_judge.py +183 -0
- offsec_ai/core/mcp_attacker.py +384 -0
- offsec_ai/core/mcp_scanner.py +506 -0
- offsec_ai/core/mtls_checker.py +990 -0
- offsec_ai/core/owasp_scanner.py +653 -0
- offsec_ai/core/port_scanner.py +277 -0
- offsec_ai/core/security_headers.py +472 -0
- offsec_ai/models/__init__.py +1 -0
- offsec_ai/models/ai_owasp_result.py +161 -0
- offsec_ai/models/l7_result.py +231 -0
- offsec_ai/models/mcp_result.py +148 -0
- offsec_ai/models/mtls_result.py +95 -0
- offsec_ai/models/owasp_result.py +282 -0
- offsec_ai/models/scan_result.py +143 -0
- offsec_ai/py.typed +0 -0
- offsec_ai/utils/__init__.py +1 -0
- offsec_ai/utils/ai_owasp_payloads.py +283 -0
- offsec_ai/utils/ai_owasp_remediation.py +248 -0
- offsec_ai/utils/common_ports.py +316 -0
- offsec_ai/utils/exporters.py +441 -0
- offsec_ai/utils/l7_signatures.py +460 -0
- offsec_ai/utils/mcp_cve_db.py +263 -0
- offsec_ai/utils/mcp_payloads.py +121 -0
- offsec_ai/utils/owasp_remediation.py +787 -0
- offsec_ai-2.0.0.dist-info/METADATA +601 -0
- offsec_ai-2.0.0.dist-info/RECORD +37 -0
- offsec_ai-2.0.0.dist-info/WHEEL +4 -0
- offsec_ai-2.0.0.dist-info/entry_points.txt +2 -0
- offsec_ai-2.0.0.dist-info/licenses/LICENSE +21 -0
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]")
|