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.
- pivotcheck/__init__.py +6 -0
- pivotcheck/__main__.py +13 -0
- pivotcheck/analysis/__init__.py +57 -0
- pivotcheck/analysis/comparison.py +320 -0
- pivotcheck/analysis/evidence_gaps.py +319 -0
- pivotcheck/analysis/explanation.py +171 -0
- pivotcheck/analysis/gateway.py +266 -0
- pivotcheck/analysis/map_view.py +175 -0
- pivotcheck/analysis/next_step.py +319 -0
- pivotcheck/analysis/query.py +144 -0
- pivotcheck/analysis/recommendation.py +109 -0
- pivotcheck/analysis/summary.py +37 -0
- pivotcheck/analysis/topology.py +131 -0
- pivotcheck/analysis/transit_priority.py +133 -0
- pivotcheck/checks/__init__.py +30 -0
- pivotcheck/checks/context.py +252 -0
- pivotcheck/checks/proxy.py +452 -0
- pivotcheck/checks/resolver.py +101 -0
- pivotcheck/checks/tcp.py +129 -0
- pivotcheck/cli.py +1394 -0
- pivotcheck/discovery/__init__.py +22 -0
- pivotcheck/discovery/connections.py +150 -0
- pivotcheck/discovery/dns.py +44 -0
- pivotcheck/discovery/engine.py +37 -0
- pivotcheck/discovery/interfaces.py +142 -0
- pivotcheck/discovery/local.py +78 -0
- pivotcheck/discovery/neighbors.py +67 -0
- pivotcheck/discovery/provider.py +40 -0
- pivotcheck/discovery/routes.py +143 -0
- pivotcheck/discovery/ssh.py +236 -0
- pivotcheck/models/__init__.py +43 -0
- pivotcheck/models/baseline.py +86 -0
- pivotcheck/models/check.py +584 -0
- pivotcheck/models/network.py +271 -0
- pivotcheck/models/proxy_check.py +301 -0
- pivotcheck/models/result.py +77 -0
- pivotcheck/models/session.py +34 -0
- pivotcheck/output/__init__.py +18 -0
- pivotcheck/output/artifact.py +27 -0
- pivotcheck/output/check_output.py +201 -0
- pivotcheck/output/comparison.py +117 -0
- pivotcheck/output/evidence_gaps.py +69 -0
- pivotcheck/output/intelligence.py +79 -0
- pivotcheck/output/json.py +36 -0
- pivotcheck/output/map_view.py +123 -0
- pivotcheck/output/next_step.py +121 -0
- pivotcheck/output/proxy_check.py +105 -0
- pivotcheck/output/terminal.py +304 -0
- pivotcheck/output/writer.py +73 -0
- pivotcheck/storage/__init__.py +23 -0
- pivotcheck/storage/baseline_store.py +249 -0
- pivotcheck/utils/__init__.py +1 -0
- pivotcheck/utils/system.py +54 -0
- pivotcheck/utils/validation.py +22 -0
- pivotcheck-1.0.0.dist-info/METADATA +557 -0
- pivotcheck-1.0.0.dist-info/RECORD +60 -0
- pivotcheck-1.0.0.dist-info/WHEEL +5 -0
- pivotcheck-1.0.0.dist-info/entry_points.txt +2 -0
- pivotcheck-1.0.0.dist-info/licenses/LICENSE +674 -0
- pivotcheck-1.0.0.dist-info/top_level.txt +1 -0
pivotcheck/__init__.py
ADDED
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
|
+
)
|