certgraph 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certgraph
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Utility for exploring and mapping X509 certificate chains.
5
5
  Author: Chris Adshead
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "certgraph"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Utility for exploring and mapping X509 certificate chains."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,217 @@
1
+ from cryptography import x509
2
+ from cryptography.hazmat.primitives import hashes
3
+ import networkx as nx
4
+ from rapidfuzz import fuzz
5
+
6
+
7
+ class certgraph:
8
+ """Class for creating and exploring directed graphs of X509 certificates."""
9
+
10
+ def __init__(self) -> None:
11
+ """Initialise an empty certgraph object."""
12
+ self._certlist: set[x509.Certificate] = []
13
+ self._graph = nx.DiGraph()
14
+
15
+ def import_certificates(
16
+ self,
17
+ certificates: (
18
+ x509.Certificate | str | bytes | list[x509.Certificate | str | bytes]
19
+ ),
20
+ ) -> certgraph:
21
+ """
22
+ Import one or more certificates and regenerate the certificate digraph.
23
+
24
+ Args:
25
+ certificates: Certificates to import, either individual or a list. x509.Certificate objects are directly imported, str is assumed to be PEM encoded, bytes are assumed to be DER encoded.
26
+
27
+ Returns:
28
+ Returns self to allow method chaining.
29
+
30
+ Raises:
31
+ TypeError: Certificate type is not supported (i.e. not x509.Certificate, str or bytes)
32
+ """
33
+ new_certs: set[x509.Certificate] = []
34
+
35
+ # If there's only a non-list object being handing in, turn it into an array to keep the same iteration code
36
+ for i, cert in enumerate(
37
+ certificates if isinstance(certificates, list) else [certificates]
38
+ ):
39
+ if isinstance(cert, x509.Certificate):
40
+ # No conversion neccessary
41
+ new_certs.append(cert)
42
+ elif isinstance(cert, str):
43
+ # PEM encoded
44
+ pem_certs = x509.load_pem_x509_certificates(cert.encode("utf-8"))
45
+ new_certs.extend(pem_certs)
46
+ elif isinstance(cert, bytes):
47
+ # DER encoded
48
+ der_cert = x509.load_der_x509_certificate(cert)
49
+ new_certs.append(der_cert)
50
+ else:
51
+ raise TypeError(
52
+ f"Cannot import certificate {i} from data type {type(cert)}"
53
+ )
54
+
55
+ self._certlist.extend(new_certs)
56
+ self._graph = self._generate_graph(self._certlist)
57
+
58
+ return self
59
+
60
+ def _generate_graph(self, certificates: set[x509.Certificate]) -> nx.DiGraph:
61
+ """Generate a networkx digraph from the provided list of certificates."""
62
+ G = nx.DiGraph()
63
+
64
+ # Pass 1: add nodes and build a subject_dn -> fingerprint index
65
+ subject_index: dict[str, list[str]] = {}
66
+ for cert in certificates:
67
+ fingerprint = cert.fingerprint(hashes.SHA256()).hex()
68
+ G.add_node(fingerprint, certificate=cert)
69
+
70
+ subject_dn = cert.subject.rfc4514_string()
71
+ subject_index.setdefault(subject_dn, []).append(fingerprint)
72
+
73
+ # Pass 2: add edges using the index
74
+ for fingerprint, data in G.nodes(data=True):
75
+ issuer_dn = data["certificate"].issuer.rfc4514_string()
76
+
77
+ for issuer_fingerprint in subject_index.get(issuer_dn, []):
78
+ G.add_edge(issuer_fingerprint, fingerprint)
79
+
80
+ return G
81
+
82
+ def report_fingerprint_edges(self) -> list[str]:
83
+ """
84
+ Get the list of all edges in the form 'fingerprint1 -> fingerprint2'. Only contains the first 8 characters of each fingerprint, because this is mostly for debugging from a terminal without having to generate a complete graph image.
85
+
86
+ Returns:
87
+ List of all edges in the current certificate digraph in terms of fingerprints.
88
+ """
89
+ return [f"{edge[0][:8]} -> {edge[1][:8]}" for edge in self._graph.edges()]
90
+
91
+ def export_dot(self, format: str = "svg") -> str:
92
+ """Function to export the current certificate digraph in the dot language. NOTE: Still in progress."""
93
+ allowed_types = ["svg", "png"]
94
+
95
+ if format not in allowed_types:
96
+ raise ValueError(
97
+ f"Cannot export dot graph of type {format} - must be one of {allowed_types}"
98
+ )
99
+
100
+ dot_graph = nx.DiGraph()
101
+
102
+ for fingerprint, data in self._graph.nodes(data=True):
103
+ label = data["certificate"].subject.rfc4514_string()
104
+ dot_graph.add_node(fingerprint, label=label)
105
+ dot_graph.add_edges_from(self._graph.edges())
106
+
107
+ nx.drawing.nx_pydot.write_dot(dot_graph, "./test_out.dot")
108
+
109
+ return "dot"
110
+
111
+ def clear(self) -> certgraph:
112
+ """
113
+ Remove all certificates imported and clear the digraph.
114
+
115
+ Returns:
116
+ Returns self to allow method chaining.
117
+ """
118
+ self._certlist.clear()
119
+ self._graph.clear()
120
+ return self
121
+
122
+ def fingerprint_from_distinguished_name(self, dn: str, cutoff: int = 0) -> str:
123
+ """
124
+ Get the fingerprint of the imported certificate with the best fuzzy-search match between the requested distinguised name and the rfc4514 string of each imported certificate.
125
+
126
+ Args:
127
+ dn: The distinguished name to use as the basis for the fuzzy-search.
128
+ cutoff: Cutoff value for matching, from 0-100. 0 will always return the closest match (even if poor), 100 will only ever return an exact match.
129
+
130
+ Returns:
131
+ The fingerprint of the certificate with the closes matching distinguished name to the one requested. Can also return None if there are no certificates above the cutoff threshold.
132
+ """
133
+ if (cutoff < 0) or (cutoff > 100):
134
+ raise ValueError("cutoff must be between 0-100")
135
+
136
+ # Evaluate the nodes
137
+ nodes: list[tuple[str, dict]] = list(self._graph.nodes(data=True))
138
+
139
+ # Sort using a fuzzy-search
140
+ ranked = sorted(
141
+ nodes,
142
+ key=lambda t: fuzz.ratio(
143
+ dn, t[1]["certificate"].subject.rfc4514_string(), score_cutoff=cutoff
144
+ ),
145
+ )
146
+
147
+ if not ranked:
148
+ return None
149
+
150
+ return ranked[-1][0]
151
+
152
+ def get_certificate(self, fingerprint: str) -> x509.Certificate | None:
153
+ """
154
+ Get an imported ceritficate based on the fingerprint.
155
+
156
+ Args:
157
+ fingerprint: Fingerprint of the certificate to get. Use fingerprint_from_distinguished_name() to resolve the fingerprint from a DN.
158
+
159
+ Returns:
160
+ Certificate that matches the provided fingerprint or None if it doesn't exist.
161
+ """
162
+ if fingerprint not in self._graph.nodes:
163
+ return None
164
+
165
+ return self._graph.nodes[fingerprint]["certificate"]
166
+
167
+ def get_issuer_fingerprint(self, fingerprint: str) -> str:
168
+ """
169
+ Get the fingerprint of the issuer certificate.
170
+
171
+ Args:
172
+ fingerprint: Fingerprint of the certificate to get the issuer of.
173
+
174
+ Returns:
175
+ The fingerprint of the issuing certificate or None if not present in the digraph.
176
+
177
+ Raises:
178
+ ValueError: If certificate with the supplied fingerprint isn't in the digraph.
179
+ """
180
+ if fingerprint not in self._graph.nodes:
181
+ raise ValueError(f"Fingerprint supplied doesn't match any imported certificate.")
182
+
183
+ return next(self._graph.predecessors(fingerprint), None)
184
+
185
+ def get_child_fingerprints(self, fingerprint: str) -> str:
186
+ """
187
+ Get the fingerprints of any certificates issued by the certificate with the provided fingerprint.
188
+
189
+ Args:
190
+ fingerprint: Fingerprint of the certificate to get the child certificates of.
191
+
192
+ Returns:
193
+ The list of fingerprints belonging to child certificates.
194
+
195
+ Raises:
196
+ ValueError: If certificate with the supplied fingerprint isn't in the digraph.
197
+ """
198
+ if fingerprint not in self._graph.nodes:
199
+ raise ValueError(f"Fingerprint supplied doesn't match any imported certificate.")
200
+
201
+ return list(self._graph.successors(fingerprint))
202
+
203
+ def remove_certificate(self, fingerprint: str) -> None:
204
+ """
205
+ Remove an imported ceritficate based on the fingerprint and update the certificate digraph.
206
+
207
+ Args:
208
+ fingerprint: Fingerprint of the certificate to remove. Use fingerprint_from_distinguished_name() to resolve the fingerprint from a DN.
209
+ """
210
+ if fingerprint in self._graph.nodes:
211
+ cert = self._graph.nodes[fingerprint]["certificate"]
212
+
213
+ self._graph.remove_node(fingerprint)
214
+
215
+ if cert in self._certlist:
216
+ self._certlist.remove(cert)
217
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certgraph
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Utility for exploring and mapping X509 certificate chains.
5
5
  Author: Chris Adshead
6
6
  License: MIT License
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/certgraph/__init__.py
5
+ src/certgraph/certgraph.py
6
+ src/certgraph.egg-info/PKG-INFO
7
+ src/certgraph.egg-info/SOURCES.txt
8
+ src/certgraph.egg-info/dependency_links.txt
9
+ src/certgraph.egg-info/requires.txt
10
+ src/certgraph.egg-info/top_level.txt
11
+ tests/test_clear.py
12
+ tests/test_export_dot.py
13
+ tests/test_fingerprint_search.py
14
+ tests/test_get_certificate.py
15
+ tests/test_get_child_fingerprints.py
16
+ tests/test_get_issuer_fingerprint.py
17
+ tests/test_graph.py
18
+ tests/test_import_certificates.py
19
+ tests/test_remove_certificate.py
@@ -0,0 +1,21 @@
1
+ def test_clear_removes_edges(graph, chain_certs):
2
+ graph.import_certificates(chain_certs)
3
+ assert graph.report_fingerprint_edges()
4
+
5
+ graph.clear()
6
+
7
+ assert graph.report_fingerprint_edges() == []
8
+
9
+
10
+ def test_clear_returns_self(graph):
11
+ assert graph.clear() is graph
12
+
13
+
14
+ def test_clear_resets_dn_search(graph, self_signed_cert):
15
+ graph.import_certificates(self_signed_cert)
16
+ dn = self_signed_cert.subject.rfc4514_string()
17
+ assert graph.fingerprint_from_distinguished_name(dn) is not None
18
+
19
+ graph.clear()
20
+
21
+ assert graph.fingerprint_from_distinguished_name(dn) is None
@@ -0,0 +1,9 @@
1
+ import pytest
2
+
3
+ # export_dot()'s actual output generation is still rough and not covered here.
4
+ # Only the argument validation, which happens before anything is written, is tested.
5
+
6
+
7
+ def test_export_dot_rejects_invalid_format(graph):
8
+ with pytest.raises(ValueError):
9
+ graph.export_dot(format="jpeg")
@@ -0,0 +1,28 @@
1
+ from cryptography.hazmat.primitives import hashes
2
+
3
+
4
+ def test_returns_none_for_empty_graph(graph):
5
+ assert graph.fingerprint_from_distinguished_name("CN=whatever") is None
6
+
7
+
8
+ def test_exact_match_returns_correct_fingerprint(graph, chain_certs):
9
+ graph.import_certificates(chain_certs)
10
+ target = chain_certs[1]
11
+ dn = target.subject.rfc4514_string()
12
+ expected_fp = target.fingerprint(hashes.SHA256()).hex()
13
+
14
+ result = graph.fingerprint_from_distinguished_name(dn)
15
+
16
+ assert result == expected_fp
17
+
18
+
19
+ def test_fuzzy_match_finds_closest_dn(graph, chain_certs):
20
+ graph.import_certificates(chain_certs)
21
+ target = chain_certs[0]
22
+ dn = target.subject.rfc4514_string()
23
+ typo_dn = dn[:-1] # drop a character to force a fuzzy, non-exact match
24
+ expected_fp = target.fingerprint(hashes.SHA256()).hex()
25
+
26
+ result = graph.fingerprint_from_distinguished_name(typo_dn)
27
+
28
+ assert result == expected_fp
@@ -0,0 +1,20 @@
1
+ from cryptography.hazmat.primitives import hashes
2
+
3
+
4
+ def test_returns_none_for_unknown_fingerprint(graph):
5
+ assert graph.get_certificate("does-not-exist") is None
6
+
7
+
8
+ def test_returns_certificate_for_known_fingerprint(graph, self_signed_cert):
9
+ graph.import_certificates(self_signed_cert)
10
+ fingerprint = self_signed_cert.fingerprint(hashes.SHA256()).hex()
11
+
12
+ assert graph.get_certificate(fingerprint) == self_signed_cert
13
+
14
+
15
+ def test_returns_correct_certificate_from_chain(graph, chain_certs):
16
+ graph.import_certificates(chain_certs)
17
+ target = chain_certs[1]
18
+ fingerprint = target.fingerprint(hashes.SHA256()).hex()
19
+
20
+ assert graph.get_certificate(fingerprint) == target
@@ -0,0 +1,38 @@
1
+ import pytest
2
+ from cryptography.hazmat.primitives import hashes
3
+
4
+
5
+ def test_raises_for_unknown_fingerprint(graph):
6
+ with pytest.raises(ValueError):
7
+ graph.get_child_fingerprints("does-not-exist")
8
+
9
+
10
+ def test_leaf_certificate_has_no_children(graph, chain_certs):
11
+ graph.import_certificates(chain_certs)
12
+ leaf_fp = chain_certs[2].fingerprint(hashes.SHA256()).hex()
13
+
14
+ assert graph.get_child_fingerprints(leaf_fp) == []
15
+
16
+
17
+ def test_self_signed_certificate_is_its_own_child(graph, self_signed_cert):
18
+ graph.import_certificates(self_signed_cert)
19
+ fingerprint = self_signed_cert.fingerprint(hashes.SHA256()).hex()
20
+
21
+ assert graph.get_child_fingerprints(fingerprint) == [fingerprint]
22
+
23
+
24
+ def test_root_returns_itself_and_issued_certificate(graph, chain_certs):
25
+ graph.import_certificates(chain_certs)
26
+ root_fp = chain_certs[0].fingerprint(hashes.SHA256()).hex()
27
+ intermediate_fp = chain_certs[1].fingerprint(hashes.SHA256()).hex()
28
+
29
+ # root has a self-loop plus the certificate it issued
30
+ assert set(graph.get_child_fingerprints(root_fp)) == {root_fp, intermediate_fp}
31
+
32
+
33
+ def test_intermediate_returns_only_leaf_it_issued(graph, chain_certs):
34
+ graph.import_certificates(chain_certs)
35
+ intermediate_fp = chain_certs[1].fingerprint(hashes.SHA256()).hex()
36
+ leaf_fp = chain_certs[2].fingerprint(hashes.SHA256()).hex()
37
+
38
+ assert graph.get_child_fingerprints(intermediate_fp) == [leaf_fp]
@@ -0,0 +1,35 @@
1
+ import pytest
2
+ from cryptography.hazmat.primitives import hashes
3
+
4
+
5
+ def test_raises_for_unknown_fingerprint(graph):
6
+ with pytest.raises(ValueError):
7
+ graph.get_issuer_fingerprint("does-not-exist")
8
+
9
+
10
+ def test_self_signed_certificate_is_its_own_issuer(graph, self_signed_cert):
11
+ graph.import_certificates(self_signed_cert)
12
+ fingerprint = self_signed_cert.fingerprint(hashes.SHA256()).hex()
13
+
14
+ assert graph.get_issuer_fingerprint(fingerprint) == fingerprint
15
+
16
+
17
+ def test_leaf_returns_direct_issuer_fingerprint(graph, chain_certs):
18
+ graph.import_certificates(chain_certs)
19
+ root_fp = chain_certs[0].fingerprint(hashes.SHA256()).hex()
20
+ intermediate_fp = chain_certs[1].fingerprint(hashes.SHA256()).hex()
21
+ leaf_fp = chain_certs[2].fingerprint(hashes.SHA256()).hex()
22
+
23
+ assert graph.get_issuer_fingerprint(intermediate_fp) == root_fp
24
+ assert graph.get_issuer_fingerprint(leaf_fp) == intermediate_fp
25
+
26
+
27
+ def test_unrelated_self_signed_certs_do_not_share_issuer(graph, make_chain):
28
+ cert_a = make_chain(1, cns=["alpha-root"])[0]
29
+ cert_b = make_chain(1, cns=["beta-root"])[0]
30
+ graph.import_certificates([cert_a, cert_b])
31
+ fp_a = cert_a.fingerprint(hashes.SHA256()).hex()
32
+ fp_b = cert_b.fingerprint(hashes.SHA256()).hex()
33
+
34
+ assert graph.get_issuer_fingerprint(fp_a) == fp_a
35
+ assert graph.get_issuer_fingerprint(fp_b) == fp_b
@@ -0,0 +1,36 @@
1
+ def test_empty_graph_has_no_edges(graph):
2
+ assert graph.report_fingerprint_edges() == []
3
+
4
+
5
+ def test_self_signed_certificate_produces_self_loop(graph, self_signed_cert):
6
+ graph.import_certificates(self_signed_cert)
7
+ edges = graph.report_fingerprint_edges()
8
+ assert len(edges) == 1
9
+ src, _, dst = edges[0].partition(" -> ")
10
+ assert src == dst
11
+
12
+
13
+ def test_chain_produces_expected_number_of_edges(graph, chain_certs):
14
+ graph.import_certificates(chain_certs)
15
+ # one self-loop for the root, plus one edge linking each subsequent cert to its issuer
16
+ assert len(graph.report_fingerprint_edges()) == len(chain_certs)
17
+
18
+
19
+ def test_unrelated_self_signed_certs_do_not_link(graph, make_chain):
20
+ cert_a = make_chain(1, cns=["alpha-root"])[0]
21
+ cert_b = make_chain(1, cns=["beta-root"])[0]
22
+ graph.import_certificates([cert_a, cert_b])
23
+
24
+ edges = graph.report_fingerprint_edges()
25
+ assert len(edges) == 2
26
+ for edge in edges:
27
+ src, _, dst = edge.partition(" -> ")
28
+ assert src == dst
29
+
30
+
31
+ def test_edge_report_format_uses_truncated_fingerprints(graph, self_signed_cert):
32
+ graph.import_certificates(self_signed_cert)
33
+ edge = graph.report_fingerprint_edges()[0]
34
+ src, _, dst = edge.partition(" -> ")
35
+ assert len(src) == 8
36
+ assert len(dst) == 8
@@ -0,0 +1,39 @@
1
+ import pytest
2
+ from cryptography.hazmat.primitives import serialization
3
+
4
+
5
+ def test_import_single_certificate_object(graph, self_signed_cert):
6
+ graph.import_certificates(self_signed_cert)
7
+ assert len(graph.report_fingerprint_edges()) == 1
8
+
9
+
10
+ def test_import_certificate_list(graph, chain_certs):
11
+ graph.import_certificates(chain_certs)
12
+ assert len(graph.report_fingerprint_edges()) == len(chain_certs)
13
+
14
+
15
+ def test_import_pem_string(graph, chain_pem, chain_certs):
16
+ graph.import_certificates(chain_pem)
17
+ assert len(graph.report_fingerprint_edges()) == len(chain_certs)
18
+
19
+
20
+ def test_import_der_bytes(graph, self_signed_cert):
21
+ der = self_signed_cert.public_bytes(serialization.Encoding.DER)
22
+ graph.import_certificates(der)
23
+ assert len(graph.report_fingerprint_edges()) == 1
24
+
25
+
26
+ def test_import_invalid_type_raises_typeerror(graph):
27
+ with pytest.raises(TypeError):
28
+ graph.import_certificates(12345)
29
+
30
+
31
+ def test_import_returns_self_for_chaining(graph, self_signed_cert):
32
+ result = graph.import_certificates(self_signed_cert)
33
+ assert result is graph
34
+
35
+
36
+ def test_import_accumulates_across_multiple_calls(graph, chain_certs):
37
+ graph.import_certificates(chain_certs[0])
38
+ graph.import_certificates(chain_certs[1:])
39
+ assert len(graph.report_fingerprint_edges()) == len(chain_certs)
@@ -0,0 +1,41 @@
1
+ from cryptography.hazmat.primitives import hashes
2
+
3
+
4
+ def test_removes_node_from_graph(graph, self_signed_cert):
5
+ graph.import_certificates(self_signed_cert)
6
+ fingerprint = self_signed_cert.fingerprint(hashes.SHA256()).hex()
7
+
8
+ graph.remove_certificate(fingerprint)
9
+
10
+ assert graph.get_certificate(fingerprint) is None
11
+
12
+
13
+ def test_removes_certificate_from_certlist(graph, self_signed_cert):
14
+ graph.import_certificates(self_signed_cert)
15
+
16
+ fingerprint = self_signed_cert.fingerprint(hashes.SHA256()).hex()
17
+ graph.remove_certificate(fingerprint)
18
+
19
+ assert self_signed_cert not in graph._certlist
20
+
21
+
22
+ def test_unknown_fingerprint_is_a_no_op(graph, chain_certs):
23
+ graph.import_certificates(chain_certs)
24
+ edges_before = graph.report_fingerprint_edges()
25
+
26
+ graph.remove_certificate("does-not-exist")
27
+
28
+ assert graph.report_fingerprint_edges() == edges_before
29
+
30
+
31
+ def test_removing_intermediate_drops_its_edges(graph, chain_certs):
32
+ graph.import_certificates(chain_certs)
33
+ intermediate_fp = chain_certs[1].fingerprint(hashes.SHA256()).hex()
34
+ leaf_fp = chain_certs[2].fingerprint(hashes.SHA256()).hex()
35
+
36
+ graph.remove_certificate(intermediate_fp)
37
+
38
+ remaining = graph.report_fingerprint_edges()
39
+ assert all(intermediate_fp[:8] not in edge for edge in remaining)
40
+ # the leaf certificate is still present, just orphaned
41
+ assert graph.get_certificate(leaf_fp) == chain_certs[2]
@@ -1,109 +0,0 @@
1
- from cryptography import x509
2
- from cryptography.hazmat.primitives import hashes
3
- import networkx as nx
4
- from rapidfuzz import fuzz
5
-
6
-
7
- class certgraph:
8
- def __init__(self) -> None:
9
- self._certlist: set[x509.Certificate] = []
10
- self._graph = nx.DiGraph()
11
-
12
- def import_certificates(
13
- self,
14
- certificates: (
15
- x509.Certificate | str | bytes | list[x509.Certificate | str | bytes]
16
- ),
17
- pem_encoding: str = "utf-8",
18
- ) -> certgraph:
19
- new_certs: set[x509.Certificate] = []
20
-
21
- # If there's only a non-list object being handing in, turn it into an array to keep the same iteration code
22
- for i, cert in enumerate(
23
- certificates if isinstance(certificates, list) else [certificates]
24
- ):
25
- if isinstance(cert, x509.Certificate):
26
- # No conversion neccessary
27
- new_certs.append(cert)
28
- elif isinstance(cert, str):
29
- # PEM encoded
30
- pem_certs = x509.load_pem_x509_certificates(cert.encode(pem_encoding))
31
- new_certs.extend(pem_certs)
32
- elif isinstance(cert, bytes):
33
- # DER encoded
34
- der_cert = x509.load_der_x509_certificate(cert)
35
- new_certs.append(der_cert)
36
- else:
37
- raise TypeError(
38
- f"Cannot import certificate {i} from data type {type(cert)}"
39
- )
40
-
41
- self._certlist.extend(new_certs)
42
- self._graph = self._generate_graph(self._certlist)
43
-
44
- return self
45
-
46
- def _generate_graph(self, certificates: set[x509.Certificate]) -> nx.DiGraph:
47
- G = nx.DiGraph()
48
-
49
- # Pass 1: add nodes and build a subject_dn -> fingerprint index
50
- subject_index: dict[str, list[str]] = {}
51
- for cert in certificates:
52
- fingerprint = cert.fingerprint(hashes.SHA256()).hex()
53
- G.add_node(fingerprint, certificate=cert)
54
-
55
- subject_dn = cert.subject.rfc4514_string()
56
- subject_index.setdefault(subject_dn, []).append(fingerprint)
57
-
58
- # Pass 2: add edges using the index
59
- for fingerprint, data in G.nodes(data=True):
60
- issuer_dn = data["certificate"].issuer.rfc4514_string()
61
-
62
- for issuer_fingerprint in subject_index.get(issuer_dn, []):
63
- G.add_edge(issuer_fingerprint, fingerprint)
64
-
65
- return G
66
-
67
- def report_fingerprint_edges(self) -> list[str]:
68
- return [f"{edge[0][:8]} -> {edge[1][:8]}" for edge in self._graph.edges()]
69
-
70
- def export_dot(self, format: str = "svg") -> str:
71
- allowed_types = ["svg", "png"]
72
-
73
- if format not in allowed_types:
74
- raise ValueError(
75
- f"Cannot export dot graph of type {format} - must be one of {allowed_types}"
76
- )
77
-
78
- dot_graph = nx.DiGraph()
79
-
80
- for fingerprint, data in self._graph.nodes(data=True):
81
- label = data["certificate"].subject.rfc4514_string()
82
- dot_graph.add_node(fingerprint, label=label)
83
- dot_graph.add_edges_from(self._graph.edges())
84
-
85
- nx.drawing.nx_pydot.write_dot(dot_graph, "./test_out.dot")
86
-
87
- return "dot"
88
-
89
- def clear(self) -> certgraph:
90
- self._certlist.clear()
91
- self._graph.clear()
92
- return self
93
-
94
- def fingerprint_from_distinguished_name(self, dn: str, cutoff: int = 0) -> str:
95
- # Evaluate the nodes
96
- nodes: list[tuple[str, dict]] = list(self._graph.nodes(data=True))
97
-
98
- # Sort using a fuzzy-search
99
- ranked = sorted(
100
- nodes,
101
- key=lambda t: fuzz.ratio(
102
- dn, t[1]["certificate"].subject.rfc4514_string(), score_cutoff=cutoff
103
- )
104
- )
105
-
106
- if not ranked:
107
- return None
108
-
109
- return ranked[-1][0]
@@ -1,10 +0,0 @@
1
- LICENSE
2
- README.md
3
- pyproject.toml
4
- src/certgraph/__init__.py
5
- src/certgraph/certgraph.py
6
- src/certgraph.egg-info/PKG-INFO
7
- src/certgraph.egg-info/SOURCES.txt
8
- src/certgraph.egg-info/dependency_links.txt
9
- src/certgraph.egg-info/requires.txt
10
- src/certgraph.egg-info/top_level.txt
File without changes
File without changes
File without changes