certinspect 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michele Angrisano
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: certinspect
3
+ Version: 0.1.0
4
+ Summary: Ispettore di certificati TLS da riga di comando
5
+ Author: Michele Angrisano
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mangrisano/certinspect
8
+ Project-URL: Repository, https://github.com/mangrisano/certinspect
9
+ Project-URL: Issues, https://github.com/mangrisano/certinspect/issues
10
+ Keywords: tls,ssl,certificate,x509,cli
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Environment :: Console
13
+ Classifier: Topic :: Security :: Cryptography
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: cryptography>=42.0.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
20
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # certinspect
24
+
25
+ Ispettore di certificati TLS da riga di comando.
26
+
27
+ Dato uno o più domini (o un file `.pem`/`.der`), mostra validità, giorni alla
28
+ scadenza, soggetto, issuer, SAN, algoritmo di firma, dimensione della chiave,
29
+ fingerprint SHA-256, flag CA, self-signed, eventuali debolezze crittografiche e
30
+ la corrispondenza dell'hostname.
31
+
32
+ ## Requisiti
33
+
34
+ - Python >= 3.14
35
+
36
+ ## Installazione (sviluppo)
37
+
38
+ ```bash
39
+ python3.14 -m venv .venv
40
+ source .venv/bin/activate
41
+ pip install -r requirements.txt
42
+ pip install -e ".[dev]"
43
+ ```
44
+
45
+ ## Uso
46
+
47
+ ```bash
48
+ # Ispeziona un host
49
+ certinspect example.com
50
+
51
+ # Più host in una volta (modalità batch)
52
+ certinspect example.com github.com api.example.com
53
+
54
+ # Porta personalizzata
55
+ certinspect example.com --port 8443
56
+
57
+ # Output JSON (sempre una lista di oggetti)
58
+ certinspect example.com --json
59
+
60
+ # Ispeziona un certificato locale
61
+ certinspect --file ./certificato.pem
62
+
63
+ # Soglia di avviso scadenza personalizzata (default: 30 giorni)
64
+ certinspect example.com --days 14
65
+
66
+ # Salva il certificato scaricato in formato PEM
67
+ certinspect example.com --export ./scaricato.pem
68
+ ```
69
+
70
+ ## Opzioni
71
+
72
+ | Opzione | Descrizione |
73
+ | --------------- | -------------------------------------------------------------- |
74
+ | `target...` | Uno o più domini da ispezionare. Ometti quando usi `--file`. |
75
+ | `--file PATH` | Ispeziona un certificato locale (PEM o DER) invece di un host. |
76
+ | `--port N` | Porta TCP a cui connettersi (default: 443). |
77
+ | `--json` | Stampa il risultato come JSON invece del testo leggibile. |
78
+ | `--days N` | Avvisa se il certificato scade entro N giorni (default: 30). |
79
+ | `--export PATH` | Salva il certificato ispezionato come file PEM in PATH. |
80
+
81
+ ## Exit code
82
+
83
+ Pensati per l'automazione (cron, CI, script di monitoraggio). In modalità batch
84
+ viene restituito il caso peggiore tra tutti i target.
85
+
86
+ | Code | Significato |
87
+ | ---- | -------------------------------------------- |
88
+ | 0 | Certificato valido |
89
+ | 1 | Errore di runtime (rete, file, parsing) |
90
+ | 2 | Errore negli argomenti della riga di comando |
91
+ | 3 | In scadenza entro la soglia `--days` |
92
+ | 4 | Scaduto o con date non valide |
93
+ | 5 | L'hostname non corrisponde al certificato |
94
+
95
+ Esempio in uno script:
96
+
97
+ ```bash
98
+ certinspect tuosito.it --days 21
99
+ case $? in
100
+ 0) ;; # tutto ok
101
+ 3) echo "In scadenza" | mail -s "Avviso" tu@mail.it ;;
102
+ 4) echo "Scaduto" | mail -s "Urgente" tu@mail.it ;;
103
+ 5) echo "Host errato" | mail -s "Urgente" tu@mail.it ;;
104
+ *) echo "Check fallito" ;;
105
+ esac
106
+ ```
107
+
108
+ ## Sviluppo
109
+
110
+ ```bash
111
+ # Test
112
+ pytest
113
+
114
+ # Lint e formattazione (Ruff)
115
+ ruff check src tests
116
+ ruff format src tests
117
+ ```
@@ -0,0 +1,95 @@
1
+ # certinspect
2
+
3
+ Ispettore di certificati TLS da riga di comando.
4
+
5
+ Dato uno o più domini (o un file `.pem`/`.der`), mostra validità, giorni alla
6
+ scadenza, soggetto, issuer, SAN, algoritmo di firma, dimensione della chiave,
7
+ fingerprint SHA-256, flag CA, self-signed, eventuali debolezze crittografiche e
8
+ la corrispondenza dell'hostname.
9
+
10
+ ## Requisiti
11
+
12
+ - Python >= 3.14
13
+
14
+ ## Installazione (sviluppo)
15
+
16
+ ```bash
17
+ python3.14 -m venv .venv
18
+ source .venv/bin/activate
19
+ pip install -r requirements.txt
20
+ pip install -e ".[dev]"
21
+ ```
22
+
23
+ ## Uso
24
+
25
+ ```bash
26
+ # Ispeziona un host
27
+ certinspect example.com
28
+
29
+ # Più host in una volta (modalità batch)
30
+ certinspect example.com github.com api.example.com
31
+
32
+ # Porta personalizzata
33
+ certinspect example.com --port 8443
34
+
35
+ # Output JSON (sempre una lista di oggetti)
36
+ certinspect example.com --json
37
+
38
+ # Ispeziona un certificato locale
39
+ certinspect --file ./certificato.pem
40
+
41
+ # Soglia di avviso scadenza personalizzata (default: 30 giorni)
42
+ certinspect example.com --days 14
43
+
44
+ # Salva il certificato scaricato in formato PEM
45
+ certinspect example.com --export ./scaricato.pem
46
+ ```
47
+
48
+ ## Opzioni
49
+
50
+ | Opzione | Descrizione |
51
+ | --------------- | -------------------------------------------------------------- |
52
+ | `target...` | Uno o più domini da ispezionare. Ometti quando usi `--file`. |
53
+ | `--file PATH` | Ispeziona un certificato locale (PEM o DER) invece di un host. |
54
+ | `--port N` | Porta TCP a cui connettersi (default: 443). |
55
+ | `--json` | Stampa il risultato come JSON invece del testo leggibile. |
56
+ | `--days N` | Avvisa se il certificato scade entro N giorni (default: 30). |
57
+ | `--export PATH` | Salva il certificato ispezionato come file PEM in PATH. |
58
+
59
+ ## Exit code
60
+
61
+ Pensati per l'automazione (cron, CI, script di monitoraggio). In modalità batch
62
+ viene restituito il caso peggiore tra tutti i target.
63
+
64
+ | Code | Significato |
65
+ | ---- | -------------------------------------------- |
66
+ | 0 | Certificato valido |
67
+ | 1 | Errore di runtime (rete, file, parsing) |
68
+ | 2 | Errore negli argomenti della riga di comando |
69
+ | 3 | In scadenza entro la soglia `--days` |
70
+ | 4 | Scaduto o con date non valide |
71
+ | 5 | L'hostname non corrisponde al certificato |
72
+
73
+ Esempio in uno script:
74
+
75
+ ```bash
76
+ certinspect tuosito.it --days 21
77
+ case $? in
78
+ 0) ;; # tutto ok
79
+ 3) echo "In scadenza" | mail -s "Avviso" tu@mail.it ;;
80
+ 4) echo "Scaduto" | mail -s "Urgente" tu@mail.it ;;
81
+ 5) echo "Host errato" | mail -s "Urgente" tu@mail.it ;;
82
+ *) echo "Check fallito" ;;
83
+ esac
84
+ ```
85
+
86
+ ## Sviluppo
87
+
88
+ ```bash
89
+ # Test
90
+ pytest
91
+
92
+ # Lint e formattazione (Ruff)
93
+ ruff check src tests
94
+ ruff format src tests
95
+ ```
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "certinspect"
3
+ version = "0.1.0"
4
+ description = "Ispettore di certificati TLS da riga di comando"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Michele Angrisano" }]
10
+ keywords = ["tls", "ssl", "certificate", "x509", "cli"]
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "Environment :: Console",
14
+ "Topic :: Security :: Cryptography",
15
+ ]
16
+ dependencies = [
17
+ "cryptography>=42.0.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "pytest>=8.0.0",
23
+ "ruff>=0.4.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/mangrisano/certinspect"
28
+ Repository = "https://github.com/mangrisano/certinspect"
29
+ Issues = "https://github.com/mangrisano/certinspect/issues"
30
+
31
+ [project.scripts]
32
+ certinspect = "certinspect.cli:main"
33
+
34
+ [build-system]
35
+ requires = ["setuptools>=77.0"]
36
+ build-backend = "setuptools.build_meta"
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """certinspect — Ispettore di certificati TLS da riga di comando."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,169 @@
1
+ """
2
+ cli.py — Command-line entry point.
3
+
4
+ Wire together fetch -> parser -> formatter, reading the CLI arguments.
5
+
6
+ It fetches a certificate from one or more hosts (or reads a local file),
7
+ analyzes it, prints the result as human-readable text or JSON, and exits
8
+ with a status code reflecting the worst certificate state found.
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ import ssl
15
+ from certinspect.fetch import get_server_cert_der
16
+ from certinspect.parser import (
17
+ load_certificate,
18
+ analyze,
19
+ certificate_status,
20
+ hostname_matches,
21
+ to_pem,
22
+ )
23
+ from certinspect.formatter import format_human
24
+
25
+ # Exit codes reflecting the certificate status, kept distinct from
26
+ # argparse's usage error (2) and the generic runtime error (1).
27
+ EXIT_BY_STATUS = {
28
+ "VALID": 0,
29
+ "EXPIRING": 3,
30
+ "EXPIRED": 4,
31
+ "INVALID DATES": 4,
32
+ }
33
+
34
+
35
+ def build_parser() -> argparse.ArgumentParser:
36
+ """Build and return the ArgumentParser."""
37
+
38
+ parser = argparse.ArgumentParser(
39
+ prog="certinspect",
40
+ description="Inspect a TLS certificate from a host or a local file.",
41
+ )
42
+ parser.add_argument(
43
+ "target",
44
+ nargs="*",
45
+ help=(
46
+ "One or more domain names to inspect (e.g. example.com). "
47
+ "Omit when using --file."
48
+ ),
49
+ )
50
+ parser.add_argument(
51
+ "--file",
52
+ help=(
53
+ "Path to a local certificate file (PEM or DER) to inspect "
54
+ "instead of a host."
55
+ ),
56
+ )
57
+ parser.add_argument(
58
+ "--port",
59
+ type=int,
60
+ default=443,
61
+ help="TCP port to connect to (default: 443).",
62
+ )
63
+ parser.add_argument(
64
+ "--json",
65
+ action="store_true",
66
+ help="Output the result as JSON instead of human-readable text.",
67
+ )
68
+ parser.add_argument(
69
+ "--days",
70
+ type=int,
71
+ default=30,
72
+ help=("Warn if the certificate expires within this many days (default: 30)."),
73
+ )
74
+ parser.add_argument(
75
+ "--export",
76
+ metavar="PATH",
77
+ help="Save the inspected certificate as a PEM file at PATH.",
78
+ )
79
+
80
+ return parser
81
+
82
+
83
+ def _fetch_bytes(target: str | None, port: int, file: str | None) -> bytes:
84
+ """Return the raw certificate bytes for a single source (file or host)."""
85
+ if file:
86
+ with open(file, "rb") as f:
87
+ return f.read()
88
+ return get_server_cert_der(target, port)
89
+
90
+
91
+ def _inspect(
92
+ target: str | None,
93
+ *,
94
+ port: int,
95
+ file: str | None,
96
+ days: int,
97
+ export: str | None,
98
+ ) -> tuple[dict, int]:
99
+ """Inspect one source and return its (info, exit_code).
100
+
101
+ The hostname match (and its exit code 5) only applies to host targets;
102
+ with --file it is left as None.
103
+ """
104
+ cert = load_certificate(_fetch_bytes(target, port, file))
105
+ info = analyze(cert)
106
+ if export:
107
+ with open(export, "wb") as f:
108
+ f.write(to_pem(cert))
109
+ info["hostname_match"] = hostname_matches(info, target) if target else None
110
+
111
+ code = EXIT_BY_STATUS[certificate_status(info, days)]
112
+ if info["hostname_match"] is False:
113
+ code = 5
114
+ return info, code
115
+
116
+
117
+ def _render(
118
+ results: list[tuple[str | None, dict]], *, as_json: bool, days: int
119
+ ) -> None:
120
+ """Print the collected results as JSON (always a list) or human text."""
121
+ if as_json:
122
+ print(json.dumps([info for _, info in results], indent=2, default=str))
123
+ return
124
+
125
+ blocks = []
126
+ for target, info in results:
127
+ text = format_human(info, warn_days=days)
128
+ blocks.append(f"=== {target} ===\n{text}" if target else text)
129
+ if blocks:
130
+ print("\n\n".join(blocks))
131
+
132
+
133
+ def main() -> None:
134
+ """CLI entry point."""
135
+
136
+ parser = build_parser()
137
+ args = parser.parse_args()
138
+
139
+ if not args.target and not args.file:
140
+ parser.error("There is no target or no file to inspect.")
141
+
142
+ # A single --file source has no host; otherwise iterate over the targets.
143
+ targets: list[str | None] = [None] if args.file else list(args.target)
144
+
145
+ results: list[tuple[str | None, dict]] = []
146
+ codes: list[int] = []
147
+ for target in targets:
148
+ try:
149
+ info, code = _inspect(
150
+ target,
151
+ port=args.port,
152
+ file=args.file,
153
+ days=args.days,
154
+ export=args.export,
155
+ )
156
+ except (OSError, ssl.SSLError, ValueError) as err:
157
+ label = f"{target}: " if target else ""
158
+ print(f"error: {label}{err}", file=sys.stderr)
159
+ codes.append(1)
160
+ continue
161
+ results.append((target, info))
162
+ codes.append(code)
163
+
164
+ _render(results, as_json=args.json, days=args.days)
165
+ sys.exit(max(codes, default=0))
166
+
167
+
168
+ if __name__ == "__main__":
169
+ main()
@@ -0,0 +1,24 @@
1
+ """
2
+ fetch.py — Retrieving the certificate from the TLS server.
3
+
4
+ Connect to a host:port over TLS and obtain the server certificate in DER
5
+ format (bytes), ready to be parsed in parser.py.
6
+
7
+ Hostname checking and verification are disabled on purpose: this tool must
8
+ be able to inspect expired or self-signed certificates without the
9
+ connection failing. Validity is computed later in parser.py.
10
+ """
11
+
12
+ import socket
13
+ import ssl
14
+
15
+
16
+ def get_server_cert_der(host: str, port: int = 443, timeout: float = 5.0) -> bytes:
17
+ """Return the server certificate in DER format (bytes)."""
18
+
19
+ context = ssl.create_default_context()
20
+ context.check_hostname = False
21
+ context.verify_mode = ssl.CERT_NONE
22
+ with socket.create_connection((host, port), timeout=timeout) as sock:
23
+ with context.wrap_socket(sock, server_hostname=host) as ssock:
24
+ return ssock.getpeercert(binary_form=True)
@@ -0,0 +1,81 @@
1
+ """
2
+ formatter.py — Presentation of the results to the user.
3
+
4
+ MODULE GOAL
5
+ -----------
6
+ Take the dict produced by parser.analyze() and produce human-readable output
7
+ (text) or machine-readable output (JSON).
8
+
9
+ GUIDED STEPS (write them yourself):
10
+ 1. format_human(info: dict) -> str
11
+ - Print the fields in a tidy, readable way.
12
+ - Highlight the status: VALID / EXPIRED / NOT YET VALID.
13
+ - Show a WARNING if the days to expiry are < 30.
14
+ 2. format_json(info: dict) -> str
15
+ - Serialize with json.dumps(info, default=str, indent=2).
16
+ - Note: dates are not serializable by default → use default=str
17
+ or convert them to ISO 8601 in parser.analyze().
18
+
19
+ Hint: keep color/emoji logic optional and simple.
20
+ Do not put analysis logic here: this module only PRESENTS the data.
21
+ """
22
+
23
+ import json
24
+
25
+ from certinspect.parser import certificate_status
26
+
27
+ LABEL_WIDTH = 16
28
+
29
+
30
+ def format_human(info: dict, warn_days: int = 30) -> str:
31
+ """Return a human-readable text representation."""
32
+ days = info["days_to_expire"]
33
+ status = certificate_status(info, warn_days)
34
+
35
+ def row(label: str, value: object) -> str:
36
+ return f"{label + ':':<{LABEL_WIDTH}}{value}"
37
+
38
+ lines = [
39
+ row("Subject", info["subject"]),
40
+ row("Status", status),
41
+ "",
42
+ row("Issuer", info["issuer"]),
43
+ row("Valid from", info["not_valid_before"]),
44
+ row("Valid until", info["not_valid_after"]),
45
+ row("Days to expiry", days),
46
+ "",
47
+ row("Serial number", info["serial_number"]),
48
+ row("Signature", info["signature_algorithm"]),
49
+ row("Key size", f"{info['key_size']} bit"),
50
+ row("Fingerprint", info["fingerprint_sha256"]),
51
+ row("CA", info["is_ca"]),
52
+ row("Self-Signed", info["self_signed"]),
53
+ ]
54
+
55
+ if info["weak"]:
56
+ lines.append("")
57
+ for reason in info["weak"]:
58
+ lines.append(f"WARNING: {reason}")
59
+
60
+ if info.get("hostname_match") is not None:
61
+ lines.append(row("Hostname match", info["hostname_match"]))
62
+
63
+ if 0 <= days < warn_days:
64
+ lines.append("")
65
+ lines.append(f"WARNING: certificate expires in {days} days")
66
+
67
+ san = info["san"]
68
+ lines.append("")
69
+ if san:
70
+ lines.append("SAN:")
71
+ lines.extend(f" - {name}" for name in san)
72
+ else:
73
+ lines.append(row("SAN", "(none)"))
74
+
75
+ return "\n".join(lines)
76
+
77
+
78
+ def format_json(info: dict) -> str:
79
+ """Return a JSON representation."""
80
+
81
+ return json.dumps(info, indent=2, default=str)
@@ -0,0 +1,123 @@
1
+ """X.509 certificate parsing and analysis.
2
+
3
+ Turns certificate bytes (DER or PEM) into a dictionary of fields ready to be
4
+ formatted for the user.
5
+ """
6
+
7
+ from datetime import datetime, timezone
8
+ from cryptography import x509
9
+ from cryptography.hazmat.primitives import hashes, serialization
10
+ from cryptography.hazmat.primitives.asymmetric import dsa, ec, rsa
11
+
12
+
13
+ class CertificateLoadError(ValueError): ...
14
+
15
+
16
+ def to_pem(cert: x509.Certificate) -> bytes:
17
+ return cert.public_bytes(serialization.Encoding.PEM)
18
+
19
+
20
+ def _name_matches(pattern: str, hostname: str) -> bool:
21
+ pattern = pattern.lower()
22
+ hostname = hostname.lower()
23
+ if pattern.startswith("*."):
24
+ head, _, tail = hostname.partition(".")
25
+ return bool(head) and tail == pattern[2:]
26
+
27
+ return pattern == hostname
28
+
29
+
30
+ def hostname_matches(info: dict, hostname: str) -> bool:
31
+ """Return True if hostname is covered by the certificate's SAN names."""
32
+
33
+ return any(_name_matches(name, hostname) for name in info["san"])
34
+
35
+
36
+ def format_fingerprint(cert: x509.Certificate) -> str:
37
+ return ":".join(f"{b:02X}" for b in cert.fingerprint(hashes.SHA256()))
38
+
39
+
40
+ def _weak_key(public_key) -> str | None:
41
+ """Return a warning if the key is below safe size for its type.
42
+
43
+ RSA/DSA need >= 2048 bit; EC needs >= 256 bit. Other key types
44
+ (e.g. Ed25519) are always considered strong.
45
+ """
46
+ if isinstance(public_key, ec.EllipticCurvePublicKey):
47
+ if public_key.key_size < 256:
48
+ return f"Weak EC key ({public_key.key_size} bit)"
49
+ elif isinstance(public_key, (rsa.RSAPublicKey, dsa.DSAPublicKey)):
50
+ if public_key.key_size < 2048:
51
+ return f"Weak key ({public_key.key_size} bit)"
52
+ return None
53
+
54
+
55
+ def load_certificate(data: bytes) -> x509.Certificate:
56
+ """Load a certificate from DER or PEM bytes."""
57
+
58
+ if not data:
59
+ raise CertificateLoadError("There is no certificate to load.")
60
+ try:
61
+ return x509.load_der_x509_certificate(data)
62
+ except ValueError:
63
+ return x509.load_pem_x509_certificate(data)
64
+
65
+
66
+ def analyze(cert: x509.Certificate) -> dict:
67
+ """Extract the relevant information from the certificate as a dict."""
68
+
69
+ now = datetime.now(timezone.utc)
70
+ days_to_expire = (cert.not_valid_after_utc - now).days
71
+ try:
72
+ ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
73
+ san = ext.value.get_values_for_type(x509.DNSName)
74
+ except x509.ExtensionNotFound:
75
+ san = []
76
+
77
+ try:
78
+ ext = cert.extensions.get_extension_for_class(x509.BasicConstraints)
79
+ is_ca = ext.value.ca
80
+ except x509.ExtensionNotFound:
81
+ is_ca = False
82
+
83
+ weak = []
84
+ reason = _weak_key(cert.public_key())
85
+ if reason:
86
+ weak.append(reason)
87
+ sig = cert.signature_algorithm_oid._name.lower()
88
+ if "sha1" in sig or "md5" in sig:
89
+ weak.append(f"Weak signature ({cert.signature_algorithm_oid._name})")
90
+
91
+ return {
92
+ "subject": cert.subject.rfc4514_string(),
93
+ "issuer": cert.issuer.rfc4514_string(),
94
+ "not_valid_before": cert.not_valid_before_utc,
95
+ "not_valid_after": cert.not_valid_after_utc,
96
+ "serial_number": cert.serial_number,
97
+ "signature_algorithm": cert.signature_algorithm_oid._name,
98
+ "days_to_expire": days_to_expire,
99
+ "key_size": cert.public_key().key_size,
100
+ "san": san,
101
+ "fingerprint_sha256": format_fingerprint(cert),
102
+ "is_ca": is_ca,
103
+ "self_signed": cert.subject == cert.issuer,
104
+ "weak": weak,
105
+ }
106
+
107
+
108
+ def certificate_status(info: dict, warn_days: int = 30) -> str:
109
+ """Return the validity status derived from the analyzed data.
110
+
111
+ One of: 'INVALID DATES', 'EXPIRED', 'EXPIRING', 'VALID'.
112
+ 'EXPIRING' means the certificate is still valid but expires within
113
+ ``warn_days`` days.
114
+ """
115
+
116
+ if info["not_valid_before"] > info["not_valid_after"]:
117
+ return "INVALID DATES"
118
+ days = info["days_to_expire"]
119
+ if days < 0:
120
+ return "EXPIRED"
121
+ if days < warn_days:
122
+ return "EXPIRING"
123
+ return "VALID"