tlsaudit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.3
2
+ Name: tlsaudit
3
+ Version: 0.1.0
4
+ Summary: A command-line TLS/cipher auditor. Points at a host, tests which TLS/SSL protocol versions it accepts and how strong its negotiated cipher suite is, then reports findings as a simple PASS/FAIL/WARN table.
5
+ Requires-Python: >=3.14
6
+ Description-Content-Type: text/markdown
7
+
8
+ # tlsaudit
9
+
10
+ A command-line TLS/cipher auditor. Points at a host, tests which TLS/SSL protocol
11
+ versions it accepts and how strong its negotiated cipher suite is, then reports
12
+ findings as a simple PASS/FAIL/WARN table.
13
+
14
+ Standard library only (`ssl`, `socket`, `argparse`), no dependencies to install.
15
+
16
+ ## What it checks
17
+
18
+ **Protocol version support** — tests SSLv3, TLS 1.0, TLS 1.1, TLS 1.2, and TLS 1.3
19
+ individually by pinning the handshake to each version in turn.
20
+
21
+ - A deprecated version (SSLv3, TLS 1.0, TLS 1.1) that the server still **accepts**
22
+ is a **FAIL**.
23
+ - A deprecated version that the server correctly **rejects** is a **PASS**, that's
24
+ the server behaving securely.
25
+ - TLS 1.2 or TLS 1.3 being **supported** is a **PASS**.
26
+ - TLS 1.2 or TLS 1.3 **not** being supported produces no result at all, that's not
27
+ a finding worth reporting in Phase 1, it just means the server doesn't offer that
28
+ particular version.
29
+
30
+ **Cipher suite strength** — connects normally (letting the server negotiate freely)
31
+ and checks the resulting cipher name against known-weak markers: `RC4`, `3DES`,
32
+ `NULL`, `EXPORT`, `CBC`.
33
+
34
+ ## Usage
35
+
36
+ ```bash
37
+ python3 tlsaudit.py <hostname> [--port PORT]
38
+ ```
39
+
40
+ Port defaults to 443.
41
+
42
+ Example:
43
+
44
+ ```
45
+ $ python3 tlsaudit.py github.com
46
+ [*] Auditing github.com on port 443
47
+ [+] TLS Audit Report for github.com:443
48
+ [PASS] Cipher Suite: Strong cipher suite: TLS_AES_256_GCM_SHA384
49
+ [PASS] TLS 1.2: Supported
50
+ [PASS] TLS 1.3: Supported
51
+
52
+ [+] Verdict Summary: PASS: 3 | FAIL: 0 | WARN: 0
53
+ ```
54
+
55
+ If the target isn't running TLS at all (wrong port, plain HTTP, etc.), the tool
56
+ prints a distinct banner instead of the normal table:
57
+
58
+ ```
59
+ $ python3 tlsaudit.py example.com --port 80
60
+ [*] Auditing example.com on port 80
61
+ [+] TLS Audit Report for example.com:80
62
+ ------------------------------------------------------------
63
+ [!] NOT A TLS service: Provided port does not run TLS
64
+ ------------------------------------------------------------
65
+ ```
66
+
67
+ ## Known limitations (by design, not bugs)
68
+
69
+ - **No certificate validation yet.** This is Phase 1, only protocol version and
70
+ cipher strength are checked. Certificate chain validation, expiry, key size,
71
+ and hostname/SAN matching are planned for Phase 2.
72
+ - **SSLv3 is often untestable on modern systems.** Current OpenSSL builds
73
+ frequently disable SSLv3 entirely at compile time, as a hardening measure. On
74
+ those systems, testing SSLv3 fails locally before any packet reaches the
75
+ server, and shows as a WARN ("no protocols available"), not a PASS or FAIL,
76
+ since nothing was actually verified about the server itself.
77
+ - **A timeout doesn't necessarily mean nothing's there.** A well-configured
78
+ firewall silently dropping unsolicited connections looks identical, from this
79
+ tool's perspective, to a genuinely unreachable host. `Connection refused` (an
80
+ explicit rejection) and `Connection timed out` (no response at all) are
81
+ reported separately and mean different things.
82
+ - **Single host only.** No batch scanning from a file yet, and no JSON output.
83
+ Both are planned for Phase 3.
84
+
85
+ ## Roadmap
86
+
87
+ This is Phase 1 of a larger plan:
88
+
89
+ - **Phase 2** — certificate chain validation, expiry, key size, SAN matching
90
+ - **Phase 3** — batch scanning from a host list, JSON output
91
+ - **Phase 4** — HTTP security headers, A-F scoring, CAA/OCSP checks, and more
92
+
93
+ ## Responsible Usage
94
+
95
+ **DO NOT** scan hosts you are not expressly authorized to do so. Doing so may land you in trouble with
96
+ the owners of the host. Therefore, seek permission from the owner before scanning.
97
+
98
+ ## License
99
+
100
+ TBD, add MIT or Apache 2.0 once decided.
@@ -0,0 +1,93 @@
1
+ # tlsaudit
2
+
3
+ A command-line TLS/cipher auditor. Points at a host, tests which TLS/SSL protocol
4
+ versions it accepts and how strong its negotiated cipher suite is, then reports
5
+ findings as a simple PASS/FAIL/WARN table.
6
+
7
+ Standard library only (`ssl`, `socket`, `argparse`), no dependencies to install.
8
+
9
+ ## What it checks
10
+
11
+ **Protocol version support** — tests SSLv3, TLS 1.0, TLS 1.1, TLS 1.2, and TLS 1.3
12
+ individually by pinning the handshake to each version in turn.
13
+
14
+ - A deprecated version (SSLv3, TLS 1.0, TLS 1.1) that the server still **accepts**
15
+ is a **FAIL**.
16
+ - A deprecated version that the server correctly **rejects** is a **PASS**, that's
17
+ the server behaving securely.
18
+ - TLS 1.2 or TLS 1.3 being **supported** is a **PASS**.
19
+ - TLS 1.2 or TLS 1.3 **not** being supported produces no result at all, that's not
20
+ a finding worth reporting in Phase 1, it just means the server doesn't offer that
21
+ particular version.
22
+
23
+ **Cipher suite strength** — connects normally (letting the server negotiate freely)
24
+ and checks the resulting cipher name against known-weak markers: `RC4`, `3DES`,
25
+ `NULL`, `EXPORT`, `CBC`.
26
+
27
+ ## Usage
28
+
29
+ ```bash
30
+ python3 tlsaudit.py <hostname> [--port PORT]
31
+ ```
32
+
33
+ Port defaults to 443.
34
+
35
+ Example:
36
+
37
+ ```
38
+ $ python3 tlsaudit.py github.com
39
+ [*] Auditing github.com on port 443
40
+ [+] TLS Audit Report for github.com:443
41
+ [PASS] Cipher Suite: Strong cipher suite: TLS_AES_256_GCM_SHA384
42
+ [PASS] TLS 1.2: Supported
43
+ [PASS] TLS 1.3: Supported
44
+
45
+ [+] Verdict Summary: PASS: 3 | FAIL: 0 | WARN: 0
46
+ ```
47
+
48
+ If the target isn't running TLS at all (wrong port, plain HTTP, etc.), the tool
49
+ prints a distinct banner instead of the normal table:
50
+
51
+ ```
52
+ $ python3 tlsaudit.py example.com --port 80
53
+ [*] Auditing example.com on port 80
54
+ [+] TLS Audit Report for example.com:80
55
+ ------------------------------------------------------------
56
+ [!] NOT A TLS service: Provided port does not run TLS
57
+ ------------------------------------------------------------
58
+ ```
59
+
60
+ ## Known limitations (by design, not bugs)
61
+
62
+ - **No certificate validation yet.** This is Phase 1, only protocol version and
63
+ cipher strength are checked. Certificate chain validation, expiry, key size,
64
+ and hostname/SAN matching are planned for Phase 2.
65
+ - **SSLv3 is often untestable on modern systems.** Current OpenSSL builds
66
+ frequently disable SSLv3 entirely at compile time, as a hardening measure. On
67
+ those systems, testing SSLv3 fails locally before any packet reaches the
68
+ server, and shows as a WARN ("no protocols available"), not a PASS or FAIL,
69
+ since nothing was actually verified about the server itself.
70
+ - **A timeout doesn't necessarily mean nothing's there.** A well-configured
71
+ firewall silently dropping unsolicited connections looks identical, from this
72
+ tool's perspective, to a genuinely unreachable host. `Connection refused` (an
73
+ explicit rejection) and `Connection timed out` (no response at all) are
74
+ reported separately and mean different things.
75
+ - **Single host only.** No batch scanning from a file yet, and no JSON output.
76
+ Both are planned for Phase 3.
77
+
78
+ ## Roadmap
79
+
80
+ This is Phase 1 of a larger plan:
81
+
82
+ - **Phase 2** — certificate chain validation, expiry, key size, SAN matching
83
+ - **Phase 3** — batch scanning from a host list, JSON output
84
+ - **Phase 4** — HTTP security headers, A-F scoring, CAA/OCSP checks, and more
85
+
86
+ ## Responsible Usage
87
+
88
+ **DO NOT** scan hosts you are not expressly authorized to do so. Doing so may land you in trouble with
89
+ the owners of the host. Therefore, seek permission from the owner before scanning.
90
+
91
+ ## License
92
+
93
+ TBD, add MIT or Apache 2.0 once decided.
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "tlsaudit"
3
+ version = "0.1.0"
4
+ description = "A command-line TLS/cipher auditor. Points at a host, tests which TLS/SSL protocol versions it accepts and how strong its negotiated cipher suite is, then reports findings as a simple PASS/FAIL/WARN table."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = []
8
+
9
+ [project.scripts]
10
+ tlsaudit = "tlsaudit:main"
11
+
12
+ [build-system]
13
+ requires = ["uv_build>=0.12,<0.13"]
14
+ build-backend = "uv_build"
15
+
16
+ [dependency-groups]
17
+ dev = [
18
+ "bandit>=1.9.4",
19
+ "mypy>=2.3.0",
20
+ "pip-audit>=2.10.1",
21
+ "pytest>=9.1.1",
22
+ "pytest-mock>=3.15.1",
23
+ "ruff>=0.16.2",
24
+ ]
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "tlsaudit"
3
+ version = "0.1.0"
4
+ description = "A command-line TLS/cipher auditor. Points at a host, tests which TLS/SSL protocol versions it accepts and how strong its negotiated cipher suite is, then reports findings as a simple PASS/FAIL/WARN table."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = []
8
+
9
+ [project.scripts]
10
+ tlsaudit = "tlsaudit:main"
11
+
12
+ [build-system]
13
+ requires = ["uv_build>=0.12,<0.13"]
14
+ build-backend = "uv_build"
15
+
16
+ [dependency-groups]
17
+ dev = [
18
+ "bandit>=1.9.4",
19
+ "mypy>=2.3.0",
20
+ "pip-audit>=2.10.1",
21
+ "pytest>=9.1.1",
22
+ "pytest-mock>=3.15.1",
23
+ "ruff>=0.16.2",
24
+ ]
@@ -0,0 +1,23 @@
1
+ from .cli import main
2
+ from .report import (
3
+ NOT_TLS_CHECK_NAME,
4
+ CheckResult,
5
+ ScanReport,
6
+ Verdict,
7
+ VersionSpec,
8
+ display_report,
9
+ )
10
+ from .scanner import check_cipher_suite, check_protocol_version, scan_host
11
+
12
+ __all__ = [
13
+ "NOT_TLS_CHECK_NAME",
14
+ "CheckResult",
15
+ "ScanReport",
16
+ "Verdict",
17
+ "VersionSpec",
18
+ "check_cipher_suite",
19
+ "check_protocol_version",
20
+ "display_report",
21
+ "main",
22
+ "scan_host",
23
+ ]
@@ -0,0 +1,20 @@
1
+ import argparse
2
+
3
+ from .report import display_report
4
+ from .scanner import scan_host
5
+
6
+
7
+ def main():
8
+ parser = argparse.ArgumentParser()
9
+ parser.add_argument("hostname", type=str, help="Hostname to audit")
10
+ parser.add_argument("-p", "--port", type=int, default=443, help="Port to audit")
11
+ args = parser.parse_args()
12
+
13
+ print(f"[*] Auditing {args.hostname} on port {args.port}")
14
+ report = scan_host(args.hostname, args.port)
15
+
16
+ display_report(report)
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()
@@ -0,0 +1,53 @@
1
+ import ssl
2
+ from collections import Counter
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+
6
+ NOT_TLS_CHECK_NAME = "Not TLS"
7
+ WEAK_CIPHER_MARKERS = ["RC4", "3DES", "NULL", "EXPORT", "CBC"]
8
+
9
+
10
+ class Verdict(Enum):
11
+ PASS = "PASS" # nosec: B105 - num member, not a credential; Bandit's heuristic matches on the name "PASS"
12
+ FAIL = "FAIL"
13
+ WARN = "WARN"
14
+
15
+
16
+ @dataclass
17
+ class VersionSpec:
18
+ version: ssl.TLSVersion
19
+ label: str
20
+ is_deprecated: bool
21
+
22
+
23
+ @dataclass
24
+ class CheckResult:
25
+ name: str # which check this is, e.g. "TLS 1.0" or "Cipher Suite"?
26
+ detail: str
27
+ verdict: Verdict
28
+
29
+
30
+ @dataclass
31
+ class ScanReport:
32
+ hostname: str
33
+ port: int
34
+ results: list[CheckResult] = field(default_factory=list)
35
+
36
+
37
+ def display_report(report: ScanReport):
38
+ print(f"[+] TLS Audit Report for {report.hostname}:{report.port}")
39
+ if len(report.results) == 1 and report.results[0].name == NOT_TLS_CHECK_NAME:
40
+ print("-" * 40)
41
+ print(f"[!] NOT A TLS service: {report.results[0].detail}")
42
+ print("-" * 40)
43
+ return
44
+
45
+ for result in report.results:
46
+ print(f"[{result.verdict.value}] {result.name}: {result.detail}")
47
+
48
+ verdict_count = Counter({v: 0 for v in Verdict})
49
+ verdict_count.update(result.verdict for result in report.results)
50
+
51
+ summary_part = [f"{v.value}: {verdict_count[v]}" for v in Verdict]
52
+ one_liner = " | ".join(summary_part)
53
+ print(f"\n[+] Verdict Summary: {one_liner}")
@@ -0,0 +1,160 @@
1
+ import socket
2
+ import ssl
3
+
4
+ from .report import (
5
+ NOT_TLS_CHECK_NAME,
6
+ WEAK_CIPHER_MARKERS,
7
+ CheckResult,
8
+ ScanReport,
9
+ Verdict,
10
+ VersionSpec,
11
+ )
12
+
13
+
14
+ def check_protocol_version(
15
+ hostname: str, port: int, version: ssl.TLSVersion, label: str, is_deprecated: bool
16
+ ) -> CheckResult | None:
17
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
18
+ context.check_hostname = False
19
+ context.verify_mode = ssl.CERT_NONE
20
+ context.minimum_version = context.maximum_version = version
21
+
22
+ try:
23
+ with (
24
+ socket.create_connection((hostname, port), timeout=20) as sock,
25
+ context.wrap_socket(sock, server_hostname=hostname),
26
+ ):
27
+ if is_deprecated:
28
+ return CheckResult(label, "Deprecated", Verdict.FAIL)
29
+ return CheckResult(label, "Supported", Verdict.PASS)
30
+
31
+ except ssl.SSLError as e:
32
+ if e.reason == "NO_SHARED_CIPHER":
33
+ return CheckResult(label, "No shared cipher", Verdict.WARN)
34
+ elif e.reason == "NO_PROTOCOLS_AVAILABLE":
35
+ return CheckResult(label, "No protocols available", Verdict.WARN)
36
+ if is_deprecated:
37
+ return CheckResult(label, "Not supported & Deprecated", Verdict.PASS)
38
+ return None
39
+ except TimeoutError:
40
+ return CheckResult(label, "Connection timed out", Verdict.WARN)
41
+
42
+
43
+ def check_cipher_suite(hostname: str, port: int = 443) -> CheckResult:
44
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
45
+ context.check_hostname = False
46
+ context.verify_mode = ssl.CERT_NONE
47
+ try:
48
+ with (
49
+ socket.create_connection((hostname, port), timeout=20) as sock,
50
+ context.wrap_socket(sock, server_hostname=hostname) as ssock,
51
+ ):
52
+ cipher_info = ssock.cipher()
53
+ if cipher_info is None:
54
+ return CheckResult("Cipher Suite", "No cipher negotiated", Verdict.WARN)
55
+ cipher_name = cipher_info[0]
56
+
57
+ if any(marker in cipher_name for marker in WEAK_CIPHER_MARKERS):
58
+ return CheckResult(
59
+ "Cipher Suite", f"Weak cipher negotiated: {cipher_name}", Verdict.FAIL
60
+ )
61
+ else:
62
+ return CheckResult(
63
+ "Cipher Suite", f"Strong cipher suite: {cipher_name}", Verdict.PASS
64
+ )
65
+ except TimeoutError:
66
+ return CheckResult("Cipher Suite", "Connection timed out", Verdict.WARN)
67
+
68
+
69
+ def scan_host(hostname: str, port: int) -> ScanReport:
70
+ report = ScanReport(hostname, port)
71
+ target_versions = [
72
+ VersionSpec(ssl.TLSVersion.SSLv3, "SSL 3.0", True),
73
+ VersionSpec(ssl.TLSVersion.TLSv1, "TLS 1.0", True),
74
+ VersionSpec(ssl.TLSVersion.TLSv1_1, "TLS 1.1", True),
75
+ VersionSpec(ssl.TLSVersion.TLSv1_2, "TLS 1.2", False),
76
+ VersionSpec(ssl.TLSVersion.TLSv1_3, "TLS 1.3", False),
77
+ ]
78
+ try:
79
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
80
+ context.check_hostname = False
81
+ context.verify_mode = ssl.CERT_NONE
82
+ with (
83
+ socket.create_connection((hostname, port), timeout=10) as sock,
84
+ context.wrap_socket(sock, server_hostname=hostname),
85
+ ):
86
+ pass
87
+ check_cipher_suite_result = check_cipher_suite(hostname, port)
88
+ report.results.append(check_cipher_suite_result)
89
+ for version in target_versions:
90
+ try:
91
+ result = check_protocol_version(
92
+ hostname,
93
+ port,
94
+ version.version,
95
+ version.label,
96
+ version.is_deprecated,
97
+ )
98
+ if result:
99
+ report.results.append(result)
100
+ except Exception as e: # noqa: BLE001 — deliberate safety net; check_protocol_version already handles its known exception types internally
101
+ report.results.append(
102
+ CheckResult(
103
+ version.label, f"Could not complete check: {e!s}", Verdict.WARN
104
+ )
105
+ )
106
+ return report
107
+ except socket.gaierror:
108
+ return ScanReport(
109
+ hostname,
110
+ port,
111
+ [CheckResult("Hostname", "Could not resolve hostname", Verdict.WARN)],
112
+ )
113
+ except TimeoutError:
114
+ return ScanReport(
115
+ hostname,
116
+ port,
117
+ [CheckResult("Connection", "Connection timed out", Verdict.WARN)],
118
+ )
119
+ except ConnectionRefusedError:
120
+ return ScanReport(
121
+ hostname,
122
+ port,
123
+ [CheckResult("Connection", "Connection refused", Verdict.WARN)],
124
+ )
125
+ except ConnectionResetError:
126
+ return ScanReport(
127
+ hostname,
128
+ port,
129
+ [CheckResult("Connection", "Connection reset", Verdict.WARN)],
130
+ )
131
+ except ssl.SSLError as e:
132
+ if e.reason == "RECORD_LAYER_FAILURE":
133
+ return ScanReport(
134
+ hostname,
135
+ port,
136
+ [
137
+ CheckResult(
138
+ NOT_TLS_CHECK_NAME,
139
+ "Provided port does not run TLS",
140
+ Verdict.WARN,
141
+ )
142
+ ],
143
+ )
144
+ return ScanReport(
145
+ hostname,
146
+ port,
147
+ [
148
+ CheckResult(
149
+ NOT_TLS_CHECK_NAME,
150
+ f"Provided port does not appear to run TLS ({e.reason})",
151
+ Verdict.WARN,
152
+ )
153
+ ],
154
+ )
155
+ except OSError as e:
156
+ return ScanReport(
157
+ hostname,
158
+ port,
159
+ [CheckResult("Network Error", f"OS Error: {e}", Verdict.WARN)],
160
+ )