certgraph 0.3.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. certgraph-0.5.0/PKG-INFO +179 -0
  2. certgraph-0.5.0/README.md +135 -0
  3. {certgraph-0.3.0 → certgraph-0.5.0}/pyproject.toml +6 -1
  4. {certgraph-0.3.0 → certgraph-0.5.0}/src/certgraph/certgraph.py +94 -7
  5. certgraph-0.5.0/src/certgraph.egg-info/PKG-INFO +179 -0
  6. {certgraph-0.3.0 → certgraph-0.5.0}/src/certgraph.egg-info/SOURCES.txt +1 -0
  7. {certgraph-0.3.0 → certgraph-0.5.0}/src/certgraph.egg-info/requires.txt +5 -0
  8. certgraph-0.5.0/tests/test_certificate_invalid_at.py +39 -0
  9. {certgraph-0.3.0 → certgraph-0.5.0}/tests/test_export_dot.py +53 -2
  10. certgraph-0.3.0/PKG-INFO +0 -42
  11. certgraph-0.3.0/README.md +0 -2
  12. certgraph-0.3.0/src/certgraph.egg-info/PKG-INFO +0 -42
  13. {certgraph-0.3.0 → certgraph-0.5.0}/LICENSE +0 -0
  14. {certgraph-0.3.0 → certgraph-0.5.0}/setup.cfg +0 -0
  15. {certgraph-0.3.0 → certgraph-0.5.0}/src/certgraph/__init__.py +0 -0
  16. {certgraph-0.3.0 → certgraph-0.5.0}/src/certgraph.egg-info/dependency_links.txt +0 -0
  17. {certgraph-0.3.0 → certgraph-0.5.0}/src/certgraph.egg-info/top_level.txt +0 -0
  18. {certgraph-0.3.0 → certgraph-0.5.0}/tests/test_clear.py +0 -0
  19. {certgraph-0.3.0 → certgraph-0.5.0}/tests/test_fingerprint_search.py +0 -0
  20. {certgraph-0.3.0 → certgraph-0.5.0}/tests/test_get_certificate.py +0 -0
  21. {certgraph-0.3.0 → certgraph-0.5.0}/tests/test_get_child_fingerprints.py +0 -0
  22. {certgraph-0.3.0 → certgraph-0.5.0}/tests/test_get_issuer_fingerprint.py +0 -0
  23. {certgraph-0.3.0 → certgraph-0.5.0}/tests/test_graph.py +0 -0
  24. {certgraph-0.3.0 → certgraph-0.5.0}/tests/test_import_certificates.py +0 -0
  25. {certgraph-0.3.0 → certgraph-0.5.0}/tests/test_remove_certificate.py +0 -0
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: certgraph
3
+ Version: 0.5.0
4
+ Summary: Utility for exploring and mapping X509 certificate chains.
5
+ Author: Chris Adshead
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Chris Adshead
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Requires-Python: >=3.12
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: cryptography
32
+ Requires-Dist: networkx
33
+ Requires-Dist: pydot
34
+ Requires-Dist: rapidfuzz
35
+ Provides-Extra: dev
36
+ Requires-Dist: black; extra == "dev"
37
+ Requires-Dist: pytest; extra == "dev"
38
+ Requires-Dist: pytest-cov; extra == "dev"
39
+ Provides-Extra: docs
40
+ Requires-Dist: sphinx; extra == "docs"
41
+ Requires-Dist: sphinx-autodoc-typehints; extra == "docs"
42
+ Requires-Dist: sphinx-rtd-theme; extra == "docs"
43
+ Dynamic: license-file
44
+
45
+ # certgraph
46
+
47
+ [![PyPI version](https://img.shields.io/pypi/v/certgraph)](https://pypi.org/project/certgraph/)
48
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
49
+
50
+ Python package for creating, exploring and displaying directed graphs of X.509 certificates.
51
+
52
+ Build a directed graph by importing PEM/DER/`x509.Certificate` objects using subject/issuer relationships. Explore the
53
+ generated digraph with the built in functions and then export the graph rendered in the DOT language.
54
+
55
+ Basic example graph generated to DOT source with `certgraph.export_dot()` and then rendered to SVG with `dot` command line tool:
56
+
57
+ ![Basic example certificate graph](readme_basic_example.svg)
58
+
59
+ ## Features
60
+
61
+ * Import certificates from:
62
+ * PEM strings (individually or whole PKIs)
63
+ * DER bytes
64
+ * `cryptography.x509.Certificate` objects
65
+ * Dynamically built directed graphs from imported certificates
66
+ * Imported certificates fuzzy-searchable based on distinguised name
67
+ * Issuer and child certificates walkable to follow chains
68
+ * Identify certificates not valid at a given time
69
+ * Export DOT language graphs, with:
70
+ * RFC4514 distinguised names
71
+ * Valid time range
72
+ * Node highlighting for invalid certificates
73
+
74
+ **Note on _fingerprints_**: This code relies extensively on certificate _fingerprints_. This is a SHA256 hash of the public bytes in the certificate. Nodes in the graph are stored with the fingerprint as their key and are indexed using the fingerprint value. While this initially seems awkward, it prevents malformed (or maliciously designed) distinguished names or serial numbers from clashing with other imported certificates. You can use `certgraph.fingerprint_from_distinguished_name()` to map from distinguised names to fingerprints for ease of use.
75
+
76
+ ## Installation
77
+
78
+ ### PyPI
79
+
80
+ Install the latest version of the package from PyPI via `pip` with:
81
+
82
+ ```bash
83
+ pip install certgraph
84
+ ```
85
+
86
+ For development dependencies, install with:
87
+
88
+ ```bash
89
+ pip install certgraph[dev]
90
+ ```
91
+
92
+ ### Optional
93
+
94
+ * `dot` command line tool
95
+ * Used to render generated DOT language graphs into PNG/SVG/etc.
96
+ * E.g. `dot -Tsvg <generated_file>.dot -o graph.svg`
97
+ * Alternatively can use online tools.
98
+
99
+ ## Basic Examples
100
+
101
+ Import the `certgraph` class from the module:
102
+
103
+ ```python
104
+ >>> from certgraph import certgraph as cg
105
+ ```
106
+
107
+ Load some varied data for later examples:
108
+
109
+ ```python
110
+ >>> with open("path/to/cert.pem") as f:
111
+ >>> pem_data = f.read() # 1 cert
112
+ >>> with open("path/to/pki.pem") as f:
113
+ >>> pki_data = f.read() # 3 certs in 1 PEM file
114
+ >>> with open("path/to/cert.der", "rb") as f:
115
+ >>> der_data = f.read() # 1 cert
116
+ ```
117
+
118
+ Import a PEM file with 1 certificate in it:
119
+
120
+ ```python
121
+ >>> graph = cg().import_certificates(pem_data)
122
+ >>> len(graph)
123
+ 1
124
+ ```
125
+
126
+ Import mixed PEM/DER dataset, including PEMs with PKIs, using function chaining:
127
+
128
+ ```python
129
+ >>> graph = (
130
+ >>> cg()
131
+ >>> .import_certificates(pem_data)
132
+ >>> .import_certificates(pki_data)
133
+ >>> .import_certificates(der_data)
134
+ >>> )
135
+ >>> len(graph)
136
+ 5
137
+ ```
138
+
139
+ Get an imported certificate (as `cryptography.x509.Certificate`) by using a fingerprint. The fingerprint of the desired certificate can be retrieved using a fuzzy-searching function based on the distinguised name:
140
+
141
+ ```python
142
+ >>> dn = "CN=f3ec133b-4"
143
+ >>> fingerprint = cg.fingerprint_from_distinguished_name(dn) # Fuzzy-search the distinguised name in the list of imported certificates for the best match
144
+ >>> print(fingerprint)
145
+ "77c4b5b12b79887b8c709df826cff89a7268071593de5cc84053b8c00302b2fb"
146
+ >>> cert = cg.get_certificate(fingerprint) # Get the certificate associated with the fingerprint
147
+ >>> print(cert.subject.rfc4514_string())
148
+ "CN=f3ec133b-4"
149
+ ```
150
+
151
+ Get predecessor (issuer) and successor (child) fingerprints from a certificate fingerprint:
152
+
153
+ ```python
154
+ >>> fingerprint = "77c4b5b12b79887b8c709df826cff89a7268071593de5cc84053b8c00302b2fb"
155
+ >>> cg.get_issuer_fingerprint(fingerprint)
156
+ "965e8d87f96ccf564726ff5e97ea4d6b18731d9b762ef9510479f628bc85837e"
157
+ >>> cg.get_child_certificates(fingerprint)
158
+ ["3cf372288714b52298222bb1f8cf51dce3f1e60147ec53f9ee3dc28e8766c734", "2caf1b256bcbb327aea8d1442ba12e0fc6582b3c2980ee9f31d4ae3a62f62673"]
159
+ ```
160
+
161
+ Export graph to DOT language and write to a file, where it can be rendered with the `dot` command line tool.
162
+
163
+ ```python
164
+ >>> with open("path/to/output.dot", "w") as f:
165
+ >>> dot_str = cg.export_dot()
166
+ >>> f.write(dot_str)
167
+ ```
168
+
169
+ See more complex examples in the `examples` directory of the repository (**WIP**).
170
+
171
+ ## Documentation
172
+
173
+ Documentation for the package uses Sphinx with the read-the-docs template. Source can be found in `docs`.
174
+
175
+ To build the source, execute the following from the repo root:
176
+
177
+ ```bash
178
+ python -m sphinx -b html docs/source docs/build
179
+ ```
@@ -0,0 +1,135 @@
1
+ # certgraph
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/certgraph)](https://pypi.org/project/certgraph/)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ Python package for creating, exploring and displaying directed graphs of X.509 certificates.
7
+
8
+ Build a directed graph by importing PEM/DER/`x509.Certificate` objects using subject/issuer relationships. Explore the
9
+ generated digraph with the built in functions and then export the graph rendered in the DOT language.
10
+
11
+ Basic example graph generated to DOT source with `certgraph.export_dot()` and then rendered to SVG with `dot` command line tool:
12
+
13
+ ![Basic example certificate graph](readme_basic_example.svg)
14
+
15
+ ## Features
16
+
17
+ * Import certificates from:
18
+ * PEM strings (individually or whole PKIs)
19
+ * DER bytes
20
+ * `cryptography.x509.Certificate` objects
21
+ * Dynamically built directed graphs from imported certificates
22
+ * Imported certificates fuzzy-searchable based on distinguised name
23
+ * Issuer and child certificates walkable to follow chains
24
+ * Identify certificates not valid at a given time
25
+ * Export DOT language graphs, with:
26
+ * RFC4514 distinguised names
27
+ * Valid time range
28
+ * Node highlighting for invalid certificates
29
+
30
+ **Note on _fingerprints_**: This code relies extensively on certificate _fingerprints_. This is a SHA256 hash of the public bytes in the certificate. Nodes in the graph are stored with the fingerprint as their key and are indexed using the fingerprint value. While this initially seems awkward, it prevents malformed (or maliciously designed) distinguished names or serial numbers from clashing with other imported certificates. You can use `certgraph.fingerprint_from_distinguished_name()` to map from distinguised names to fingerprints for ease of use.
31
+
32
+ ## Installation
33
+
34
+ ### PyPI
35
+
36
+ Install the latest version of the package from PyPI via `pip` with:
37
+
38
+ ```bash
39
+ pip install certgraph
40
+ ```
41
+
42
+ For development dependencies, install with:
43
+
44
+ ```bash
45
+ pip install certgraph[dev]
46
+ ```
47
+
48
+ ### Optional
49
+
50
+ * `dot` command line tool
51
+ * Used to render generated DOT language graphs into PNG/SVG/etc.
52
+ * E.g. `dot -Tsvg <generated_file>.dot -o graph.svg`
53
+ * Alternatively can use online tools.
54
+
55
+ ## Basic Examples
56
+
57
+ Import the `certgraph` class from the module:
58
+
59
+ ```python
60
+ >>> from certgraph import certgraph as cg
61
+ ```
62
+
63
+ Load some varied data for later examples:
64
+
65
+ ```python
66
+ >>> with open("path/to/cert.pem") as f:
67
+ >>> pem_data = f.read() # 1 cert
68
+ >>> with open("path/to/pki.pem") as f:
69
+ >>> pki_data = f.read() # 3 certs in 1 PEM file
70
+ >>> with open("path/to/cert.der", "rb") as f:
71
+ >>> der_data = f.read() # 1 cert
72
+ ```
73
+
74
+ Import a PEM file with 1 certificate in it:
75
+
76
+ ```python
77
+ >>> graph = cg().import_certificates(pem_data)
78
+ >>> len(graph)
79
+ 1
80
+ ```
81
+
82
+ Import mixed PEM/DER dataset, including PEMs with PKIs, using function chaining:
83
+
84
+ ```python
85
+ >>> graph = (
86
+ >>> cg()
87
+ >>> .import_certificates(pem_data)
88
+ >>> .import_certificates(pki_data)
89
+ >>> .import_certificates(der_data)
90
+ >>> )
91
+ >>> len(graph)
92
+ 5
93
+ ```
94
+
95
+ Get an imported certificate (as `cryptography.x509.Certificate`) by using a fingerprint. The fingerprint of the desired certificate can be retrieved using a fuzzy-searching function based on the distinguised name:
96
+
97
+ ```python
98
+ >>> dn = "CN=f3ec133b-4"
99
+ >>> fingerprint = cg.fingerprint_from_distinguished_name(dn) # Fuzzy-search the distinguised name in the list of imported certificates for the best match
100
+ >>> print(fingerprint)
101
+ "77c4b5b12b79887b8c709df826cff89a7268071593de5cc84053b8c00302b2fb"
102
+ >>> cert = cg.get_certificate(fingerprint) # Get the certificate associated with the fingerprint
103
+ >>> print(cert.subject.rfc4514_string())
104
+ "CN=f3ec133b-4"
105
+ ```
106
+
107
+ Get predecessor (issuer) and successor (child) fingerprints from a certificate fingerprint:
108
+
109
+ ```python
110
+ >>> fingerprint = "77c4b5b12b79887b8c709df826cff89a7268071593de5cc84053b8c00302b2fb"
111
+ >>> cg.get_issuer_fingerprint(fingerprint)
112
+ "965e8d87f96ccf564726ff5e97ea4d6b18731d9b762ef9510479f628bc85837e"
113
+ >>> cg.get_child_certificates(fingerprint)
114
+ ["3cf372288714b52298222bb1f8cf51dce3f1e60147ec53f9ee3dc28e8766c734", "2caf1b256bcbb327aea8d1442ba12e0fc6582b3c2980ee9f31d4ae3a62f62673"]
115
+ ```
116
+
117
+ Export graph to DOT language and write to a file, where it can be rendered with the `dot` command line tool.
118
+
119
+ ```python
120
+ >>> with open("path/to/output.dot", "w") as f:
121
+ >>> dot_str = cg.export_dot()
122
+ >>> f.write(dot_str)
123
+ ```
124
+
125
+ See more complex examples in the `examples` directory of the repository (**WIP**).
126
+
127
+ ## Documentation
128
+
129
+ Documentation for the package uses Sphinx with the read-the-docs template. Source can be found in `docs`.
130
+
131
+ To build the source, execute the following from the repo root:
132
+
133
+ ```bash
134
+ python -m sphinx -b html docs/source docs/build
135
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "certgraph"
7
- version = "0.3.0"
7
+ version = "0.5.0"
8
8
  description = "Utility for exploring and mapping X509 certificate chains."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -25,6 +25,11 @@ dev = [
25
25
  "pytest",
26
26
  "pytest-cov",
27
27
  ]
28
+ docs = [
29
+ "sphinx",
30
+ "sphinx-autodoc-typehints",
31
+ "sphinx-rtd-theme",
32
+ ]
28
33
 
29
34
  [tool.setuptools.packages.find]
30
35
  where = ["src"]
@@ -1,6 +1,10 @@
1
+ from __future__ import annotations
2
+
1
3
  from cryptography import x509
2
4
  from cryptography.hazmat.primitives import hashes
5
+ from datetime import datetime, timezone
3
6
  import networkx as nx
7
+ import pydot
4
8
  from rapidfuzz import fuzz
5
9
 
6
10
 
@@ -12,6 +16,10 @@ class certgraph:
12
16
  self._certlist: set[x509.Certificate] = []
13
17
  self._graph = nx.DiGraph()
14
18
 
19
+ def __len__(self) -> int:
20
+ """Get number of certificates imported."""
21
+ return len(self._certlist)
22
+
15
23
  def import_certificates(
16
24
  self,
17
25
  certificates: (
@@ -88,10 +96,43 @@ class certgraph:
88
96
  """
89
97
  return [f"{edge[0][:8]} -> {edge[1][:8]}" for edge in self._graph.edges()]
90
98
 
91
- def export_dot(self) -> str:
99
+ def _generate_dot_legend(self) -> pydot.Cluster:
100
+ """Generate the optional legend for the dot output."""
101
+ cluster = pydot.Cluster("legend", label="Legend", style="dashed")
102
+
103
+ nodes = [
104
+ pydot.Node(
105
+ "legend_valid",
106
+ label="Valid",
107
+ shape="box",
108
+ style="filled",
109
+ fillcolor="white",
110
+ ),
111
+ pydot.Node(
112
+ "legend_invalid",
113
+ label="Invalid",
114
+ shape="box",
115
+ style="filled",
116
+ fillcolor="tomato",
117
+ ),
118
+ ]
119
+
120
+ for i, n in enumerate(nodes):
121
+ cluster.add_node(n)
122
+
123
+ # Add invisible edge to force the nodes to appear on different ranks (i.e. top-bottom direction)
124
+ if i != len(nodes) - 1:
125
+ cluster.add_edge(pydot.Edge(nodes[i], nodes[i + 1], style="invis"))
126
+
127
+ return cluster
128
+
129
+ def export_dot(self, add_legend: bool = True) -> str:
92
130
  """
93
131
  Export the current certificate digraph in the dot language.
94
132
 
133
+ Args:
134
+ add_legend: Add a legend with the node colours to the graph output. Default is True.
135
+
95
136
  Returns:
96
137
  The certificate digraph rendered as a dot language string.
97
138
  """
@@ -100,15 +141,31 @@ class certgraph:
100
141
  for fingerprint, data in self._graph.nodes(data=True):
101
142
  cert: x509.Certificate = data["certificate"]
102
143
  validity_time_str = f"{cert.not_valid_before_utc.isoformat()} → {cert.not_valid_after_utc.isoformat()}"
144
+ invalid = self.certificate_invalid_at(fingerprint)
103
145
 
104
146
  label = f"DN: {cert.subject.rfc4514_string()}"
105
147
  label += f"\nValid: {validity_time_str}"
106
148
 
107
- dot_graph.add_node(fingerprint, label=label, shape="box")
149
+ if invalid:
150
+ node_color = "tomato"
151
+ else:
152
+ node_color = "white"
153
+
154
+ dot_graph.add_node(
155
+ fingerprint,
156
+ label=label,
157
+ shape="box",
158
+ style="filled",
159
+ fillcolor=node_color,
160
+ )
108
161
 
109
162
  dot_graph.add_edges_from(self._graph.edges())
163
+ dot_out = nx.drawing.nx_pydot.to_pydot(dot_graph)
164
+
165
+ if add_legend:
166
+ dot_out.add_subgraph(self._generate_dot_legend())
110
167
 
111
- return nx.drawing.nx_pydot.to_pydot(dot_graph).to_string()
168
+ return dot_out.to_string()
112
169
 
113
170
  def clear(self) -> certgraph:
114
171
  """
@@ -172,7 +229,7 @@ class certgraph:
172
229
 
173
230
  Args:
174
231
  fingerprint: Fingerprint of the certificate to get the issuer of.
175
-
232
+
176
233
  Returns:
177
234
  The fingerprint of the issuing certificate or None if not present in the digraph.
178
235
 
@@ -180,7 +237,9 @@ class certgraph:
180
237
  ValueError: If certificate with the supplied fingerprint isn't in the digraph.
181
238
  """
182
239
  if fingerprint not in self._graph.nodes:
183
- raise ValueError(f"Fingerprint supplied doesn't match any imported certificate.")
240
+ raise ValueError(
241
+ f"Fingerprint supplied doesn't match any imported certificate."
242
+ )
184
243
 
185
244
  return next(self._graph.predecessors(fingerprint), None)
186
245
 
@@ -190,7 +249,7 @@ class certgraph:
190
249
 
191
250
  Args:
192
251
  fingerprint: Fingerprint of the certificate to get the child certificates of.
193
-
252
+
194
253
  Returns:
195
254
  The list of fingerprints belonging to child certificates.
196
255
 
@@ -198,7 +257,9 @@ class certgraph:
198
257
  ValueError: If certificate with the supplied fingerprint isn't in the digraph.
199
258
  """
200
259
  if fingerprint not in self._graph.nodes:
201
- raise ValueError(f"Fingerprint supplied doesn't match any imported certificate.")
260
+ raise ValueError(
261
+ f"Fingerprint supplied doesn't match any imported certificate."
262
+ )
202
263
 
203
264
  return list(self._graph.successors(fingerprint))
204
265
 
@@ -217,3 +278,29 @@ class certgraph:
217
278
  if cert in self._certlist:
218
279
  self._certlist.remove(cert)
219
280
 
281
+ def certificate_invalid_at(
282
+ self, fingerprint: str, at_time: datetime | None = None
283
+ ) -> bool:
284
+ """
285
+ Check if an imported certificate is outside its validity period - either not yet valid or expired.
286
+
287
+ Args:
288
+ fingerprint: Fingerprint of the certificate to check.
289
+ at_time: When the certificate validity will be checked against. None value defaults to now (UTC).
290
+
291
+ Returns:
292
+ True if the certificate is not yet valid or expired at the given time, otherwise false.
293
+
294
+ Raises:
295
+ ValueError: If certificate with the supplied fingerprint isn't in the digraph.
296
+ """
297
+ if fingerprint not in self._graph.nodes:
298
+ raise ValueError(
299
+ f"Fingerprint supplied doesn't match any imported certificate."
300
+ )
301
+
302
+ if at_time is None:
303
+ at_time = datetime.now(timezone.utc)
304
+
305
+ cert = self.get_certificate(fingerprint)
306
+ return at_time < cert.not_valid_before_utc or at_time > cert.not_valid_after_utc
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: certgraph
3
+ Version: 0.5.0
4
+ Summary: Utility for exploring and mapping X509 certificate chains.
5
+ Author: Chris Adshead
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Chris Adshead
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Requires-Python: >=3.12
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: cryptography
32
+ Requires-Dist: networkx
33
+ Requires-Dist: pydot
34
+ Requires-Dist: rapidfuzz
35
+ Provides-Extra: dev
36
+ Requires-Dist: black; extra == "dev"
37
+ Requires-Dist: pytest; extra == "dev"
38
+ Requires-Dist: pytest-cov; extra == "dev"
39
+ Provides-Extra: docs
40
+ Requires-Dist: sphinx; extra == "docs"
41
+ Requires-Dist: sphinx-autodoc-typehints; extra == "docs"
42
+ Requires-Dist: sphinx-rtd-theme; extra == "docs"
43
+ Dynamic: license-file
44
+
45
+ # certgraph
46
+
47
+ [![PyPI version](https://img.shields.io/pypi/v/certgraph)](https://pypi.org/project/certgraph/)
48
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
49
+
50
+ Python package for creating, exploring and displaying directed graphs of X.509 certificates.
51
+
52
+ Build a directed graph by importing PEM/DER/`x509.Certificate` objects using subject/issuer relationships. Explore the
53
+ generated digraph with the built in functions and then export the graph rendered in the DOT language.
54
+
55
+ Basic example graph generated to DOT source with `certgraph.export_dot()` and then rendered to SVG with `dot` command line tool:
56
+
57
+ ![Basic example certificate graph](readme_basic_example.svg)
58
+
59
+ ## Features
60
+
61
+ * Import certificates from:
62
+ * PEM strings (individually or whole PKIs)
63
+ * DER bytes
64
+ * `cryptography.x509.Certificate` objects
65
+ * Dynamically built directed graphs from imported certificates
66
+ * Imported certificates fuzzy-searchable based on distinguised name
67
+ * Issuer and child certificates walkable to follow chains
68
+ * Identify certificates not valid at a given time
69
+ * Export DOT language graphs, with:
70
+ * RFC4514 distinguised names
71
+ * Valid time range
72
+ * Node highlighting for invalid certificates
73
+
74
+ **Note on _fingerprints_**: This code relies extensively on certificate _fingerprints_. This is a SHA256 hash of the public bytes in the certificate. Nodes in the graph are stored with the fingerprint as their key and are indexed using the fingerprint value. While this initially seems awkward, it prevents malformed (or maliciously designed) distinguished names or serial numbers from clashing with other imported certificates. You can use `certgraph.fingerprint_from_distinguished_name()` to map from distinguised names to fingerprints for ease of use.
75
+
76
+ ## Installation
77
+
78
+ ### PyPI
79
+
80
+ Install the latest version of the package from PyPI via `pip` with:
81
+
82
+ ```bash
83
+ pip install certgraph
84
+ ```
85
+
86
+ For development dependencies, install with:
87
+
88
+ ```bash
89
+ pip install certgraph[dev]
90
+ ```
91
+
92
+ ### Optional
93
+
94
+ * `dot` command line tool
95
+ * Used to render generated DOT language graphs into PNG/SVG/etc.
96
+ * E.g. `dot -Tsvg <generated_file>.dot -o graph.svg`
97
+ * Alternatively can use online tools.
98
+
99
+ ## Basic Examples
100
+
101
+ Import the `certgraph` class from the module:
102
+
103
+ ```python
104
+ >>> from certgraph import certgraph as cg
105
+ ```
106
+
107
+ Load some varied data for later examples:
108
+
109
+ ```python
110
+ >>> with open("path/to/cert.pem") as f:
111
+ >>> pem_data = f.read() # 1 cert
112
+ >>> with open("path/to/pki.pem") as f:
113
+ >>> pki_data = f.read() # 3 certs in 1 PEM file
114
+ >>> with open("path/to/cert.der", "rb") as f:
115
+ >>> der_data = f.read() # 1 cert
116
+ ```
117
+
118
+ Import a PEM file with 1 certificate in it:
119
+
120
+ ```python
121
+ >>> graph = cg().import_certificates(pem_data)
122
+ >>> len(graph)
123
+ 1
124
+ ```
125
+
126
+ Import mixed PEM/DER dataset, including PEMs with PKIs, using function chaining:
127
+
128
+ ```python
129
+ >>> graph = (
130
+ >>> cg()
131
+ >>> .import_certificates(pem_data)
132
+ >>> .import_certificates(pki_data)
133
+ >>> .import_certificates(der_data)
134
+ >>> )
135
+ >>> len(graph)
136
+ 5
137
+ ```
138
+
139
+ Get an imported certificate (as `cryptography.x509.Certificate`) by using a fingerprint. The fingerprint of the desired certificate can be retrieved using a fuzzy-searching function based on the distinguised name:
140
+
141
+ ```python
142
+ >>> dn = "CN=f3ec133b-4"
143
+ >>> fingerprint = cg.fingerprint_from_distinguished_name(dn) # Fuzzy-search the distinguised name in the list of imported certificates for the best match
144
+ >>> print(fingerprint)
145
+ "77c4b5b12b79887b8c709df826cff89a7268071593de5cc84053b8c00302b2fb"
146
+ >>> cert = cg.get_certificate(fingerprint) # Get the certificate associated with the fingerprint
147
+ >>> print(cert.subject.rfc4514_string())
148
+ "CN=f3ec133b-4"
149
+ ```
150
+
151
+ Get predecessor (issuer) and successor (child) fingerprints from a certificate fingerprint:
152
+
153
+ ```python
154
+ >>> fingerprint = "77c4b5b12b79887b8c709df826cff89a7268071593de5cc84053b8c00302b2fb"
155
+ >>> cg.get_issuer_fingerprint(fingerprint)
156
+ "965e8d87f96ccf564726ff5e97ea4d6b18731d9b762ef9510479f628bc85837e"
157
+ >>> cg.get_child_certificates(fingerprint)
158
+ ["3cf372288714b52298222bb1f8cf51dce3f1e60147ec53f9ee3dc28e8766c734", "2caf1b256bcbb327aea8d1442ba12e0fc6582b3c2980ee9f31d4ae3a62f62673"]
159
+ ```
160
+
161
+ Export graph to DOT language and write to a file, where it can be rendered with the `dot` command line tool.
162
+
163
+ ```python
164
+ >>> with open("path/to/output.dot", "w") as f:
165
+ >>> dot_str = cg.export_dot()
166
+ >>> f.write(dot_str)
167
+ ```
168
+
169
+ See more complex examples in the `examples` directory of the repository (**WIP**).
170
+
171
+ ## Documentation
172
+
173
+ Documentation for the package uses Sphinx with the read-the-docs template. Source can be found in `docs`.
174
+
175
+ To build the source, execute the following from the repo root:
176
+
177
+ ```bash
178
+ python -m sphinx -b html docs/source docs/build
179
+ ```
@@ -8,6 +8,7 @@ src/certgraph.egg-info/SOURCES.txt
8
8
  src/certgraph.egg-info/dependency_links.txt
9
9
  src/certgraph.egg-info/requires.txt
10
10
  src/certgraph.egg-info/top_level.txt
11
+ tests/test_certificate_invalid_at.py
11
12
  tests/test_clear.py
12
13
  tests/test_export_dot.py
13
14
  tests/test_fingerprint_search.py
@@ -7,3 +7,8 @@ rapidfuzz
7
7
  black
8
8
  pytest
9
9
  pytest-cov
10
+
11
+ [docs]
12
+ sphinx
13
+ sphinx-autodoc-typehints
14
+ sphinx-rtd-theme
@@ -0,0 +1,39 @@
1
+ from datetime import timedelta
2
+
3
+ import pytest
4
+ from cryptography.hazmat.primitives import hashes
5
+
6
+
7
+ def test_raises_for_unknown_fingerprint(graph):
8
+ with pytest.raises(ValueError):
9
+ graph.certificate_invalid_at("does-not-exist")
10
+
11
+
12
+ def test_certificate_within_validity_period_is_valid(graph, self_signed_cert):
13
+ graph.import_certificates(self_signed_cert)
14
+ fingerprint = self_signed_cert.fingerprint(hashes.SHA256()).hex()
15
+
16
+ assert graph.certificate_invalid_at(fingerprint) is False
17
+
18
+
19
+ def test_expired_certificate_is_invalid(graph, expired_cert):
20
+ graph.import_certificates(expired_cert)
21
+ fingerprint = expired_cert.fingerprint(hashes.SHA256()).hex()
22
+
23
+ assert graph.certificate_invalid_at(fingerprint) is True
24
+
25
+
26
+ def test_not_yet_valid_certificate_is_invalid(graph, self_signed_cert):
27
+ graph.import_certificates(self_signed_cert)
28
+ fingerprint = self_signed_cert.fingerprint(hashes.SHA256()).hex()
29
+ before_validity = self_signed_cert.not_valid_before_utc - timedelta(days=1)
30
+
31
+ assert graph.certificate_invalid_at(fingerprint, at_time=before_validity) is True
32
+
33
+
34
+ def test_at_time_controls_validity_check(graph, self_signed_cert):
35
+ graph.import_certificates(self_signed_cert)
36
+ fingerprint = self_signed_cert.fingerprint(hashes.SHA256()).hex()
37
+ after_validity = self_signed_cert.not_valid_after_utc + timedelta(days=1)
38
+
39
+ assert graph.certificate_invalid_at(fingerprint, at_time=after_validity) is True
@@ -3,7 +3,7 @@ def test_export_dot_returns_string(graph):
3
3
 
4
4
 
5
5
  def test_export_dot_empty_graph_has_no_edges(graph):
6
- dot = graph.export_dot()
6
+ dot = graph.export_dot(add_legend=False)
7
7
  assert "digraph" in dot
8
8
  assert "->" not in dot
9
9
 
@@ -36,10 +36,28 @@ def test_export_dot_node_shape_is_box(graph, self_signed_cert):
36
36
  assert "shape=box" in dot
37
37
 
38
38
 
39
+ def test_export_dot_highlights_expired_certificate(graph, expired_cert):
40
+ graph.import_certificates(expired_cert)
41
+
42
+ dot = graph.export_dot()
43
+
44
+ assert "fillcolor=tomato" in dot
45
+
46
+
47
+ def test_export_dot_does_not_highlight_valid_certificate(graph, self_signed_cert):
48
+ graph.import_certificates(self_signed_cert)
49
+
50
+ # legend is disabled so its own "tomato" legend node doesn't interfere
51
+ dot = graph.export_dot(add_legend=False)
52
+
53
+ assert "fillcolor=white" in dot
54
+ assert "tomato" not in dot
55
+
56
+
39
57
  def test_export_dot_includes_edges(graph, chain_certs):
40
58
  graph.import_certificates(chain_certs)
41
59
 
42
- dot = graph.export_dot()
60
+ dot = graph.export_dot(add_legend=False)
43
61
 
44
62
  # one self-loop for the root, plus one edge per subsequent certificate
45
63
  assert dot.count("->") == len(chain_certs)
@@ -52,3 +70,36 @@ def test_export_dot_does_not_write_file(graph, self_signed_cert, tmp_path, monke
52
70
  graph.export_dot()
53
71
 
54
72
  assert not (tmp_path / "test_out.dot").exists()
73
+
74
+
75
+ def test_export_dot_includes_legend_by_default(graph):
76
+ dot = graph.export_dot()
77
+
78
+ assert "subgraph cluster_legend" in dot
79
+ assert "label=Legend" in dot
80
+ assert "legend_valid [label=Valid, shape=box, style=filled, fillcolor=white]" in dot
81
+ assert (
82
+ "legend_invalid [label=Invalid, shape=box, style=filled, fillcolor=tomato]"
83
+ in dot
84
+ )
85
+
86
+
87
+ def test_export_dot_legend_can_be_disabled(graph):
88
+ dot = graph.export_dot(add_legend=False)
89
+
90
+ assert "cluster_legend" not in dot
91
+ assert "legend_valid" not in dot
92
+ assert "legend_invalid" not in dot
93
+
94
+
95
+ def test_export_dot_legend_is_dashed(graph):
96
+ dot = graph.export_dot()
97
+
98
+ assert "style=dashed" in dot
99
+
100
+
101
+ def test_export_dot_legend_nodes_are_ranked_top_to_bottom(graph):
102
+ dot = graph.export_dot()
103
+
104
+ # invisible edge forces the two legend nodes onto separate ranks
105
+ assert "legend_valid -> legend_invalid [style=invis]" in dot
certgraph-0.3.0/PKG-INFO DELETED
@@ -1,42 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: certgraph
3
- Version: 0.3.0
4
- Summary: Utility for exploring and mapping X509 certificate chains.
5
- Author: Chris Adshead
6
- License: MIT License
7
-
8
- Copyright (c) 2026 Chris Adshead
9
-
10
- Permission is hereby granted, free of charge, to any person obtaining a copy
11
- of this software and associated documentation files (the "Software"), to deal
12
- in the Software without restriction, including without limitation the rights
13
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
- copies of the Software, and to permit persons to whom the Software is
15
- furnished to do so, subject to the following conditions:
16
-
17
- The above copyright notice and this permission notice shall be included in all
18
- copies or substantial portions of the Software.
19
-
20
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
- SOFTWARE.
27
-
28
- Requires-Python: >=3.12
29
- Description-Content-Type: text/markdown
30
- License-File: LICENSE
31
- Requires-Dist: cryptography
32
- Requires-Dist: networkx
33
- Requires-Dist: pydot
34
- Requires-Dist: rapidfuzz
35
- Provides-Extra: dev
36
- Requires-Dist: black; extra == "dev"
37
- Requires-Dist: pytest; extra == "dev"
38
- Requires-Dist: pytest-cov; extra == "dev"
39
- Dynamic: license-file
40
-
41
- # certgraph
42
- Python package for displaying X509 certificate chains.
certgraph-0.3.0/README.md DELETED
@@ -1,2 +0,0 @@
1
- # certgraph
2
- Python package for displaying X509 certificate chains.
@@ -1,42 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: certgraph
3
- Version: 0.3.0
4
- Summary: Utility for exploring and mapping X509 certificate chains.
5
- Author: Chris Adshead
6
- License: MIT License
7
-
8
- Copyright (c) 2026 Chris Adshead
9
-
10
- Permission is hereby granted, free of charge, to any person obtaining a copy
11
- of this software and associated documentation files (the "Software"), to deal
12
- in the Software without restriction, including without limitation the rights
13
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
- copies of the Software, and to permit persons to whom the Software is
15
- furnished to do so, subject to the following conditions:
16
-
17
- The above copyright notice and this permission notice shall be included in all
18
- copies or substantial portions of the Software.
19
-
20
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
- SOFTWARE.
27
-
28
- Requires-Python: >=3.12
29
- Description-Content-Type: text/markdown
30
- License-File: LICENSE
31
- Requires-Dist: cryptography
32
- Requires-Dist: networkx
33
- Requires-Dist: pydot
34
- Requires-Dist: rapidfuzz
35
- Provides-Extra: dev
36
- Requires-Dist: black; extra == "dev"
37
- Requires-Dist: pytest; extra == "dev"
38
- Requires-Dist: pytest-cov; extra == "dev"
39
- Dynamic: license-file
40
-
41
- # certgraph
42
- Python package for displaying X509 certificate chains.
File without changes
File without changes
File without changes
File without changes