certinspect 2.3.1__tar.gz → 2.3.2__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.
- {certinspect-2.3.1/src/certinspect.egg-info → certinspect-2.3.2}/PKG-INFO +1 -1
- {certinspect-2.3.1 → certinspect-2.3.2}/pyproject.toml +1 -1
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/__init__.py +1 -1
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/cli.py +1 -1
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/discover.py +1 -1
- certinspect-2.3.2/src/certinspect/exit_codes.py +53 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/fetch.py +2 -318
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/formatter.py +7 -6
- certinspect-2.3.2/src/certinspect/httpfetch.py +84 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/parser.py +8 -7
- certinspect-2.3.2/src/certinspect/revocation.py +257 -0
- {certinspect-2.3.1 → certinspect-2.3.2/src/certinspect.egg-info}/PKG-INFO +1 -1
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect.egg-info/SOURCES.txt +2 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/tests/test_fetch.py +47 -47
- certinspect-2.3.1/src/certinspect/exit_codes.py +0 -37
- {certinspect-2.3.1 → certinspect-2.3.2}/LICENSE +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/README.md +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/setup.cfg +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/args.py +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/completion.py +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/config.py +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/models.py +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/render.py +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect.egg-info/dependency_links.txt +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect.egg-info/entry_points.txt +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect.egg-info/requires.txt +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect.egg-info/top_level.txt +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/tests/test_cli.py +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/tests/test_discover.py +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/tests/test_formatter.py +0 -0
- {certinspect-2.3.1 → certinspect-2.3.2}/tests/test_parser.py +0 -0
|
@@ -26,12 +26,12 @@ from certinspect.discover import (
|
|
|
26
26
|
)
|
|
27
27
|
from certinspect.fetch import (
|
|
28
28
|
STARTTLS_PORTS,
|
|
29
|
-
check_revocation,
|
|
30
29
|
get_server_cert,
|
|
31
30
|
retry_network,
|
|
32
31
|
verify_chain,
|
|
33
32
|
verify_chain_offline,
|
|
34
33
|
)
|
|
34
|
+
from certinspect.revocation import check_revocation
|
|
35
35
|
from certinspect.parser import (
|
|
36
36
|
load_certificate,
|
|
37
37
|
load_certificates,
|
|
@@ -14,7 +14,7 @@ import json
|
|
|
14
14
|
from dataclasses import dataclass
|
|
15
15
|
from urllib.parse import urlencode
|
|
16
16
|
|
|
17
|
-
from certinspect.
|
|
17
|
+
from certinspect.httpfetch import _http
|
|
18
18
|
|
|
19
19
|
# crt.sh search front end. The `%` in the query is a SQL LIKE wildcard matching
|
|
20
20
|
# any subdomain label; `output=json` asks for machine-readable results.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Process exit codes and the status-to-code mapping.
|
|
2
|
+
|
|
3
|
+
Single source of truth for the exit codes certinspect returns, so the
|
|
4
|
+
orchestration (cli) and the reporters (formatter) agree on what each number
|
|
5
|
+
means instead of repeating the literals.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from enum import IntEnum, StrEnum
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExitCode(IntEnum):
|
|
12
|
+
"""Exit code reflecting the worst certificate state found.
|
|
13
|
+
|
|
14
|
+
Kept distinct from argparse's usage error (2) and the generic runtime
|
|
15
|
+
error (1), which are not certificate states and stay plain integers.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
OK = 0
|
|
19
|
+
EXPIRING = 3
|
|
20
|
+
INVALID = 4 # expired, critical, not-yet-valid or invalid dates
|
|
21
|
+
HOSTNAME_MISMATCH = 5
|
|
22
|
+
UNTRUSTED_OR_REVOKED = 6
|
|
23
|
+
PIN_MISMATCH = 7
|
|
24
|
+
SAN_MISMATCH = 8
|
|
25
|
+
POLICY = 9
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Status(StrEnum):
|
|
29
|
+
"""Certificate validity status returned by ``certificate_status``.
|
|
30
|
+
|
|
31
|
+
A ``StrEnum`` so each member still *is* its plain string ("EXPIRED", ...),
|
|
32
|
+
keeping the JSON output and every existing string comparison unchanged
|
|
33
|
+
while giving one authoritative definition of the status set.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
VALID = "VALID"
|
|
37
|
+
EXPIRING = "EXPIRING"
|
|
38
|
+
CRITICAL = "CRITICAL"
|
|
39
|
+
EXPIRED = "EXPIRED"
|
|
40
|
+
NOT_YET_VALID = "NOT YET VALID"
|
|
41
|
+
INVALID_DATES = "INVALID DATES"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Validity status mapped to its exit code; the four non-valid date states all
|
|
45
|
+
# share ExitCode.INVALID.
|
|
46
|
+
EXIT_BY_STATUS: dict[Status, ExitCode] = {
|
|
47
|
+
Status.VALID: ExitCode.OK,
|
|
48
|
+
Status.EXPIRING: ExitCode.EXPIRING,
|
|
49
|
+
Status.CRITICAL: ExitCode.INVALID,
|
|
50
|
+
Status.EXPIRED: ExitCode.INVALID,
|
|
51
|
+
Status.INVALID_DATES: ExitCode.INVALID,
|
|
52
|
+
Status.NOT_YET_VALID: ExitCode.INVALID,
|
|
53
|
+
}
|
|
@@ -10,78 +10,22 @@ connection failing. Validity is computed later in parser.py.
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import base64
|
|
13
|
-
import ipaddress
|
|
14
13
|
import socket
|
|
15
14
|
import ssl
|
|
16
15
|
import time
|
|
17
16
|
import urllib.request
|
|
18
17
|
import warnings
|
|
19
|
-
from datetime import datetime, timedelta, timezone
|
|
20
18
|
from urllib.parse import urlsplit
|
|
21
19
|
|
|
22
20
|
from cryptography import x509
|
|
23
|
-
from cryptography.
|
|
24
|
-
from cryptography.x509 import
|
|
25
|
-
from cryptography.x509.oid import AuthorityInformationAccessOID, ExtensionOID, NameOID
|
|
21
|
+
from cryptography.x509 import verification
|
|
22
|
+
from cryptography.x509.oid import NameOID
|
|
26
23
|
|
|
27
24
|
|
|
28
25
|
# Standard plaintext ports for the STARTTLS-capable protocols, used as the
|
|
29
26
|
# default port when --port is left unset.
|
|
30
27
|
STARTTLS_PORTS = {"smtp": 587, "imap": 143, "pop3": 110, "ftp": 21}
|
|
31
28
|
|
|
32
|
-
# Cap the size of any certificate-supplied HTTP response (OCSP/CRL/CA-Issuer)
|
|
33
|
-
# so a malicious certificate cannot point us at an unbounded download and
|
|
34
|
-
# exhaust memory. Real-world CRLs stay comfortably below this.
|
|
35
|
-
_MAX_HTTP_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
36
|
-
|
|
37
|
-
# Clock-skew tolerance when judging whether an OCSP response is still fresh.
|
|
38
|
-
_OCSP_CLOCK_SKEW = timedelta(minutes=5)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def _is_blocked_fetch_address(
|
|
42
|
-
ip: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
|
43
|
-
) -> bool:
|
|
44
|
-
"""Return True for addresses a certificate-supplied URL must not reach.
|
|
45
|
-
|
|
46
|
-
Loopback, link-local (which covers the cloud metadata endpoint
|
|
47
|
-
``169.254.169.254``), unspecified, multicast and reserved ranges are
|
|
48
|
-
refused. Private RFC1918 ranges are deliberately allowed so revocation
|
|
49
|
-
still works behind an internal PKI.
|
|
50
|
-
"""
|
|
51
|
-
return (
|
|
52
|
-
ip.is_loopback
|
|
53
|
-
or ip.is_link_local
|
|
54
|
-
or ip.is_unspecified
|
|
55
|
-
or ip.is_multicast
|
|
56
|
-
or ip.is_reserved
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _guard_fetch_host(url: str) -> None:
|
|
61
|
-
"""Refuse to fetch a certificate-supplied URL pointing at an internal host.
|
|
62
|
-
|
|
63
|
-
OCSP, CRL and CA-Issuer URLs come from the inspected certificate, i.e. from
|
|
64
|
-
untrusted input; following them blindly would turn certinspect into an SSRF
|
|
65
|
-
primitive able to reach the cloud metadata service or a port on localhost.
|
|
66
|
-
The host is resolved and every returned address checked. The guard is
|
|
67
|
-
best-effort (the HTTP client resolves DNS again, so a rebinding attacker
|
|
68
|
-
could still race it) but closes the obvious vectors. Raises ValueError when
|
|
69
|
-
the target is not allowed, which the callers already treat as a soft-fail.
|
|
70
|
-
"""
|
|
71
|
-
host = urlsplit(url).hostname
|
|
72
|
-
if not host:
|
|
73
|
-
raise ValueError(f"URL has no host: {url}")
|
|
74
|
-
try:
|
|
75
|
-
infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
|
|
76
|
-
except OSError as err:
|
|
77
|
-
raise ValueError(f"could not resolve {host}: {err}") from err
|
|
78
|
-
for info in infos:
|
|
79
|
-
ip = ipaddress.ip_address(info[4][0])
|
|
80
|
-
if _is_blocked_fetch_address(ip):
|
|
81
|
-
raise ValueError(
|
|
82
|
-
f"refusing to fetch {url}: {ip} is a non-routable or internal address"
|
|
83
|
-
)
|
|
84
|
-
|
|
85
29
|
|
|
86
30
|
def _read_connect_response(sock: socket.socket) -> bytes:
|
|
87
31
|
"""Read an HTTP CONNECT response up to the end of its header block."""
|
|
@@ -494,263 +438,3 @@ def verify_chain_offline(
|
|
|
494
438
|
except verification.VerificationError as err:
|
|
495
439
|
return False, str(err), []
|
|
496
440
|
return True, None, list(verified)
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
def _aia_urls(cert: x509.Certificate) -> tuple[list[str], list[str]]:
|
|
500
|
-
"""Return (ocsp_urls, ca_issuer_urls) from the certificate's AIA extension.
|
|
501
|
-
|
|
502
|
-
Both lists are empty when the Authority Information Access extension is
|
|
503
|
-
absent.
|
|
504
|
-
"""
|
|
505
|
-
try:
|
|
506
|
-
aia = cert.extensions.get_extension_for_oid(
|
|
507
|
-
ExtensionOID.AUTHORITY_INFORMATION_ACCESS
|
|
508
|
-
).value
|
|
509
|
-
except x509.ExtensionNotFound:
|
|
510
|
-
return [], []
|
|
511
|
-
|
|
512
|
-
ocsp_urls: list[str] = []
|
|
513
|
-
issuer_urls: list[str] = []
|
|
514
|
-
for desc in aia:
|
|
515
|
-
location = desc.access_location.value
|
|
516
|
-
if desc.access_method == AuthorityInformationAccessOID.OCSP:
|
|
517
|
-
ocsp_urls.append(location)
|
|
518
|
-
elif desc.access_method == AuthorityInformationAccessOID.CA_ISSUERS:
|
|
519
|
-
issuer_urls.append(location)
|
|
520
|
-
return ocsp_urls, issuer_urls
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
def _http(url: str, *, data: bytes | None = None, timeout: float) -> bytes:
|
|
524
|
-
"""Perform a minimal HTTP(S) GET/POST and return the response body.
|
|
525
|
-
|
|
526
|
-
Only ``http`` and ``https`` URLs are accepted; the URLs come from the
|
|
527
|
-
certificate's own AIA/CRL extensions, i.e. from untrusted input, so the
|
|
528
|
-
target host is screened against internal/non-routable addresses and the
|
|
529
|
-
response size is capped. A POST is used when ``data`` is given.
|
|
530
|
-
"""
|
|
531
|
-
if not url.lower().startswith(("http://", "https://")):
|
|
532
|
-
raise ValueError(f"unsupported URL scheme: {url}")
|
|
533
|
-
_guard_fetch_host(url)
|
|
534
|
-
headers = {"Content-Type": "application/ocsp-request"} if data else {}
|
|
535
|
-
request = urllib.request.Request(url, data=data, headers=headers)
|
|
536
|
-
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
|
537
|
-
body = response.read(_MAX_HTTP_RESPONSE_BYTES + 1)
|
|
538
|
-
if len(body) > _MAX_HTTP_RESPONSE_BYTES:
|
|
539
|
-
raise ValueError(
|
|
540
|
-
f"response from {url} exceeds the {_MAX_HTTP_RESPONSE_BYTES}-byte limit"
|
|
541
|
-
)
|
|
542
|
-
return body
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
def _fetch_issuer(cert: x509.Certificate, timeout: float) -> x509.Certificate | None:
|
|
546
|
-
"""Download the issuer certificate via the AIA "CA Issuers" URL.
|
|
547
|
-
|
|
548
|
-
Return None when no usable issuer can be retrieved.
|
|
549
|
-
"""
|
|
550
|
-
_, issuer_urls = _aia_urls(cert)
|
|
551
|
-
for url in issuer_urls:
|
|
552
|
-
try:
|
|
553
|
-
return x509.load_der_x509_certificate(_http(url, timeout=timeout))
|
|
554
|
-
except (OSError, ValueError):
|
|
555
|
-
continue
|
|
556
|
-
return None
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
def _crl_urls(cert: x509.Certificate) -> list[str]:
|
|
560
|
-
"""Return the HTTP(S) CRL distribution-point URLs from the certificate.
|
|
561
|
-
|
|
562
|
-
Only ``http``/``https`` distribution points are returned (LDAP and other
|
|
563
|
-
schemes are skipped). The list is empty when the CRLDistributionPoints
|
|
564
|
-
extension is absent or carries no usable URL.
|
|
565
|
-
"""
|
|
566
|
-
try:
|
|
567
|
-
dps = cert.extensions.get_extension_for_oid(
|
|
568
|
-
ExtensionOID.CRL_DISTRIBUTION_POINTS
|
|
569
|
-
).value
|
|
570
|
-
except x509.ExtensionNotFound:
|
|
571
|
-
return []
|
|
572
|
-
|
|
573
|
-
urls: list[str] = []
|
|
574
|
-
for dp in dps:
|
|
575
|
-
for name in dp.full_name or []:
|
|
576
|
-
value = getattr(name, "value", None)
|
|
577
|
-
if isinstance(value, str) and value.lower().startswith(
|
|
578
|
-
("http://", "https://")
|
|
579
|
-
):
|
|
580
|
-
urls.append(value)
|
|
581
|
-
return urls
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
def _ocsp_response_stale(response: ocsp.OCSPResponse) -> str | None:
|
|
585
|
-
"""Return a reason when the OCSP response is outside its validity window.
|
|
586
|
-
|
|
587
|
-
A response whose ``nextUpdate`` is already in the past (or whose
|
|
588
|
-
``thisUpdate`` lies in the future) may be a replayed or stale answer and
|
|
589
|
-
must not back a trusted GOOD verdict; a small clock-skew tolerance is
|
|
590
|
-
allowed. Missing timestamps or parse errors return None ("cannot tell"),
|
|
591
|
-
preserving the browser-like soft-fail behaviour.
|
|
592
|
-
"""
|
|
593
|
-
now = datetime.now(timezone.utc)
|
|
594
|
-
try:
|
|
595
|
-
this_update = response.this_update_utc
|
|
596
|
-
next_update = response.next_update_utc
|
|
597
|
-
except (ValueError, AttributeError):
|
|
598
|
-
return None
|
|
599
|
-
if this_update is not None and this_update - _OCSP_CLOCK_SKEW > now:
|
|
600
|
-
return f"OCSP response not yet valid (thisUpdate {this_update})"
|
|
601
|
-
if next_update is not None and next_update + _OCSP_CLOCK_SKEW < now:
|
|
602
|
-
return f"OCSP response is stale (nextUpdate {next_update})"
|
|
603
|
-
return None
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
def _check_ocsp(
|
|
607
|
-
cert: x509.Certificate,
|
|
608
|
-
issuer: x509.Certificate | None,
|
|
609
|
-
timeout: float,
|
|
610
|
-
) -> tuple[str, str | None]:
|
|
611
|
-
"""Check revocation via OCSP. See ``check_revocation`` for the status set."""
|
|
612
|
-
ocsp_urls, _ = _aia_urls(cert)
|
|
613
|
-
if not ocsp_urls:
|
|
614
|
-
return "UNAVAILABLE", "no OCSP responder in AIA extension"
|
|
615
|
-
if issuer is None:
|
|
616
|
-
return "UNAVAILABLE", "issuer certificate could not be retrieved"
|
|
617
|
-
|
|
618
|
-
# OCSP CertID conventionally uses SHA-1 for the issuer name/key hashes;
|
|
619
|
-
# many responders reject other digests.
|
|
620
|
-
builder = ocsp.OCSPRequestBuilder().add_certificate(cert, issuer, hashes.SHA1())
|
|
621
|
-
der_request = builder.build().public_bytes(serialization.Encoding.DER)
|
|
622
|
-
|
|
623
|
-
try:
|
|
624
|
-
raw = _http(ocsp_urls[0], data=der_request, timeout=timeout)
|
|
625
|
-
except (OSError, ValueError) as err:
|
|
626
|
-
return "UNAVAILABLE", f"OCSP request failed: {err}"
|
|
627
|
-
|
|
628
|
-
# Parsing must soft-fail too: some responders (e.g. DigiCert/GitHub) return
|
|
629
|
-
# a BasicOCSPResponse whose signatureAlgorithm the strict ASN.1 parser
|
|
630
|
-
# rejects with a ValueError. A malformed response must not abort the whole
|
|
631
|
-
# inspection — degrade to UNAVAILABLE and let the CRL fallback take over.
|
|
632
|
-
try:
|
|
633
|
-
response = ocsp.load_der_ocsp_response(raw)
|
|
634
|
-
if response.response_status != ocsp.OCSPResponseStatus.SUCCESSFUL:
|
|
635
|
-
return (
|
|
636
|
-
"UNAVAILABLE",
|
|
637
|
-
f"OCSP response status: {response.response_status.name}",
|
|
638
|
-
)
|
|
639
|
-
status = response.certificate_status
|
|
640
|
-
except ValueError as err:
|
|
641
|
-
return "UNAVAILABLE", f"OCSP response could not be parsed: {err}"
|
|
642
|
-
|
|
643
|
-
if status == ocsp.OCSPCertStatus.GOOD:
|
|
644
|
-
stale = _ocsp_response_stale(response)
|
|
645
|
-
if stale is not None:
|
|
646
|
-
return "UNAVAILABLE", stale
|
|
647
|
-
return "GOOD", None
|
|
648
|
-
if status == ocsp.OCSPCertStatus.REVOKED:
|
|
649
|
-
when = getattr(response, "revocation_time_utc", None)
|
|
650
|
-
return "REVOKED", f"revoked at {when}" if when else "revoked"
|
|
651
|
-
return "UNKNOWN", "responder does not know this certificate"
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
def _load_crl(raw: bytes) -> x509.CertificateRevocationList | None:
|
|
655
|
-
"""Parse a CRL from DER or PEM bytes, or return None when neither works."""
|
|
656
|
-
try:
|
|
657
|
-
return x509.load_der_x509_crl(raw)
|
|
658
|
-
except ValueError:
|
|
659
|
-
try:
|
|
660
|
-
return x509.load_pem_x509_crl(raw)
|
|
661
|
-
except ValueError:
|
|
662
|
-
return None
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
def _crl_stale(crl: x509.CertificateRevocationList) -> str | None:
|
|
666
|
-
"""Return a reason when a CRL is outside its validity window."""
|
|
667
|
-
now = datetime.now(timezone.utc)
|
|
668
|
-
try:
|
|
669
|
-
last_update = crl.last_update_utc
|
|
670
|
-
next_update = crl.next_update_utc
|
|
671
|
-
except (ValueError, AttributeError):
|
|
672
|
-
return None
|
|
673
|
-
if last_update is not None and last_update - _OCSP_CLOCK_SKEW > now:
|
|
674
|
-
return f"CRL is not yet valid (lastUpdate {last_update})"
|
|
675
|
-
if next_update is not None and next_update + _OCSP_CLOCK_SKEW < now:
|
|
676
|
-
return f"CRL is stale (nextUpdate {next_update})"
|
|
677
|
-
return None
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
def _check_crl(
|
|
681
|
-
cert: x509.Certificate,
|
|
682
|
-
issuer: x509.Certificate | None,
|
|
683
|
-
timeout: float,
|
|
684
|
-
) -> tuple[str, str | None]:
|
|
685
|
-
"""Check revocation via the certificate's CRL distribution points.
|
|
686
|
-
|
|
687
|
-
Download each CRL in turn and look up the certificate's serial number.
|
|
688
|
-
When ``issuer`` is known the CRL signature is verified and a CRL that
|
|
689
|
-
fails the check is skipped. The first CRL that yields a verdict wins;
|
|
690
|
-
otherwise the status is ``"UNAVAILABLE"`` (soft-fail).
|
|
691
|
-
"""
|
|
692
|
-
urls = _crl_urls(cert)
|
|
693
|
-
if not urls:
|
|
694
|
-
return "UNAVAILABLE", "no CRL distribution point in extension"
|
|
695
|
-
|
|
696
|
-
for url in urls:
|
|
697
|
-
try:
|
|
698
|
-
raw = _http(url, timeout=timeout)
|
|
699
|
-
except (OSError, ValueError):
|
|
700
|
-
continue
|
|
701
|
-
crl = _load_crl(raw)
|
|
702
|
-
if crl is None:
|
|
703
|
-
continue
|
|
704
|
-
if issuer is not None and not crl.is_signature_valid(issuer.public_key()):
|
|
705
|
-
continue
|
|
706
|
-
|
|
707
|
-
revoked = crl.get_revoked_certificate_by_serial_number(cert.serial_number)
|
|
708
|
-
if revoked is not None:
|
|
709
|
-
when = getattr(revoked, "revocation_date_utc", None)
|
|
710
|
-
detail = f"revoked at {when}" if when else "revoked"
|
|
711
|
-
return "REVOKED", f"{detail} (via CRL)"
|
|
712
|
-
stale = _crl_stale(crl)
|
|
713
|
-
if stale is not None:
|
|
714
|
-
return "UNAVAILABLE", stale
|
|
715
|
-
return "GOOD", "via CRL"
|
|
716
|
-
|
|
717
|
-
return "UNAVAILABLE", "CRL could not be retrieved"
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
def check_revocation(
|
|
721
|
-
cert: x509.Certificate,
|
|
722
|
-
timeout: float = 5.0,
|
|
723
|
-
issuer: x509.Certificate | None = None,
|
|
724
|
-
) -> tuple[str, str | None]:
|
|
725
|
-
"""Check the certificate's revocation status via OCSP, then CRL.
|
|
726
|
-
|
|
727
|
-
Return ``(status, detail)`` where status is one of:
|
|
728
|
-
|
|
729
|
-
* ``"GOOD"`` — the certificate is confirmed valid.
|
|
730
|
-
* ``"REVOKED"`` — the certificate is confirmed revoked.
|
|
731
|
-
* ``"UNKNOWN"`` — the OCSP responder does not know this certificate.
|
|
732
|
-
* ``"UNAVAILABLE"`` — neither OCSP nor CRL gave an answer (soft-fail,
|
|
733
|
-
like a browser).
|
|
734
|
-
|
|
735
|
-
OCSP is tried first. When it soft-fails (no responder, issuer unavailable,
|
|
736
|
-
network or responder error) the certificate's CRL distribution points are
|
|
737
|
-
queried as a fallback. ``detail`` carries extra context (e.g. the
|
|
738
|
-
revocation time, or which source answered) when useful.
|
|
739
|
-
|
|
740
|
-
When ``issuer`` is provided (e.g. from the verified TLS chain) it is used
|
|
741
|
-
directly; otherwise the issuer is downloaded via the AIA "CA Issuers" URL.
|
|
742
|
-
"""
|
|
743
|
-
if issuer is None:
|
|
744
|
-
issuer = _fetch_issuer(cert, timeout)
|
|
745
|
-
|
|
746
|
-
status, detail = _check_ocsp(cert, issuer, timeout)
|
|
747
|
-
if status != "UNAVAILABLE":
|
|
748
|
-
return status, detail
|
|
749
|
-
|
|
750
|
-
crl_status, crl_detail = _check_crl(cert, issuer, timeout)
|
|
751
|
-
if crl_status != "UNAVAILABLE":
|
|
752
|
-
return crl_status, crl_detail
|
|
753
|
-
|
|
754
|
-
# Both soft-failed: report the OCSP reason, which is usually the more
|
|
755
|
-
# informative of the two.
|
|
756
|
-
return status, detail
|
|
@@ -9,7 +9,7 @@ import csv
|
|
|
9
9
|
import io
|
|
10
10
|
import json
|
|
11
11
|
|
|
12
|
-
from certinspect.exit_codes import ExitCode
|
|
12
|
+
from certinspect.exit_codes import ExitCode, Status
|
|
13
13
|
from certinspect.models import CertificateInfo
|
|
14
14
|
from certinspect.parser import certificate_status
|
|
15
15
|
|
|
@@ -104,10 +104,10 @@ def format_human(
|
|
|
104
104
|
if not info["pin_match"]:
|
|
105
105
|
lines.append("WARNING: fingerprint does not match the expected pin")
|
|
106
106
|
|
|
107
|
-
if status ==
|
|
107
|
+
if status == Status.CRITICAL:
|
|
108
108
|
lines.append("")
|
|
109
109
|
lines.append(f"CRITICAL: certificate expires in {days} days")
|
|
110
|
-
elif status ==
|
|
110
|
+
elif status == Status.NOT_YET_VALID:
|
|
111
111
|
lines.append("")
|
|
112
112
|
lines.append(
|
|
113
113
|
f"WARNING: certificate is not valid until {info['not_valid_before']}"
|
|
@@ -369,9 +369,9 @@ def format_summary(
|
|
|
369
369
|
status = info.get("status") or certificate_status(
|
|
370
370
|
info, warn_days, critical_days
|
|
371
371
|
)
|
|
372
|
-
if status ==
|
|
372
|
+
if status == Status.CRITICAL:
|
|
373
373
|
counts["critical"] += 1
|
|
374
|
-
elif status ==
|
|
374
|
+
elif status == Status.NOT_YET_VALID:
|
|
375
375
|
counts["not-yet-valid"] += 1
|
|
376
376
|
else:
|
|
377
377
|
counts["expired"] += 1
|
|
@@ -496,7 +496,8 @@ def format_prometheus(
|
|
|
496
496
|
days = info["days_to_expire"]
|
|
497
497
|
is_valid = (
|
|
498
498
|
0
|
|
499
|
-
if certificate_status(info, warn_days)
|
|
499
|
+
if certificate_status(info, warn_days)
|
|
500
|
+
in (Status.EXPIRED, Status.INVALID_DATES)
|
|
500
501
|
else 1
|
|
501
502
|
)
|
|
502
503
|
up.append(f'certinspect_up{{target="{label}"}} 1')
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""SSRF-guarded HTTP GET/POST for certificate- and CT-log-supplied URLs.
|
|
2
|
+
|
|
3
|
+
OCSP, CRL, CA-Issuer and Certificate Transparency URLs all come from untrusted
|
|
4
|
+
input, so this small client screens the target host against internal or
|
|
5
|
+
non-routable addresses and caps the response size. Shared by the revocation
|
|
6
|
+
checks and the CT-log discovery.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import ipaddress
|
|
10
|
+
import socket
|
|
11
|
+
import urllib.request
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
# Cap the size of any certificate-supplied HTTP response (OCSP/CRL/CA-Issuer)
|
|
15
|
+
# so a malicious certificate cannot point us at an unbounded download and
|
|
16
|
+
# exhaust memory. Real-world CRLs stay comfortably below this.
|
|
17
|
+
_MAX_HTTP_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_blocked_fetch_address(
|
|
21
|
+
ip: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
|
22
|
+
) -> bool:
|
|
23
|
+
"""Return True for addresses a certificate-supplied URL must not reach.
|
|
24
|
+
|
|
25
|
+
Loopback, link-local (which covers the cloud metadata endpoint
|
|
26
|
+
``169.254.169.254``), unspecified, multicast and reserved ranges are
|
|
27
|
+
refused. Private RFC1918 ranges are deliberately allowed so revocation
|
|
28
|
+
still works behind an internal PKI.
|
|
29
|
+
"""
|
|
30
|
+
return (
|
|
31
|
+
ip.is_loopback
|
|
32
|
+
or ip.is_link_local
|
|
33
|
+
or ip.is_unspecified
|
|
34
|
+
or ip.is_multicast
|
|
35
|
+
or ip.is_reserved
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _guard_fetch_host(url: str) -> None:
|
|
40
|
+
"""Refuse to fetch a certificate-supplied URL pointing at an internal host.
|
|
41
|
+
|
|
42
|
+
OCSP, CRL and CA-Issuer URLs come from the inspected certificate, i.e. from
|
|
43
|
+
untrusted input; following them blindly would turn certinspect into an SSRF
|
|
44
|
+
primitive able to reach the cloud metadata service or a port on localhost.
|
|
45
|
+
The host is resolved and every returned address checked. The guard is
|
|
46
|
+
best-effort (the HTTP client resolves DNS again, so a rebinding attacker
|
|
47
|
+
could still race it) but closes the obvious vectors. Raises ValueError when
|
|
48
|
+
the target is not allowed, which the callers already treat as a soft-fail.
|
|
49
|
+
"""
|
|
50
|
+
host = urlsplit(url).hostname
|
|
51
|
+
if not host:
|
|
52
|
+
raise ValueError(f"URL has no host: {url}")
|
|
53
|
+
try:
|
|
54
|
+
infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
|
|
55
|
+
except OSError as err:
|
|
56
|
+
raise ValueError(f"could not resolve {host}: {err}") from err
|
|
57
|
+
for info in infos:
|
|
58
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
59
|
+
if _is_blocked_fetch_address(ip):
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"refusing to fetch {url}: {ip} is a non-routable or internal address"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _http(url: str, *, data: bytes | None = None, timeout: float) -> bytes:
|
|
66
|
+
"""Perform a minimal HTTP(S) GET/POST and return the response body.
|
|
67
|
+
|
|
68
|
+
Only ``http`` and ``https`` URLs are accepted; the URLs come from the
|
|
69
|
+
certificate's own AIA/CRL extensions, i.e. from untrusted input, so the
|
|
70
|
+
target host is screened against internal/non-routable addresses and the
|
|
71
|
+
response size is capped. A POST is used when ``data`` is given.
|
|
72
|
+
"""
|
|
73
|
+
if not url.lower().startswith(("http://", "https://")):
|
|
74
|
+
raise ValueError(f"unsupported URL scheme: {url}")
|
|
75
|
+
_guard_fetch_host(url)
|
|
76
|
+
headers = {"Content-Type": "application/ocsp-request"} if data else {}
|
|
77
|
+
request = urllib.request.Request(url, data=data, headers=headers)
|
|
78
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
|
79
|
+
body = response.read(_MAX_HTTP_RESPONSE_BYTES + 1)
|
|
80
|
+
if len(body) > _MAX_HTTP_RESPONSE_BYTES:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"response from {url} exceeds the {_MAX_HTTP_RESPONSE_BYTES}-byte limit"
|
|
83
|
+
)
|
|
84
|
+
return body
|
|
@@ -9,6 +9,7 @@ from cryptography import x509
|
|
|
9
9
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
10
10
|
from cryptography.hazmat.primitives.asymmetric import dsa, ec, rsa
|
|
11
11
|
from cryptography.x509.oid import AuthorityInformationAccessOID, ExtensionOID, NameOID
|
|
12
|
+
from certinspect.exit_codes import Status
|
|
12
13
|
from certinspect.models import CertificateInfo
|
|
13
14
|
|
|
14
15
|
|
|
@@ -404,7 +405,7 @@ def analyze(cert: x509.Certificate) -> CertificateInfo:
|
|
|
404
405
|
|
|
405
406
|
def certificate_status(
|
|
406
407
|
info: CertificateInfo, warn_days: int = 30, critical_days: int | None = None
|
|
407
|
-
) ->
|
|
408
|
+
) -> Status:
|
|
408
409
|
"""Return the validity status derived from the analyzed data.
|
|
409
410
|
|
|
410
411
|
One of: 'INVALID DATES', 'NOT YET VALID', 'EXPIRED', 'CRITICAL',
|
|
@@ -415,17 +416,17 @@ def certificate_status(
|
|
|
415
416
|
window is reported as 'CRITICAL' instead.
|
|
416
417
|
"""
|
|
417
418
|
if info["not_valid_before"] > info["not_valid_after"]:
|
|
418
|
-
return
|
|
419
|
+
return Status.INVALID_DATES
|
|
419
420
|
if info["not_valid_before"] > datetime.now(timezone.utc):
|
|
420
|
-
return
|
|
421
|
+
return Status.NOT_YET_VALID
|
|
421
422
|
days = info["days_to_expire"]
|
|
422
423
|
if days < 0:
|
|
423
|
-
return
|
|
424
|
+
return Status.EXPIRED
|
|
424
425
|
if critical_days is not None and days < critical_days:
|
|
425
|
-
return
|
|
426
|
+
return Status.CRITICAL
|
|
426
427
|
if days < warn_days:
|
|
427
|
-
return
|
|
428
|
-
return
|
|
428
|
+
return Status.EXPIRING
|
|
429
|
+
return Status.VALID
|
|
429
430
|
|
|
430
431
|
|
|
431
432
|
# CA/Browser Forum TLS validity cap and its scheduled reductions (ballot
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Certificate revocation checking via OCSP and CRL.
|
|
2
|
+
|
|
3
|
+
Given a certificate (and, ideally, its issuer) determine whether it has been
|
|
4
|
+
revoked, trying OCSP first and falling back to the CRL distribution points.
|
|
5
|
+
Both soft-fail like a browser when no authoritative answer is available. The
|
|
6
|
+
HTTP transport is the SSRF-guarded client in :mod:`certinspect.httpfetch`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
|
|
11
|
+
from cryptography import x509
|
|
12
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
13
|
+
from cryptography.x509 import ocsp
|
|
14
|
+
from cryptography.x509.oid import AuthorityInformationAccessOID, ExtensionOID
|
|
15
|
+
|
|
16
|
+
from certinspect.httpfetch import _http
|
|
17
|
+
|
|
18
|
+
# Clock-skew tolerance when judging whether an OCSP response is still fresh.
|
|
19
|
+
_OCSP_CLOCK_SKEW = timedelta(minutes=5)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _aia_urls(cert: x509.Certificate) -> tuple[list[str], list[str]]:
|
|
23
|
+
"""Return (ocsp_urls, ca_issuer_urls) from the certificate's AIA extension.
|
|
24
|
+
|
|
25
|
+
Both lists are empty when the Authority Information Access extension is
|
|
26
|
+
absent.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
aia = cert.extensions.get_extension_for_oid(
|
|
30
|
+
ExtensionOID.AUTHORITY_INFORMATION_ACCESS
|
|
31
|
+
).value
|
|
32
|
+
except x509.ExtensionNotFound:
|
|
33
|
+
return [], []
|
|
34
|
+
|
|
35
|
+
ocsp_urls: list[str] = []
|
|
36
|
+
issuer_urls: list[str] = []
|
|
37
|
+
for desc in aia:
|
|
38
|
+
location = desc.access_location.value
|
|
39
|
+
if desc.access_method == AuthorityInformationAccessOID.OCSP:
|
|
40
|
+
ocsp_urls.append(location)
|
|
41
|
+
elif desc.access_method == AuthorityInformationAccessOID.CA_ISSUERS:
|
|
42
|
+
issuer_urls.append(location)
|
|
43
|
+
return ocsp_urls, issuer_urls
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fetch_issuer(cert: x509.Certificate, timeout: float) -> x509.Certificate | None:
|
|
47
|
+
"""Download the issuer certificate via the AIA "CA Issuers" URL.
|
|
48
|
+
|
|
49
|
+
Return None when no usable issuer can be retrieved.
|
|
50
|
+
"""
|
|
51
|
+
_, issuer_urls = _aia_urls(cert)
|
|
52
|
+
for url in issuer_urls:
|
|
53
|
+
try:
|
|
54
|
+
return x509.load_der_x509_certificate(_http(url, timeout=timeout))
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
continue
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _crl_urls(cert: x509.Certificate) -> list[str]:
|
|
61
|
+
"""Return the HTTP(S) CRL distribution-point URLs from the certificate.
|
|
62
|
+
|
|
63
|
+
Only ``http``/``https`` distribution points are returned (LDAP and other
|
|
64
|
+
schemes are skipped). The list is empty when the CRLDistributionPoints
|
|
65
|
+
extension is absent or carries no usable URL.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
dps = cert.extensions.get_extension_for_oid(
|
|
69
|
+
ExtensionOID.CRL_DISTRIBUTION_POINTS
|
|
70
|
+
).value
|
|
71
|
+
except x509.ExtensionNotFound:
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
urls: list[str] = []
|
|
75
|
+
for dp in dps:
|
|
76
|
+
for name in dp.full_name or []:
|
|
77
|
+
value = getattr(name, "value", None)
|
|
78
|
+
if isinstance(value, str) and value.lower().startswith(
|
|
79
|
+
("http://", "https://")
|
|
80
|
+
):
|
|
81
|
+
urls.append(value)
|
|
82
|
+
return urls
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _ocsp_response_stale(response: ocsp.OCSPResponse) -> str | None:
|
|
86
|
+
"""Return a reason when the OCSP response is outside its validity window.
|
|
87
|
+
|
|
88
|
+
A response whose ``nextUpdate`` is already in the past (or whose
|
|
89
|
+
``thisUpdate`` lies in the future) may be a replayed or stale answer and
|
|
90
|
+
must not back a trusted GOOD verdict; a small clock-skew tolerance is
|
|
91
|
+
allowed. Missing timestamps or parse errors return None ("cannot tell"),
|
|
92
|
+
preserving the browser-like soft-fail behaviour.
|
|
93
|
+
"""
|
|
94
|
+
now = datetime.now(timezone.utc)
|
|
95
|
+
try:
|
|
96
|
+
this_update = response.this_update_utc
|
|
97
|
+
next_update = response.next_update_utc
|
|
98
|
+
except (ValueError, AttributeError):
|
|
99
|
+
return None
|
|
100
|
+
if this_update is not None and this_update - _OCSP_CLOCK_SKEW > now:
|
|
101
|
+
return f"OCSP response not yet valid (thisUpdate {this_update})"
|
|
102
|
+
if next_update is not None and next_update + _OCSP_CLOCK_SKEW < now:
|
|
103
|
+
return f"OCSP response is stale (nextUpdate {next_update})"
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _check_ocsp(
|
|
108
|
+
cert: x509.Certificate,
|
|
109
|
+
issuer: x509.Certificate | None,
|
|
110
|
+
timeout: float,
|
|
111
|
+
) -> tuple[str, str | None]:
|
|
112
|
+
"""Check revocation via OCSP. See ``check_revocation`` for the status set."""
|
|
113
|
+
ocsp_urls, _ = _aia_urls(cert)
|
|
114
|
+
if not ocsp_urls:
|
|
115
|
+
return "UNAVAILABLE", "no OCSP responder in AIA extension"
|
|
116
|
+
if issuer is None:
|
|
117
|
+
return "UNAVAILABLE", "issuer certificate could not be retrieved"
|
|
118
|
+
|
|
119
|
+
# OCSP CertID conventionally uses SHA-1 for the issuer name/key hashes;
|
|
120
|
+
# many responders reject other digests.
|
|
121
|
+
builder = ocsp.OCSPRequestBuilder().add_certificate(cert, issuer, hashes.SHA1())
|
|
122
|
+
der_request = builder.build().public_bytes(serialization.Encoding.DER)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
raw = _http(ocsp_urls[0], data=der_request, timeout=timeout)
|
|
126
|
+
except (OSError, ValueError) as err:
|
|
127
|
+
return "UNAVAILABLE", f"OCSP request failed: {err}"
|
|
128
|
+
|
|
129
|
+
# Parsing must soft-fail too: some responders (e.g. DigiCert/GitHub) return
|
|
130
|
+
# a BasicOCSPResponse whose signatureAlgorithm the strict ASN.1 parser
|
|
131
|
+
# rejects with a ValueError. A malformed response must not abort the whole
|
|
132
|
+
# inspection — degrade to UNAVAILABLE and let the CRL fallback take over.
|
|
133
|
+
try:
|
|
134
|
+
response = ocsp.load_der_ocsp_response(raw)
|
|
135
|
+
if response.response_status != ocsp.OCSPResponseStatus.SUCCESSFUL:
|
|
136
|
+
return (
|
|
137
|
+
"UNAVAILABLE",
|
|
138
|
+
f"OCSP response status: {response.response_status.name}",
|
|
139
|
+
)
|
|
140
|
+
status = response.certificate_status
|
|
141
|
+
except ValueError as err:
|
|
142
|
+
return "UNAVAILABLE", f"OCSP response could not be parsed: {err}"
|
|
143
|
+
|
|
144
|
+
if status == ocsp.OCSPCertStatus.GOOD:
|
|
145
|
+
stale = _ocsp_response_stale(response)
|
|
146
|
+
if stale is not None:
|
|
147
|
+
return "UNAVAILABLE", stale
|
|
148
|
+
return "GOOD", None
|
|
149
|
+
if status == ocsp.OCSPCertStatus.REVOKED:
|
|
150
|
+
when = getattr(response, "revocation_time_utc", None)
|
|
151
|
+
return "REVOKED", f"revoked at {when}" if when else "revoked"
|
|
152
|
+
return "UNKNOWN", "responder does not know this certificate"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _load_crl(raw: bytes) -> x509.CertificateRevocationList | None:
|
|
156
|
+
"""Parse a CRL from DER or PEM bytes, or return None when neither works."""
|
|
157
|
+
try:
|
|
158
|
+
return x509.load_der_x509_crl(raw)
|
|
159
|
+
except ValueError:
|
|
160
|
+
try:
|
|
161
|
+
return x509.load_pem_x509_crl(raw)
|
|
162
|
+
except ValueError:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _crl_stale(crl: x509.CertificateRevocationList) -> str | None:
|
|
167
|
+
"""Return a reason when a CRL is outside its validity window."""
|
|
168
|
+
now = datetime.now(timezone.utc)
|
|
169
|
+
try:
|
|
170
|
+
last_update = crl.last_update_utc
|
|
171
|
+
next_update = crl.next_update_utc
|
|
172
|
+
except (ValueError, AttributeError):
|
|
173
|
+
return None
|
|
174
|
+
if last_update is not None and last_update - _OCSP_CLOCK_SKEW > now:
|
|
175
|
+
return f"CRL is not yet valid (lastUpdate {last_update})"
|
|
176
|
+
if next_update is not None and next_update + _OCSP_CLOCK_SKEW < now:
|
|
177
|
+
return f"CRL is stale (nextUpdate {next_update})"
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _check_crl(
|
|
182
|
+
cert: x509.Certificate,
|
|
183
|
+
issuer: x509.Certificate | None,
|
|
184
|
+
timeout: float,
|
|
185
|
+
) -> tuple[str, str | None]:
|
|
186
|
+
"""Check revocation via the certificate's CRL distribution points.
|
|
187
|
+
|
|
188
|
+
Download each CRL in turn and look up the certificate's serial number.
|
|
189
|
+
When ``issuer`` is known the CRL signature is verified and a CRL that
|
|
190
|
+
fails the check is skipped. The first CRL that yields a verdict wins;
|
|
191
|
+
otherwise the status is ``"UNAVAILABLE"`` (soft-fail).
|
|
192
|
+
"""
|
|
193
|
+
urls = _crl_urls(cert)
|
|
194
|
+
if not urls:
|
|
195
|
+
return "UNAVAILABLE", "no CRL distribution point in extension"
|
|
196
|
+
|
|
197
|
+
for url in urls:
|
|
198
|
+
try:
|
|
199
|
+
raw = _http(url, timeout=timeout)
|
|
200
|
+
except (OSError, ValueError):
|
|
201
|
+
continue
|
|
202
|
+
crl = _load_crl(raw)
|
|
203
|
+
if crl is None:
|
|
204
|
+
continue
|
|
205
|
+
if issuer is not None and not crl.is_signature_valid(issuer.public_key()):
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
revoked = crl.get_revoked_certificate_by_serial_number(cert.serial_number)
|
|
209
|
+
if revoked is not None:
|
|
210
|
+
when = getattr(revoked, "revocation_date_utc", None)
|
|
211
|
+
detail = f"revoked at {when}" if when else "revoked"
|
|
212
|
+
return "REVOKED", f"{detail} (via CRL)"
|
|
213
|
+
stale = _crl_stale(crl)
|
|
214
|
+
if stale is not None:
|
|
215
|
+
return "UNAVAILABLE", stale
|
|
216
|
+
return "GOOD", "via CRL"
|
|
217
|
+
|
|
218
|
+
return "UNAVAILABLE", "CRL could not be retrieved"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def check_revocation(
|
|
222
|
+
cert: x509.Certificate,
|
|
223
|
+
timeout: float = 5.0,
|
|
224
|
+
issuer: x509.Certificate | None = None,
|
|
225
|
+
) -> tuple[str, str | None]:
|
|
226
|
+
"""Check the certificate's revocation status via OCSP, then CRL.
|
|
227
|
+
|
|
228
|
+
Return ``(status, detail)`` where status is one of:
|
|
229
|
+
|
|
230
|
+
* ``"GOOD"`` — the certificate is confirmed valid.
|
|
231
|
+
* ``"REVOKED"`` — the certificate is confirmed revoked.
|
|
232
|
+
* ``"UNKNOWN"`` — the OCSP responder does not know this certificate.
|
|
233
|
+
* ``"UNAVAILABLE"`` — neither OCSP nor CRL gave an answer (soft-fail,
|
|
234
|
+
like a browser).
|
|
235
|
+
|
|
236
|
+
OCSP is tried first. When it soft-fails (no responder, issuer unavailable,
|
|
237
|
+
network or responder error) the certificate's CRL distribution points are
|
|
238
|
+
queried as a fallback. ``detail`` carries extra context (e.g. the
|
|
239
|
+
revocation time, or which source answered) when useful.
|
|
240
|
+
|
|
241
|
+
When ``issuer`` is provided (e.g. from the verified TLS chain) it is used
|
|
242
|
+
directly; otherwise the issuer is downloaded via the AIA "CA Issuers" URL.
|
|
243
|
+
"""
|
|
244
|
+
if issuer is None:
|
|
245
|
+
issuer = _fetch_issuer(cert, timeout)
|
|
246
|
+
|
|
247
|
+
status, detail = _check_ocsp(cert, issuer, timeout)
|
|
248
|
+
if status != "UNAVAILABLE":
|
|
249
|
+
return status, detail
|
|
250
|
+
|
|
251
|
+
crl_status, crl_detail = _check_crl(cert, issuer, timeout)
|
|
252
|
+
if crl_status != "UNAVAILABLE":
|
|
253
|
+
return crl_status, crl_detail
|
|
254
|
+
|
|
255
|
+
# Both soft-failed: report the OCSP reason, which is usually the more
|
|
256
|
+
# informative of the two.
|
|
257
|
+
return status, detail
|
|
@@ -10,9 +10,11 @@ src/certinspect/discover.py
|
|
|
10
10
|
src/certinspect/exit_codes.py
|
|
11
11
|
src/certinspect/fetch.py
|
|
12
12
|
src/certinspect/formatter.py
|
|
13
|
+
src/certinspect/httpfetch.py
|
|
13
14
|
src/certinspect/models.py
|
|
14
15
|
src/certinspect/parser.py
|
|
15
16
|
src/certinspect/render.py
|
|
17
|
+
src/certinspect/revocation.py
|
|
16
18
|
src/certinspect.egg-info/PKG-INFO
|
|
17
19
|
src/certinspect.egg-info/SOURCES.txt
|
|
18
20
|
src/certinspect.egg-info/dependency_links.txt
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
5
|
-
from certinspect.
|
|
5
|
+
from certinspect.revocation import check_revocation
|
|
6
6
|
from certinspect.parser import load_certificate
|
|
7
7
|
|
|
8
8
|
|
|
@@ -22,7 +22,7 @@ def test_check_revocation_accepts_explicit_issuer(make_cert):
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def test_http_rejects_unsupported_scheme():
|
|
25
|
-
from certinspect.
|
|
25
|
+
from certinspect.httpfetch import _http
|
|
26
26
|
|
|
27
27
|
with pytest.raises(ValueError, match="unsupported URL scheme"):
|
|
28
28
|
_http("ftp://example.com/cert", timeout=1.0)
|
|
@@ -454,7 +454,7 @@ def _build_crl_pki(
|
|
|
454
454
|
def test_crl_urls_extracts_http_distribution_points():
|
|
455
455
|
from cryptography.hazmat.primitives import serialization
|
|
456
456
|
|
|
457
|
-
from certinspect.
|
|
457
|
+
from certinspect.revocation import _crl_urls
|
|
458
458
|
|
|
459
459
|
_, leaf_cert, _ = _build_crl_pki()
|
|
460
460
|
leaf = load_certificate(leaf_cert.public_bytes(serialization.Encoding.DER))
|
|
@@ -464,13 +464,13 @@ def test_crl_urls_extracts_http_distribution_points():
|
|
|
464
464
|
def test_check_crl_reports_good_when_serial_absent(monkeypatch):
|
|
465
465
|
from cryptography.hazmat.primitives import serialization
|
|
466
466
|
|
|
467
|
-
from certinspect import
|
|
467
|
+
from certinspect import revocation
|
|
468
468
|
|
|
469
469
|
issuer_cert, leaf_cert, crl = _build_crl_pki(revoked_serials=())
|
|
470
470
|
der_crl = crl.public_bytes(serialization.Encoding.DER)
|
|
471
|
-
monkeypatch.setattr(
|
|
471
|
+
monkeypatch.setattr(revocation, "_http", lambda url, timeout: der_crl)
|
|
472
472
|
|
|
473
|
-
status, detail =
|
|
473
|
+
status, detail = revocation._check_crl(leaf_cert, issuer_cert, timeout=1.0)
|
|
474
474
|
assert status == "GOOD"
|
|
475
475
|
assert detail == "via CRL"
|
|
476
476
|
|
|
@@ -478,13 +478,13 @@ def test_check_crl_reports_good_when_serial_absent(monkeypatch):
|
|
|
478
478
|
def test_check_crl_reports_revoked_when_serial_listed(monkeypatch):
|
|
479
479
|
from cryptography.hazmat.primitives import serialization
|
|
480
480
|
|
|
481
|
-
from certinspect import
|
|
481
|
+
from certinspect import revocation
|
|
482
482
|
|
|
483
483
|
issuer_cert, leaf_cert, crl = _build_crl_pki(revoked_serials=(4242,))
|
|
484
484
|
der_crl = crl.public_bytes(serialization.Encoding.DER)
|
|
485
|
-
monkeypatch.setattr(
|
|
485
|
+
monkeypatch.setattr(revocation, "_http", lambda url, timeout: der_crl)
|
|
486
486
|
|
|
487
|
-
status, detail =
|
|
487
|
+
status, detail = revocation._check_crl(leaf_cert, issuer_cert, timeout=1.0)
|
|
488
488
|
assert status == "REVOKED"
|
|
489
489
|
assert "via CRL" in detail
|
|
490
490
|
|
|
@@ -494,7 +494,7 @@ def test_check_crl_soft_fails_stale_crl_when_serial_absent(monkeypatch):
|
|
|
494
494
|
|
|
495
495
|
from cryptography.hazmat.primitives import serialization
|
|
496
496
|
|
|
497
|
-
from certinspect import
|
|
497
|
+
from certinspect import revocation
|
|
498
498
|
|
|
499
499
|
issuer_cert, leaf_cert, crl = _build_crl_pki(
|
|
500
500
|
revoked_serials=(),
|
|
@@ -502,9 +502,9 @@ def test_check_crl_soft_fails_stale_crl_when_serial_absent(monkeypatch):
|
|
|
502
502
|
crl_next_update_delta=-timedelta(days=1),
|
|
503
503
|
)
|
|
504
504
|
der_crl = crl.public_bytes(serialization.Encoding.DER)
|
|
505
|
-
monkeypatch.setattr(
|
|
505
|
+
monkeypatch.setattr(revocation, "_http", lambda url, timeout: der_crl)
|
|
506
506
|
|
|
507
|
-
status, detail =
|
|
507
|
+
status, detail = revocation._check_crl(leaf_cert, issuer_cert, timeout=1.0)
|
|
508
508
|
assert status == "UNAVAILABLE"
|
|
509
509
|
assert "stale" in detail
|
|
510
510
|
|
|
@@ -514,7 +514,7 @@ def test_check_crl_reports_revoked_even_when_crl_is_stale(monkeypatch):
|
|
|
514
514
|
|
|
515
515
|
from cryptography.hazmat.primitives import serialization
|
|
516
516
|
|
|
517
|
-
from certinspect import
|
|
517
|
+
from certinspect import revocation
|
|
518
518
|
|
|
519
519
|
issuer_cert, leaf_cert, crl = _build_crl_pki(
|
|
520
520
|
revoked_serials=(4242,),
|
|
@@ -522,9 +522,9 @@ def test_check_crl_reports_revoked_even_when_crl_is_stale(monkeypatch):
|
|
|
522
522
|
crl_next_update_delta=-timedelta(days=1),
|
|
523
523
|
)
|
|
524
524
|
der_crl = crl.public_bytes(serialization.Encoding.DER)
|
|
525
|
-
monkeypatch.setattr(
|
|
525
|
+
monkeypatch.setattr(revocation, "_http", lambda url, timeout: der_crl)
|
|
526
526
|
|
|
527
|
-
status, detail =
|
|
527
|
+
status, detail = revocation._check_crl(leaf_cert, issuer_cert, timeout=1.0)
|
|
528
528
|
assert status == "REVOKED"
|
|
529
529
|
assert "via CRL" in detail
|
|
530
530
|
|
|
@@ -534,7 +534,7 @@ def test_check_crl_soft_fails_not_yet_valid_crl_when_serial_absent(monkeypatch):
|
|
|
534
534
|
|
|
535
535
|
from cryptography.hazmat.primitives import serialization
|
|
536
536
|
|
|
537
|
-
from certinspect import
|
|
537
|
+
from certinspect import revocation
|
|
538
538
|
|
|
539
539
|
issuer_cert, leaf_cert, crl = _build_crl_pki(
|
|
540
540
|
revoked_serials=(),
|
|
@@ -542,9 +542,9 @@ def test_check_crl_soft_fails_not_yet_valid_crl_when_serial_absent(monkeypatch):
|
|
|
542
542
|
crl_next_update_delta=timedelta(days=2),
|
|
543
543
|
)
|
|
544
544
|
der_crl = crl.public_bytes(serialization.Encoding.DER)
|
|
545
|
-
monkeypatch.setattr(
|
|
545
|
+
monkeypatch.setattr(revocation, "_http", lambda url, timeout: der_crl)
|
|
546
546
|
|
|
547
|
-
status, detail =
|
|
547
|
+
status, detail = revocation._check_crl(leaf_cert, issuer_cert, timeout=1.0)
|
|
548
548
|
assert status == "UNAVAILABLE"
|
|
549
549
|
assert "not yet valid" in detail
|
|
550
550
|
|
|
@@ -553,17 +553,17 @@ def test_check_crl_skips_crl_with_bad_signature(monkeypatch):
|
|
|
553
553
|
"""A CRL not signed by the issuer is ignored (soft-fail)."""
|
|
554
554
|
from cryptography.hazmat.primitives import serialization
|
|
555
555
|
|
|
556
|
-
from certinspect import
|
|
556
|
+
from certinspect import revocation
|
|
557
557
|
|
|
558
558
|
_, leaf_cert, _ = _build_crl_pki(revoked_serials=(4242,))
|
|
559
559
|
# CRL signed by an unrelated CA must not be trusted against this issuer.
|
|
560
560
|
other_issuer, _, other_crl = _build_crl_pki(revoked_serials=(4242,))
|
|
561
561
|
der_crl = other_crl.public_bytes(serialization.Encoding.DER)
|
|
562
|
-
monkeypatch.setattr(
|
|
562
|
+
monkeypatch.setattr(revocation, "_http", lambda url, timeout: der_crl)
|
|
563
563
|
|
|
564
564
|
# Use the first PKI's issuer, whose key did not sign ``other_crl``.
|
|
565
565
|
wrong_issuer, _, _ = _build_crl_pki()
|
|
566
|
-
status, _ =
|
|
566
|
+
status, _ = revocation._check_crl(leaf_cert, wrong_issuer, timeout=1.0)
|
|
567
567
|
assert status == "UNAVAILABLE"
|
|
568
568
|
|
|
569
569
|
|
|
@@ -571,13 +571,13 @@ def test_check_revocation_falls_back_to_crl(monkeypatch):
|
|
|
571
571
|
"""With no OCSP responder, check_revocation consults the CRL."""
|
|
572
572
|
from cryptography.hazmat.primitives import serialization
|
|
573
573
|
|
|
574
|
-
from certinspect import
|
|
574
|
+
from certinspect import revocation
|
|
575
575
|
|
|
576
576
|
issuer_cert, leaf_cert, crl = _build_crl_pki(revoked_serials=(4242,))
|
|
577
577
|
der_crl = crl.public_bytes(serialization.Encoding.DER)
|
|
578
|
-
monkeypatch.setattr(
|
|
578
|
+
monkeypatch.setattr(revocation, "_http", lambda url, timeout: der_crl)
|
|
579
579
|
|
|
580
|
-
status, detail =
|
|
580
|
+
status, detail = revocation.check_revocation(leaf_cert, issuer=issuer_cert)
|
|
581
581
|
assert status == "REVOKED"
|
|
582
582
|
assert "via CRL" in detail
|
|
583
583
|
|
|
@@ -642,15 +642,15 @@ def test_check_ocsp_soft_fails_on_unparseable_response(monkeypatch):
|
|
|
642
642
|
signatureAlgorithm the strict ASN.1 parser rejects with a ValueError. That
|
|
643
643
|
must not abort the inspection — the revocation check soft-fails instead.
|
|
644
644
|
"""
|
|
645
|
-
from certinspect import
|
|
645
|
+
from certinspect import revocation
|
|
646
646
|
|
|
647
647
|
issuer_cert, leaf_cert = _build_ocsp_pki()
|
|
648
648
|
# Garbage bytes that load_der_ocsp_response cannot parse.
|
|
649
649
|
monkeypatch.setattr(
|
|
650
|
-
|
|
650
|
+
revocation, "_http", lambda url, data=None, timeout=None: b"\x30\x03not-asn1"
|
|
651
651
|
)
|
|
652
652
|
|
|
653
|
-
status, detail =
|
|
653
|
+
status, detail = revocation._check_ocsp(leaf_cert, issuer_cert, timeout=1.0)
|
|
654
654
|
assert status == "UNAVAILABLE"
|
|
655
655
|
assert "could not be parsed" in detail
|
|
656
656
|
|
|
@@ -673,40 +673,40 @@ def _addrinfo(ip: str):
|
|
|
673
673
|
def test_guard_fetch_host_blocks_internal_addresses(monkeypatch, ip):
|
|
674
674
|
"""Loopback, link-local (cloud metadata), unspecified and multicast
|
|
675
675
|
targets from a certificate URL must be refused."""
|
|
676
|
-
from certinspect import
|
|
676
|
+
from certinspect import httpfetch
|
|
677
677
|
|
|
678
|
-
monkeypatch.setattr(
|
|
678
|
+
monkeypatch.setattr(httpfetch.socket, "getaddrinfo", lambda *a, **k: _addrinfo(ip))
|
|
679
679
|
with pytest.raises(ValueError, match="non-routable or internal"):
|
|
680
|
-
|
|
680
|
+
httpfetch._guard_fetch_host("http://danger.example/x")
|
|
681
681
|
|
|
682
682
|
|
|
683
683
|
def test_guard_fetch_host_allows_private_pki(monkeypatch):
|
|
684
684
|
"""An internal PKI on an RFC1918 address must stay reachable."""
|
|
685
|
-
from certinspect import
|
|
685
|
+
from certinspect import httpfetch
|
|
686
686
|
|
|
687
687
|
monkeypatch.setattr(
|
|
688
|
-
|
|
688
|
+
httpfetch.socket, "getaddrinfo", lambda *a, **k: _addrinfo("10.10.0.5")
|
|
689
689
|
)
|
|
690
|
-
assert
|
|
690
|
+
assert httpfetch._guard_fetch_host("http://ocsp.internal.lan/") is None
|
|
691
691
|
|
|
692
692
|
|
|
693
693
|
def test_http_refuses_link_local_metadata_address(monkeypatch):
|
|
694
694
|
"""The guard is wired into _http, so a metadata URL raises before urlopen."""
|
|
695
|
-
from certinspect import
|
|
695
|
+
from certinspect import httpfetch
|
|
696
696
|
|
|
697
697
|
monkeypatch.setattr(
|
|
698
|
-
|
|
698
|
+
httpfetch.socket, "getaddrinfo", lambda *a, **k: _addrinfo("169.254.169.254")
|
|
699
699
|
)
|
|
700
700
|
with pytest.raises(ValueError, match="non-routable or internal"):
|
|
701
|
-
|
|
701
|
+
httpfetch._http("http://metadata.example/ocsp", timeout=1.0)
|
|
702
702
|
|
|
703
703
|
|
|
704
704
|
def test_http_caps_oversized_response(monkeypatch):
|
|
705
705
|
"""A response larger than the cap is rejected instead of read in full."""
|
|
706
|
-
from certinspect import
|
|
706
|
+
from certinspect import httpfetch
|
|
707
707
|
|
|
708
|
-
monkeypatch.setattr(
|
|
709
|
-
monkeypatch.setattr(
|
|
708
|
+
monkeypatch.setattr(httpfetch, "_guard_fetch_host", lambda url: None)
|
|
709
|
+
monkeypatch.setattr(httpfetch, "_MAX_HTTP_RESPONSE_BYTES", 10)
|
|
710
710
|
|
|
711
711
|
class _Resp:
|
|
712
712
|
def __enter__(self):
|
|
@@ -718,9 +718,9 @@ def test_http_caps_oversized_response(monkeypatch):
|
|
|
718
718
|
def read(self, amt=-1):
|
|
719
719
|
return b"x" * amt
|
|
720
720
|
|
|
721
|
-
monkeypatch.setattr(
|
|
721
|
+
monkeypatch.setattr(httpfetch.urllib.request, "urlopen", lambda *a, **k: _Resp())
|
|
722
722
|
with pytest.raises(ValueError, match="exceeds the"):
|
|
723
|
-
|
|
723
|
+
httpfetch._http("http://big.example/crl", timeout=1.0)
|
|
724
724
|
|
|
725
725
|
|
|
726
726
|
# --- OCSP response freshness ------------------------------------------------
|
|
@@ -799,15 +799,15 @@ def test_check_ocsp_good_when_response_is_fresh(monkeypatch):
|
|
|
799
799
|
|
|
800
800
|
from cryptography.x509 import ocsp
|
|
801
801
|
|
|
802
|
-
from certinspect import
|
|
802
|
+
from certinspect import revocation
|
|
803
803
|
|
|
804
804
|
now = datetime.now(timezone.utc)
|
|
805
805
|
issuer_cert, leaf_cert, der = _signed_ocsp(
|
|
806
806
|
ocsp.OCSPCertStatus.GOOD, now - timedelta(hours=1), now + timedelta(days=1)
|
|
807
807
|
)
|
|
808
|
-
monkeypatch.setattr(
|
|
808
|
+
monkeypatch.setattr(revocation, "_http", lambda url, data=None, timeout=None: der)
|
|
809
809
|
|
|
810
|
-
status, _ =
|
|
810
|
+
status, _ = revocation._check_ocsp(leaf_cert, issuer_cert, timeout=1.0)
|
|
811
811
|
assert status == "GOOD"
|
|
812
812
|
|
|
813
813
|
|
|
@@ -818,15 +818,15 @@ def test_check_ocsp_soft_fails_on_stale_response(monkeypatch):
|
|
|
818
818
|
|
|
819
819
|
from cryptography.x509 import ocsp
|
|
820
820
|
|
|
821
|
-
from certinspect import
|
|
821
|
+
from certinspect import revocation
|
|
822
822
|
|
|
823
823
|
now = datetime.now(timezone.utc)
|
|
824
824
|
issuer_cert, leaf_cert, der = _signed_ocsp(
|
|
825
825
|
ocsp.OCSPCertStatus.GOOD, now - timedelta(days=2), now - timedelta(days=1)
|
|
826
826
|
)
|
|
827
|
-
monkeypatch.setattr(
|
|
827
|
+
monkeypatch.setattr(revocation, "_http", lambda url, data=None, timeout=None: der)
|
|
828
828
|
|
|
829
|
-
status, detail =
|
|
829
|
+
status, detail = revocation._check_ocsp(leaf_cert, issuer_cert, timeout=1.0)
|
|
830
830
|
assert status == "UNAVAILABLE"
|
|
831
831
|
assert "stale" in detail
|
|
832
832
|
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
"""Process exit codes and the status-to-code mapping.
|
|
2
|
-
|
|
3
|
-
Single source of truth for the exit codes certinspect returns, so the
|
|
4
|
-
orchestration (cli) and the reporters (formatter) agree on what each number
|
|
5
|
-
means instead of repeating the literals.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from enum import IntEnum
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class ExitCode(IntEnum):
|
|
12
|
-
"""Exit code reflecting the worst certificate state found.
|
|
13
|
-
|
|
14
|
-
Kept distinct from argparse's usage error (2) and the generic runtime
|
|
15
|
-
error (1), which are not certificate states and stay plain integers.
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
OK = 0
|
|
19
|
-
EXPIRING = 3
|
|
20
|
-
INVALID = 4 # expired, critical, not-yet-valid or invalid dates
|
|
21
|
-
HOSTNAME_MISMATCH = 5
|
|
22
|
-
UNTRUSTED_OR_REVOKED = 6
|
|
23
|
-
PIN_MISMATCH = 7
|
|
24
|
-
SAN_MISMATCH = 8
|
|
25
|
-
POLICY = 9
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# Validity status (from certificate_status) mapped to its exit code; the four
|
|
29
|
-
# non-valid date states all share ExitCode.INVALID.
|
|
30
|
-
EXIT_BY_STATUS: dict[str, ExitCode] = {
|
|
31
|
-
"VALID": ExitCode.OK,
|
|
32
|
-
"EXPIRING": ExitCode.EXPIRING,
|
|
33
|
-
"CRITICAL": ExitCode.INVALID,
|
|
34
|
-
"EXPIRED": ExitCode.INVALID,
|
|
35
|
-
"INVALID DATES": ExitCode.INVALID,
|
|
36
|
-
"NOT YET VALID": ExitCode.INVALID,
|
|
37
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|