96 lines
3.5 KiB
Python
96 lines
3.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Collect focused TLS evidence that ZAP baseline does not enumerate."""
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
def openssl_handshake(host: str, port: int, option: str, cipher: str | None = None) -> tuple[bool, str]:
|
|
command = ["openssl", "s_client", "-connect", f"{host}:{port}", "-servername", host, option, "-brief"]
|
|
if cipher:
|
|
command.extend(["-cipher", cipher])
|
|
result = subprocess.run(command, input="", text=True, capture_output=True, timeout=20, check=False)
|
|
evidence = (result.stdout + result.stderr).strip()
|
|
return result.returncode == 0 and "Protocol version" in evidence, evidence[-2000:]
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("url")
|
|
parser.add_argument("output", type=Path)
|
|
args = parser.parse_args()
|
|
parsed = urlparse(args.url)
|
|
host = parsed.hostname
|
|
port = parsed.port or 443
|
|
if not host:
|
|
parser.error("URL must include a hostname")
|
|
|
|
findings = []
|
|
for option, label in (("-tls1", "TLS 1.0"), ("-tls1_1", "TLS 1.1")):
|
|
accepted, evidence = openssl_handshake(host, port, option, "AES128-SHA:@SECLEVEL=0")
|
|
if accepted:
|
|
findings.append(
|
|
{
|
|
"id": "TLS-OLD-PROTOCOL",
|
|
"title": f"Server accepts {label}",
|
|
"severity": "high",
|
|
"description": "The endpoint accepts an obsolete TLS protocol.",
|
|
"remediation": "Allow only TLS 1.2 and TLS 1.3.",
|
|
"evidence": evidence,
|
|
}
|
|
)
|
|
|
|
weak_cipher, cipher_evidence = openssl_handshake(host, port, "-tls1_2", "AES128-SHA:@SECLEVEL=0")
|
|
if weak_cipher:
|
|
findings.append(
|
|
{
|
|
"id": "TLS-WEAK-CIPHER",
|
|
"title": "Server accepts TLS_RSA_WITH_AES_128_CBC_SHA",
|
|
"severity": "medium",
|
|
"description": "The endpoint accepts a legacy RSA/CBC cipher suite.",
|
|
"remediation": "Use forward-secret AEAD cipher suites.",
|
|
"evidence": cipher_evidence,
|
|
}
|
|
)
|
|
|
|
verification = subprocess.run(
|
|
[
|
|
"openssl",
|
|
"s_client",
|
|
"-connect",
|
|
f"{host}:{port}",
|
|
"-servername",
|
|
host,
|
|
"-verify_return_error",
|
|
"-brief",
|
|
],
|
|
input="",
|
|
text=True,
|
|
capture_output=True,
|
|
timeout=20,
|
|
check=False,
|
|
)
|
|
verification_evidence = (verification.stdout + verification.stderr).strip()
|
|
if verification.returncode != 0 and "certificate verify failed" in verification_evidence.lower():
|
|
findings.append(
|
|
{
|
|
"id": "TLS-SELF-SIGNED",
|
|
"title": "TLS certificate is not publicly trusted",
|
|
"severity": "medium",
|
|
"description": "Default certificate verification rejected the endpoint certificate.",
|
|
"remediation": "Install a certificate issued by a trusted CA for the environment.",
|
|
"evidence": verification_evidence[-2000:],
|
|
}
|
|
)
|
|
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps({"target": args.url, "findings": findings}, indent=2) + "\n", encoding="utf-8")
|
|
print(f"Collected {len(findings)} TLS findings")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main()) |