pivotcheck 1.0.0__py3-none-any.whl

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 (60) hide show
  1. pivotcheck/__init__.py +6 -0
  2. pivotcheck/__main__.py +13 -0
  3. pivotcheck/analysis/__init__.py +57 -0
  4. pivotcheck/analysis/comparison.py +320 -0
  5. pivotcheck/analysis/evidence_gaps.py +319 -0
  6. pivotcheck/analysis/explanation.py +171 -0
  7. pivotcheck/analysis/gateway.py +266 -0
  8. pivotcheck/analysis/map_view.py +175 -0
  9. pivotcheck/analysis/next_step.py +319 -0
  10. pivotcheck/analysis/query.py +144 -0
  11. pivotcheck/analysis/recommendation.py +109 -0
  12. pivotcheck/analysis/summary.py +37 -0
  13. pivotcheck/analysis/topology.py +131 -0
  14. pivotcheck/analysis/transit_priority.py +133 -0
  15. pivotcheck/checks/__init__.py +30 -0
  16. pivotcheck/checks/context.py +252 -0
  17. pivotcheck/checks/proxy.py +452 -0
  18. pivotcheck/checks/resolver.py +101 -0
  19. pivotcheck/checks/tcp.py +129 -0
  20. pivotcheck/cli.py +1394 -0
  21. pivotcheck/discovery/__init__.py +22 -0
  22. pivotcheck/discovery/connections.py +150 -0
  23. pivotcheck/discovery/dns.py +44 -0
  24. pivotcheck/discovery/engine.py +37 -0
  25. pivotcheck/discovery/interfaces.py +142 -0
  26. pivotcheck/discovery/local.py +78 -0
  27. pivotcheck/discovery/neighbors.py +67 -0
  28. pivotcheck/discovery/provider.py +40 -0
  29. pivotcheck/discovery/routes.py +143 -0
  30. pivotcheck/discovery/ssh.py +236 -0
  31. pivotcheck/models/__init__.py +43 -0
  32. pivotcheck/models/baseline.py +86 -0
  33. pivotcheck/models/check.py +584 -0
  34. pivotcheck/models/network.py +271 -0
  35. pivotcheck/models/proxy_check.py +301 -0
  36. pivotcheck/models/result.py +77 -0
  37. pivotcheck/models/session.py +34 -0
  38. pivotcheck/output/__init__.py +18 -0
  39. pivotcheck/output/artifact.py +27 -0
  40. pivotcheck/output/check_output.py +201 -0
  41. pivotcheck/output/comparison.py +117 -0
  42. pivotcheck/output/evidence_gaps.py +69 -0
  43. pivotcheck/output/intelligence.py +79 -0
  44. pivotcheck/output/json.py +36 -0
  45. pivotcheck/output/map_view.py +123 -0
  46. pivotcheck/output/next_step.py +121 -0
  47. pivotcheck/output/proxy_check.py +105 -0
  48. pivotcheck/output/terminal.py +304 -0
  49. pivotcheck/output/writer.py +73 -0
  50. pivotcheck/storage/__init__.py +23 -0
  51. pivotcheck/storage/baseline_store.py +249 -0
  52. pivotcheck/utils/__init__.py +1 -0
  53. pivotcheck/utils/system.py +54 -0
  54. pivotcheck/utils/validation.py +22 -0
  55. pivotcheck-1.0.0.dist-info/METADATA +557 -0
  56. pivotcheck-1.0.0.dist-info/RECORD +60 -0
  57. pivotcheck-1.0.0.dist-info/WHEEL +5 -0
  58. pivotcheck-1.0.0.dist-info/entry_points.txt +2 -0
  59. pivotcheck-1.0.0.dist-info/licenses/LICENSE +674 -0
  60. pivotcheck-1.0.0.dist-info/top_level.txt +1 -0
pivotcheck/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """PivotCheck — passive network discovery and pivot path validation.
2
+
3
+ Copyright (c) 2026 PivotCheck contributors. GPL-3.0-only licensed.
4
+ """
5
+
6
+ __version__ = "1.0.0"
pivotcheck/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Enable `python -m pivotcheck` execution.
2
+
3
+ Delegates entirely to the CLI entry point so both invocation styles behave
4
+ identically:
5
+
6
+ python -m pivotcheck discover
7
+ pivotcheck discover (console script)
8
+ """
9
+
10
+ from pivotcheck.cli import entry_point
11
+
12
+ if __name__ == "__main__":
13
+ entry_point()
@@ -0,0 +1,57 @@
1
+ """Analysis engine: topology, reachability classification, pivot paths."""
2
+
3
+ from pivotcheck.analysis.comparison import (
4
+ DiffFinding,
5
+ DiffReport,
6
+ NetworkRelationship,
7
+ baseline_from_snapshot,
8
+ classify_relationship,
9
+ compare,
10
+ coverage_view,
11
+ )
12
+ from pivotcheck.analysis.gateway import assess_transit_evidence
13
+ from pivotcheck.analysis.next_step import (
14
+ NextStepCandidate,
15
+ NextStepReport,
16
+ select_next_investigation,
17
+ )
18
+ from pivotcheck.analysis.topology import (
19
+ analyze,
20
+ classify_networks,
21
+ classify_routed_networks,
22
+ infer_pivot_paths,
23
+ )
24
+ from pivotcheck.analysis.transit_priority import (
25
+ TransitPriority,
26
+ TransitPriorityResult,
27
+ assess_transit_priority,
28
+ )
29
+ from pivotcheck.models.check import (
30
+ TransitEvidence,
31
+ TransitEvidenceAssessment,
32
+ TransitEvidenceCollection,
33
+ )
34
+
35
+ __all__ = [
36
+ "DiffFinding",
37
+ "DiffReport",
38
+ "NetworkRelationship",
39
+ "NextStepCandidate",
40
+ "NextStepReport",
41
+ "TransitEvidence",
42
+ "TransitEvidenceAssessment",
43
+ "TransitEvidenceCollection",
44
+ "TransitPriority",
45
+ "TransitPriorityResult",
46
+ "analyze",
47
+ "assess_transit_evidence",
48
+ "assess_transit_priority",
49
+ "baseline_from_snapshot",
50
+ "classify_networks",
51
+ "classify_relationship",
52
+ "classify_routed_networks",
53
+ "compare",
54
+ "coverage_view",
55
+ "infer_pivot_paths",
56
+ "select_next_investigation",
57
+ ]
@@ -0,0 +1,320 @@
1
+ """Pure coverage and evidence comparison for network perspectives.
2
+
3
+ The coverage view collapses adjacent CIDRs for mathematical reachability
4
+ comparison. The evidence view retains individual entries for route context
5
+ and specificity findings.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ipaddress
11
+ from collections.abc import Iterable
12
+ from dataclasses import dataclass
13
+ from enum import Enum
14
+ from typing import Any, cast
15
+
16
+ from pivotcheck.models.baseline import Baseline, BaselineNetwork
17
+ from pivotcheck.models.network import RouteType
18
+ from pivotcheck.models.result import DiscoverySnapshot
19
+
20
+
21
+ class NetworkRelationship(str, Enum):
22
+ EXACT = "exact"
23
+ BASELINE_COVERS_CURRENT = "baseline_covers_current"
24
+ CURRENT_COVERS_BASELINE = "current_covers_baseline"
25
+ DISJOINT = "disjoint"
26
+
27
+
28
+ def classify_relationship(
29
+ baseline_network: str | ipaddress._BaseNetwork,
30
+ current_network: str | ipaddress._BaseNetwork,
31
+ ) -> NetworkRelationship:
32
+ """Classify two canonical CIDRs into exactly one relation.
33
+
34
+ CIDR blocks are hierarchical: same-family blocks either are disjoint or
35
+ one contains the other. A partial-overlap category would be dead code.
36
+ Different IP families are intentionally treated as disjoint.
37
+ """
38
+ baseline = ipaddress.ip_network(str(baseline_network), strict=False)
39
+ current = ipaddress.ip_network(str(current_network), strict=False)
40
+ if baseline.version != current.version:
41
+ return NetworkRelationship.DISJOINT
42
+ if baseline == current:
43
+ return NetworkRelationship.EXACT
44
+ # Same family: narrow so supernet_of sees matching concrete types.
45
+ if baseline.version == 4:
46
+ b4 = cast(ipaddress.IPv4Network, baseline)
47
+ c4 = cast(ipaddress.IPv4Network, current)
48
+ if b4.supernet_of(c4):
49
+ return NetworkRelationship.BASELINE_COVERS_CURRENT
50
+ if c4.supernet_of(b4):
51
+ return NetworkRelationship.CURRENT_COVERS_BASELINE
52
+ else:
53
+ b6 = cast(ipaddress.IPv6Network, baseline)
54
+ c6 = cast(ipaddress.IPv6Network, current)
55
+ if b6.supernet_of(c6):
56
+ return NetworkRelationship.BASELINE_COVERS_CURRENT
57
+ if c6.supernet_of(b6):
58
+ return NetworkRelationship.CURRENT_COVERS_BASELINE
59
+ return NetworkRelationship.DISJOINT
60
+
61
+
62
+ def coverage_view(
63
+ entries: Iterable[BaselineNetwork],
64
+ ) -> tuple[ipaddress._BaseNetwork, ...]:
65
+ """Return deterministically collapsed coverage, partitioned by family."""
66
+ by_family: dict[int, list[Any]] = {4: [], 6: []}
67
+ for entry in entries:
68
+ network = ipaddress.ip_network(entry.network)
69
+ by_family[network.version].append(network)
70
+ networks = [
71
+ collapsed
72
+ for family in (4, 6)
73
+ for collapsed in ipaddress.collapse_addresses(by_family[family])
74
+ ]
75
+ return tuple(
76
+ sorted(
77
+ networks,
78
+ key=lambda network: (
79
+ network.version,
80
+ int(network.network_address),
81
+ network.prefixlen,
82
+ ),
83
+ )
84
+ )
85
+
86
+
87
+ def baseline_from_snapshot(snapshot: DiscoverySnapshot) -> Baseline:
88
+ """Pure, deterministic transformation from analyzed discovery output."""
89
+ entries = tuple(
90
+ BaselineNetwork(
91
+ network=network.cidr,
92
+ origin=network.origin,
93
+ confidence=network.confidence,
94
+ interface=network.interface,
95
+ gateway=network.gateway,
96
+ route_type=(
97
+ RouteType.STATIC if network.gateway is not None else RouteType.CONNECTED
98
+ ),
99
+ )
100
+ for network in snapshot.networks
101
+ )
102
+ return Baseline(
103
+ created_at=snapshot.timestamp,
104
+ source="discovery_snapshot",
105
+ networks=entries,
106
+ vantage_point=snapshot.session,
107
+ )
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class DiffFinding:
112
+ network: str
113
+ classification: str
114
+ relationship: NetworkRelationship | None
115
+ related_network: str | None
116
+ reachability_novelty: bool
117
+ topology_novelty: bool
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class DiffReport:
122
+ new_networks: tuple[DiffFinding, ...] = ()
123
+ specificity_changes: tuple[DiffFinding, ...] = ()
124
+ coverage_changes: tuple[DiffFinding, ...] = ()
125
+ unchanged_networks: tuple[DiffFinding, ...] = ()
126
+ context_changes: tuple[DiffFinding, ...] = ()
127
+ warnings: tuple[str, ...] = ()
128
+
129
+
130
+ def compare(baseline: Baseline, current: Baseline) -> DiffReport:
131
+ """Compare two perspectives without confusing evidence with coverage."""
132
+ baseline_coverage = coverage_view(baseline.networks)
133
+ current_coverage = coverage_view(current.networks)
134
+
135
+ new: list[DiffFinding] = []
136
+ coverage: list[DiffFinding] = []
137
+ unchanged: list[DiffFinding] = []
138
+ for current_network in current_coverage:
139
+ relationships = [
140
+ (known, classify_relationship(known, current_network))
141
+ for known in baseline_coverage
142
+ if known.version == current_network.version
143
+ ]
144
+ exact = next(
145
+ (
146
+ known
147
+ for known, relation in relationships
148
+ if relation is NetworkRelationship.EXACT
149
+ ),
150
+ None,
151
+ )
152
+ covering = next(
153
+ (
154
+ known
155
+ for known, relation in relationships
156
+ if relation is NetworkRelationship.BASELINE_COVERS_CURRENT
157
+ ),
158
+ None,
159
+ )
160
+ overlaps = [
161
+ known
162
+ for known, relation in relationships
163
+ if relation is not NetworkRelationship.DISJOINT
164
+ ]
165
+ if exact is not None:
166
+ unchanged.append(
167
+ _finding(
168
+ current_network,
169
+ "UNCHANGED_COVERAGE",
170
+ NetworkRelationship.EXACT,
171
+ exact,
172
+ False,
173
+ False,
174
+ )
175
+ )
176
+ elif covering is None and not overlaps:
177
+ new.append(
178
+ _finding(
179
+ current_network,
180
+ "NEW_REACHABILITY",
181
+ NetworkRelationship.DISJOINT,
182
+ None,
183
+ True,
184
+ True,
185
+ )
186
+ )
187
+ elif covering is None:
188
+ coverage.append(
189
+ _finding(
190
+ current_network,
191
+ "EXPANDED_REACHABILITY",
192
+ NetworkRelationship.CURRENT_COVERS_BASELINE,
193
+ overlaps[0],
194
+ True,
195
+ True,
196
+ )
197
+ )
198
+
199
+ for known_network in baseline_coverage:
200
+ relationships = [
201
+ (current_network, classify_relationship(known_network, current_network))
202
+ for current_network in current_coverage
203
+ if current_network.version == known_network.version
204
+ ]
205
+ retained = any(
206
+ relation
207
+ in (NetworkRelationship.EXACT, NetworkRelationship.CURRENT_COVERS_BASELINE)
208
+ for _, relation in relationships
209
+ )
210
+ if not retained:
211
+ related = next(
212
+ (
213
+ network
214
+ for network, relation in relationships
215
+ if relation is NetworkRelationship.BASELINE_COVERS_CURRENT
216
+ ),
217
+ None,
218
+ )
219
+ coverage.append(
220
+ _finding(
221
+ known_network,
222
+ "REDUCED_COVERAGE",
223
+ NetworkRelationship.BASELINE_COVERS_CURRENT
224
+ if related
225
+ else NetworkRelationship.DISJOINT,
226
+ related,
227
+ False,
228
+ True,
229
+ )
230
+ )
231
+
232
+ specificity = _specificity_findings(baseline.networks, current.networks)
233
+ contexts = _context_findings(baseline.networks, current.networks)
234
+ return DiffReport(
235
+ new_networks=tuple(_sorted_findings(new)),
236
+ specificity_changes=tuple(_sorted_findings(specificity)),
237
+ coverage_changes=tuple(_sorted_findings(coverage)),
238
+ unchanged_networks=tuple(_sorted_findings(unchanged)),
239
+ context_changes=tuple(_sorted_findings(contexts)),
240
+ )
241
+
242
+
243
+ def _specificity_findings(
244
+ baseline: tuple[BaselineNetwork, ...], current: tuple[BaselineNetwork, ...]
245
+ ) -> list[DiffFinding]:
246
+ findings = []
247
+ for entry in current:
248
+ for known in baseline:
249
+ relation = classify_relationship(known.network, entry.network)
250
+ if relation is NetworkRelationship.BASELINE_COVERS_CURRENT:
251
+ findings.append(
252
+ _finding(
253
+ ipaddress.ip_network(entry.network),
254
+ "MORE_SPECIFIC",
255
+ relation,
256
+ ipaddress.ip_network(known.network),
257
+ False,
258
+ True,
259
+ )
260
+ )
261
+ break
262
+ return findings
263
+
264
+
265
+ def _context_findings(
266
+ baseline: tuple[BaselineNetwork, ...], current: tuple[BaselineNetwork, ...]
267
+ ) -> list[DiffFinding]:
268
+ findings = []
269
+ for entry in current:
270
+ for known in baseline:
271
+ if entry.network != known.network:
272
+ continue
273
+ if _context(entry) != _context(known):
274
+ findings.append(
275
+ _finding(
276
+ ipaddress.ip_network(entry.network),
277
+ "ROUTE_CONTEXT_CHANGED",
278
+ NetworkRelationship.EXACT,
279
+ ipaddress.ip_network(known.network),
280
+ False,
281
+ True,
282
+ )
283
+ )
284
+ break
285
+ return findings
286
+
287
+
288
+ def _context(entry: BaselineNetwork) -> tuple[object, ...]:
289
+ return entry.interface, entry.gateway, entry.route_type, entry.confidence
290
+
291
+
292
+ def _finding(
293
+ network: ipaddress._BaseNetwork,
294
+ classification: str,
295
+ relationship: NetworkRelationship,
296
+ related: ipaddress._BaseNetwork | None,
297
+ reachability_novelty: bool,
298
+ topology_novelty: bool,
299
+ ) -> DiffFinding:
300
+ return DiffFinding(
301
+ str(network),
302
+ classification,
303
+ relationship,
304
+ str(related) if related else None,
305
+ reachability_novelty,
306
+ topology_novelty,
307
+ )
308
+
309
+
310
+ def _sorted_findings(findings: list[DiffFinding]) -> list[DiffFinding]:
311
+ return sorted(
312
+ findings,
313
+ key=lambda item: (
314
+ ipaddress.ip_network(item.network).version,
315
+ int(ipaddress.ip_network(item.network).network_address),
316
+ ipaddress.ip_network(item.network).prefixlen,
317
+ item.classification,
318
+ item.related_network or "",
319
+ ),
320
+ )