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.
Files changed (31) hide show
  1. {certinspect-2.3.1/src/certinspect.egg-info → certinspect-2.3.2}/PKG-INFO +1 -1
  2. {certinspect-2.3.1 → certinspect-2.3.2}/pyproject.toml +1 -1
  3. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/__init__.py +1 -1
  4. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/cli.py +1 -1
  5. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/discover.py +1 -1
  6. certinspect-2.3.2/src/certinspect/exit_codes.py +53 -0
  7. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/fetch.py +2 -318
  8. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/formatter.py +7 -6
  9. certinspect-2.3.2/src/certinspect/httpfetch.py +84 -0
  10. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/parser.py +8 -7
  11. certinspect-2.3.2/src/certinspect/revocation.py +257 -0
  12. {certinspect-2.3.1 → certinspect-2.3.2/src/certinspect.egg-info}/PKG-INFO +1 -1
  13. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect.egg-info/SOURCES.txt +2 -0
  14. {certinspect-2.3.1 → certinspect-2.3.2}/tests/test_fetch.py +47 -47
  15. certinspect-2.3.1/src/certinspect/exit_codes.py +0 -37
  16. {certinspect-2.3.1 → certinspect-2.3.2}/LICENSE +0 -0
  17. {certinspect-2.3.1 → certinspect-2.3.2}/README.md +0 -0
  18. {certinspect-2.3.1 → certinspect-2.3.2}/setup.cfg +0 -0
  19. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/args.py +0 -0
  20. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/completion.py +0 -0
  21. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/config.py +0 -0
  22. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/models.py +0 -0
  23. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect/render.py +0 -0
  24. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect.egg-info/dependency_links.txt +0 -0
  25. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect.egg-info/entry_points.txt +0 -0
  26. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect.egg-info/requires.txt +0 -0
  27. {certinspect-2.3.1 → certinspect-2.3.2}/src/certinspect.egg-info/top_level.txt +0 -0
  28. {certinspect-2.3.1 → certinspect-2.3.2}/tests/test_cli.py +0 -0
  29. {certinspect-2.3.1 → certinspect-2.3.2}/tests/test_discover.py +0 -0
  30. {certinspect-2.3.1 → certinspect-2.3.2}/tests/test_formatter.py +0 -0
  31. {certinspect-2.3.1 → certinspect-2.3.2}/tests/test_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certinspect
3
- Version: 2.3.1
3
+ Version: 2.3.2
4
4
  Summary: Command-line TLS certificate inspector
5
5
  Author-email: Michele Angrisano <michele.angrisano@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "certinspect"
3
- version = "2.3.1"
3
+ version = "2.3.2"
4
4
  description = "Command-line TLS certificate inspector"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """certinspect — command-line TLS certificate inspector."""
2
2
 
3
- __version__ = "2.3.1"
3
+ __version__ = "2.3.2"
@@ -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.fetch import _http
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.hazmat.primitives import hashes, serialization
24
- from cryptography.x509 import ocsp, verification
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 == "CRITICAL":
107
+ if status == Status.CRITICAL:
108
108
  lines.append("")
109
109
  lines.append(f"CRITICAL: certificate expires in {days} days")
110
- elif status == "NOT YET VALID":
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 == "CRITICAL":
372
+ if status == Status.CRITICAL:
373
373
  counts["critical"] += 1
374
- elif status == "NOT YET VALID":
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) in ("EXPIRED", "INVALID DATES")
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
- ) -> str:
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 "INVALID DATES"
419
+ return Status.INVALID_DATES
419
420
  if info["not_valid_before"] > datetime.now(timezone.utc):
420
- return "NOT YET VALID"
421
+ return Status.NOT_YET_VALID
421
422
  days = info["days_to_expire"]
422
423
  if days < 0:
423
- return "EXPIRED"
424
+ return Status.EXPIRED
424
425
  if critical_days is not None and days < critical_days:
425
- return "CRITICAL"
426
+ return Status.CRITICAL
426
427
  if days < warn_days:
427
- return "EXPIRING"
428
- return "VALID"
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certinspect
3
- Version: 2.3.1
3
+ Version: 2.3.2
4
4
  Summary: Command-line TLS certificate inspector
5
5
  Author-email: Michele Angrisano <michele.angrisano@gmail.com>
6
6
  License-Expression: MIT
@@ -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.fetch import check_revocation
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.fetch import _http
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.fetch import _crl_urls
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 fetch
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(fetch, "_http", lambda url, timeout: der_crl)
471
+ monkeypatch.setattr(revocation, "_http", lambda url, timeout: der_crl)
472
472
 
473
- status, detail = fetch._check_crl(leaf_cert, issuer_cert, timeout=1.0)
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 fetch
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(fetch, "_http", lambda url, timeout: der_crl)
485
+ monkeypatch.setattr(revocation, "_http", lambda url, timeout: der_crl)
486
486
 
487
- status, detail = fetch._check_crl(leaf_cert, issuer_cert, timeout=1.0)
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 fetch
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(fetch, "_http", lambda url, timeout: der_crl)
505
+ monkeypatch.setattr(revocation, "_http", lambda url, timeout: der_crl)
506
506
 
507
- status, detail = fetch._check_crl(leaf_cert, issuer_cert, timeout=1.0)
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 fetch
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(fetch, "_http", lambda url, timeout: der_crl)
525
+ monkeypatch.setattr(revocation, "_http", lambda url, timeout: der_crl)
526
526
 
527
- status, detail = fetch._check_crl(leaf_cert, issuer_cert, timeout=1.0)
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 fetch
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(fetch, "_http", lambda url, timeout: der_crl)
545
+ monkeypatch.setattr(revocation, "_http", lambda url, timeout: der_crl)
546
546
 
547
- status, detail = fetch._check_crl(leaf_cert, issuer_cert, timeout=1.0)
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 fetch
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(fetch, "_http", lambda url, timeout: der_crl)
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, _ = fetch._check_crl(leaf_cert, wrong_issuer, timeout=1.0)
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 fetch
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(fetch, "_http", lambda url, timeout: der_crl)
578
+ monkeypatch.setattr(revocation, "_http", lambda url, timeout: der_crl)
579
579
 
580
- status, detail = fetch.check_revocation(leaf_cert, issuer=issuer_cert)
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 fetch
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
- fetch, "_http", lambda url, data=None, timeout=None: b"\x30\x03not-asn1"
650
+ revocation, "_http", lambda url, data=None, timeout=None: b"\x30\x03not-asn1"
651
651
  )
652
652
 
653
- status, detail = fetch._check_ocsp(leaf_cert, issuer_cert, timeout=1.0)
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 fetch
676
+ from certinspect import httpfetch
677
677
 
678
- monkeypatch.setattr(fetch.socket, "getaddrinfo", lambda *a, **k: _addrinfo(ip))
678
+ monkeypatch.setattr(httpfetch.socket, "getaddrinfo", lambda *a, **k: _addrinfo(ip))
679
679
  with pytest.raises(ValueError, match="non-routable or internal"):
680
- fetch._guard_fetch_host("http://danger.example/x")
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 fetch
685
+ from certinspect import httpfetch
686
686
 
687
687
  monkeypatch.setattr(
688
- fetch.socket, "getaddrinfo", lambda *a, **k: _addrinfo("10.10.0.5")
688
+ httpfetch.socket, "getaddrinfo", lambda *a, **k: _addrinfo("10.10.0.5")
689
689
  )
690
- assert fetch._guard_fetch_host("http://ocsp.internal.lan/") is None
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 fetch
695
+ from certinspect import httpfetch
696
696
 
697
697
  monkeypatch.setattr(
698
- fetch.socket, "getaddrinfo", lambda *a, **k: _addrinfo("169.254.169.254")
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
- fetch._http("http://metadata.example/ocsp", timeout=1.0)
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 fetch
706
+ from certinspect import httpfetch
707
707
 
708
- monkeypatch.setattr(fetch, "_guard_fetch_host", lambda url: None)
709
- monkeypatch.setattr(fetch, "_MAX_HTTP_RESPONSE_BYTES", 10)
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(fetch.urllib.request, "urlopen", lambda *a, **k: _Resp())
721
+ monkeypatch.setattr(httpfetch.urllib.request, "urlopen", lambda *a, **k: _Resp())
722
722
  with pytest.raises(ValueError, match="exceeds the"):
723
- fetch._http("http://big.example/crl", timeout=1.0)
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 fetch
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(fetch, "_http", lambda url, data=None, timeout=None: der)
808
+ monkeypatch.setattr(revocation, "_http", lambda url, data=None, timeout=None: der)
809
809
 
810
- status, _ = fetch._check_ocsp(leaf_cert, issuer_cert, timeout=1.0)
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 fetch
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(fetch, "_http", lambda url, data=None, timeout=None: der)
827
+ monkeypatch.setattr(revocation, "_http", lambda url, data=None, timeout=None: der)
828
828
 
829
- status, detail = fetch._check_ocsp(leaf_cert, issuer_cert, timeout=1.0)
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