pivotcheck 2.0.0__tar.gz → 2.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/PKG-INFO +1 -1
  2. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/__init__.py +1 -1
  3. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/explanation.py +28 -13
  4. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/cli.py +30 -0
  5. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck.egg-info/PKG-INFO +1 -1
  6. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_intelligence_cli.py +99 -1
  7. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_operator_intelligence.py +29 -0
  8. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/LICENSE +0 -0
  9. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/README.md +0 -0
  10. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/__main__.py +0 -0
  11. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/__init__.py +0 -0
  12. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/comparison.py +0 -0
  13. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/correlation.py +0 -0
  14. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/evidence_gaps.py +0 -0
  15. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/gateway.py +0 -0
  16. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/graph.py +0 -0
  17. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/map_view.py +0 -0
  18. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/next_step.py +0 -0
  19. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/opsec.py +0 -0
  20. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/query.py +0 -0
  21. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/recommendation.py +0 -0
  22. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/summary.py +0 -0
  23. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/topology.py +0 -0
  24. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/analysis/transit_priority.py +0 -0
  25. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/checks/__init__.py +0 -0
  26. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/checks/context.py +0 -0
  27. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/checks/proxy.py +0 -0
  28. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/checks/resolver.py +0 -0
  29. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/checks/smb.py +0 -0
  30. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/checks/ssh.py +0 -0
  31. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/checks/tcp.py +0 -0
  32. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/checks/winrm.py +0 -0
  33. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/discovery/__init__.py +0 -0
  34. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/discovery/connections.py +0 -0
  35. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/discovery/dns.py +0 -0
  36. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/discovery/engine.py +0 -0
  37. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/discovery/interfaces.py +0 -0
  38. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/discovery/local.py +0 -0
  39. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/discovery/macos.py +0 -0
  40. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/discovery/neighbors.py +0 -0
  41. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/discovery/provider.py +0 -0
  42. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/discovery/remote.py +0 -0
  43. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/discovery/routes.py +0 -0
  44. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/discovery/ssh.py +0 -0
  45. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/discovery/windows.py +0 -0
  46. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/__init__.py +0 -0
  47. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/baseline.py +0 -0
  48. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/check.py +0 -0
  49. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/correlation.py +0 -0
  50. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/credentials.py +0 -0
  51. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/graph.py +0 -0
  52. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/network.py +0 -0
  53. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/opsec.py +0 -0
  54. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/proxy_check.py +0 -0
  55. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/result.py +0 -0
  56. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/session.py +0 -0
  57. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/smb_check.py +0 -0
  58. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/ssh_check.py +0 -0
  59. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/models/winrm_check.py +0 -0
  60. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/__init__.py +0 -0
  61. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/artifact.py +0 -0
  62. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/check_output.py +0 -0
  63. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/comparison.py +0 -0
  64. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/evidence_gaps.py +0 -0
  65. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/intelligence.py +0 -0
  66. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/json.py +0 -0
  67. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/map_view.py +0 -0
  68. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/next_step.py +0 -0
  69. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/opsec.py +0 -0
  70. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/proxy_check.py +0 -0
  71. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/smb_check.py +0 -0
  72. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/ssh_check.py +0 -0
  73. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/terminal.py +0 -0
  74. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/winrm_check.py +0 -0
  75. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/output/writer.py +0 -0
  76. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/storage/__init__.py +0 -0
  77. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/storage/baseline_store.py +0 -0
  78. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/utils/__init__.py +0 -0
  79. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/utils/credential_loader.py +0 -0
  80. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/utils/redaction.py +0 -0
  81. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/utils/system.py +0 -0
  82. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck/utils/validation.py +0 -0
  83. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck.egg-info/SOURCES.txt +0 -0
  84. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck.egg-info/dependency_links.txt +0 -0
  85. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck.egg-info/entry_points.txt +0 -0
  86. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck.egg-info/requires.txt +0 -0
  87. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pivotcheck.egg-info/top_level.txt +0 -0
  88. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/pyproject.toml +0 -0
  89. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/setup.cfg +0 -0
  90. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_baseline_store.py +0 -0
  91. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_baseline_workflow.py +0 -0
  92. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_check_cli.py +0 -0
  93. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_cli.py +0 -0
  94. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_cli_contract.py +0 -0
  95. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_comparison.py +0 -0
  96. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_context.py +0 -0
  97. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_correlation.py +0 -0
  98. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_credentials.py +0 -0
  99. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_determinism.py +0 -0
  100. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_gateway.py +0 -0
  101. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_graph.py +0 -0
  102. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_intelligence_pure.py +0 -0
  103. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_interfaces.py +0 -0
  104. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_json_schema_audit.py +0 -0
  105. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_map_view.py +0 -0
  106. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_neighbors_dns.py +0 -0
  107. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_network_safety.py +0 -0
  108. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_next_step.py +0 -0
  109. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_next_step_cli.py +0 -0
  110. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_opsec.py +0 -0
  111. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_output_encoding.py +0 -0
  112. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_platform_collectors.py +0 -0
  113. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_proxy_check.py +0 -0
  114. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_proxy_check_cli.py +0 -0
  115. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_proxy_protocol.py +0 -0
  116. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_remote_session.py +0 -0
  117. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_resolver.py +0 -0
  118. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_routes.py +0 -0
  119. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_session_provider.py +0 -0
  120. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_side_effects.py +0 -0
  121. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_smb_check.py +0 -0
  122. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_smb_check_cli.py +0 -0
  123. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_ssh_check.py +0 -0
  124. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_ssh_check_cli.py +0 -0
  125. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_ssh_check_integration.py +0 -0
  126. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_ssh_cli.py +0 -0
  127. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_ssh_provider.py +0 -0
  128. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_tcp.py +0 -0
  129. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_terminal.py +0 -0
  130. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_topology.py +0 -0
  131. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_transit_priority.py +0 -0
  132. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_validation_context.py +0 -0
  133. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_validation_context_cli.py +0 -0
  134. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_winrm_check.py +0 -0
  135. {pivotcheck-2.0.0 → pivotcheck-2.0.2}/tests/test_winrm_check_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pivotcheck
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Passive network discovery and pivot path validation for authorized security assessments
5
5
  Author: PivotCheck contributors
6
6
  License: GPL-3.0-only
@@ -3,4 +3,4 @@
3
3
  Copyright (c) 2026 PivotCheck contributors. GPL-3.0-only licensed.
4
4
  """
5
5
 
6
- __version__ = "2.0.0"
6
+ __version__ = "2.0.2"
@@ -47,9 +47,11 @@ def _finding(network: str, report: DiffReport) -> DiffFinding | None:
47
47
  return None
48
48
 
49
49
 
50
- def _reason(finding: DiffFinding | None) -> str:
50
+ def _reason(finding: DiffFinding | None, observed: bool = True) -> str:
51
51
  if finding is None:
52
- return "Network observed in current discovery evidence."
52
+ if observed:
53
+ return "Network observed in current discovery evidence."
54
+ return "Network not found in current discovery evidence."
53
55
  reasons = {
54
56
  "NEW_REACHABILITY": "Newly observed network not present in baseline.",
55
57
  "EXPANDED_REACHABILITY": "Network coverage expanded relative to baseline.",
@@ -73,21 +75,34 @@ def explain_network(
73
75
  If not, provides standalone explanation from current evidence only.
74
76
  """
75
77
  canonical = str(ipaddress.ip_network(network, strict=False))
76
-
77
- if report is not None:
78
- finding = _finding(canonical, report)
79
- classification = finding.classification if finding else "CURRENT_EVIDENCE"
80
- reason = _reason(finding)
81
- else:
82
- # Standalone mode: no comparison context
83
- classification = "CURRENT_EVIDENCE"
84
- reason = "Network observed in current discovery evidence."
85
-
78
+
86
79
  evidence = next(
87
- (item for item in current.networks
80
+ (item for item in current.networks
88
81
  if str(ipaddress.ip_network(item.cidr, strict=False)) == canonical),
89
82
  None
90
83
  )
84
+ observed = evidence is not None
85
+
86
+ if report is not None:
87
+ finding = _finding(canonical, report)
88
+ if finding is not None:
89
+ classification = finding.classification
90
+ reason = _reason(finding)
91
+ else:
92
+ # Comparison context covers a different scope than the current
93
+ # snapshot: classification follows what the CURRENT evidence
94
+ # actually shows, never an assumed observation.
95
+ classification = "CURRENT_EVIDENCE" if observed else "NOT_OBSERVED"
96
+ reason = _reason(None, observed)
97
+ else:
98
+ # Standalone mode: no comparison context.
99
+ if observed:
100
+ classification = "CURRENT_EVIDENCE"
101
+ reason = _reason(None, observed)
102
+ else:
103
+ classification = "NOT_OBSERVED"
104
+ reason = _reason(None, observed)
105
+
91
106
  routes = _route_evidence(canonical, current)
92
107
 
93
108
  # Get transit evidence if available
@@ -32,6 +32,7 @@ from __future__ import annotations
32
32
 
33
33
  import argparse
34
34
  import getpass
35
+ import ipaddress
35
36
  import os
36
37
  import re
37
38
  import sys
@@ -1674,8 +1675,33 @@ def _run_next(args: argparse.Namespace) -> int:
1674
1675
  return EXIT_OK
1675
1676
 
1676
1677
 
1678
+ def _validate_network_argument(network: str) -> int | None:
1679
+ """Validate a network CLI argument against the canonical CIDR model.
1680
+
1681
+ Returns ``None`` when the argument is valid, otherwise ``EXIT_USAGE``
1682
+ after printing the standard operator error. Validation is pure: invalid
1683
+ input fails before discovery (no filesystem, network, or subprocess
1684
+ side effects), and the original argument is passed through to analysis
1685
+ unchanged, so valid-path behavior is byte-identical.
1686
+ """
1687
+ try:
1688
+ ipaddress.ip_network(network, strict=False)
1689
+ except ValueError:
1690
+ print(f"[-] Invalid network argument: {network!r}.", file=sys.stderr)
1691
+ print(
1692
+ " Provide a CIDR network (e.g. 10.50.0.0/16) or an IP address.",
1693
+ file=sys.stderr,
1694
+ )
1695
+ return EXIT_USAGE
1696
+ return None
1697
+
1698
+
1677
1699
  def _run_gaps(args: argparse.Namespace) -> int:
1678
1700
  """Execute evidence gap analysis."""
1701
+ error = _validate_network_argument(args.network)
1702
+ if error is not None:
1703
+ return error
1704
+
1679
1705
  # Run discovery once
1680
1706
  try:
1681
1707
  snapshot = run_discovery()
@@ -1700,6 +1726,10 @@ def _run_gaps(args: argparse.Namespace) -> int:
1700
1726
 
1701
1727
  def _run_explain(args: argparse.Namespace) -> int:
1702
1728
  """Execute standalone network explanation."""
1729
+ error = _validate_network_argument(args.network)
1730
+ if error is not None:
1731
+ return error
1732
+
1703
1733
  # If the operator explicitly requested contextual baseline analysis,
1704
1734
  # load the baseline BEFORE any discovery or socket activity. A missing
1705
1735
  # or invalid baseline fails explicitly rather than silently performing
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pivotcheck
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Passive network discovery and pivot path validation for authorized security assessments
5
5
  Author: PivotCheck contributors
6
6
  License: GPL-3.0-only
@@ -267,4 +267,102 @@ class TestDiscoverSummary:
267
267
  out = capsys.readouterr().out
268
268
  assert "Connected Coverage: 1" in out
269
269
  assert "Routed Coverage: 2" in out
270
- assert "Inferred Pivot Paths: 1" in out
270
+ assert "Inferred Pivot Paths: 1" in out
271
+
272
+
273
+ class TestNetworkArgumentValidation:
274
+ """Stage 9 DEFECT-002 regression: invalid network arguments must follow
275
+ the established CLI error contract (clean `[-]` message on stderr, no
276
+ Python traceback, EXIT_USAGE, no partial output, no side effects).
277
+
278
+ These tests fail against the defective 2.0.0 implementation, which
279
+ leaked an uncaught ValueError traceback from models/network.py and
280
+ exited 1.
281
+ """
282
+
283
+ BAD_NETWORKS = (
284
+ "not-a-cidr",
285
+ "999.999.1.0/24", # malformed IPv4 octets
286
+ "10.0.0.0/33", # invalid IPv4 prefix length
287
+ "10.0.0.0/120", # IPv6 prefix length applied to IPv4
288
+ "10.0.0.0/24/24", # double prefix
289
+ "2001:db8::/wxyz", # malformed IPv6 prefix
290
+ )
291
+
292
+ @pytest.fixture(autouse=True)
293
+ def fake_discovery(self, monkeypatch):
294
+ from pivotcheck import cli
295
+
296
+ monkeypatch.setattr(cli, "run_discovery", make_snapshot)
297
+
298
+ @pytest.mark.parametrize("bad", BAD_NETWORKS)
299
+ def test_gaps_invalid_network_is_usage_error(self, bad, capsys):
300
+ assert _run(["gaps", bad]) == EXIT_USAGE
301
+ err = capsys.readouterr().err
302
+ assert "Invalid network argument" in err
303
+ assert "Traceback" not in err
304
+ assert "ValueError" not in err
305
+
306
+ @pytest.mark.parametrize("bad", BAD_NETWORKS)
307
+ def test_explain_invalid_network_is_usage_error(self, bad, capsys):
308
+ assert _run(["explain", bad]) == EXIT_USAGE
309
+ err = capsys.readouterr().err
310
+ assert "Invalid network argument" in err
311
+ assert "Traceback" not in err
312
+
313
+ @pytest.mark.parametrize("bad", BAD_NETWORKS)
314
+ def test_invalid_network_json_path_has_no_partial_result(self, bad, capsys):
315
+ for command in ("gaps", "explain"):
316
+ assert _run([command, bad, "--json"]) == EXIT_USAGE
317
+ captured = capsys.readouterr()
318
+ # No partial JSON artifact may pretend the request succeeded.
319
+ assert captured.out.strip() == ""
320
+ assert "Traceback" not in captured.err
321
+
322
+ def test_invalid_network_fails_before_discovery(self, monkeypatch, capsys):
323
+ """Invalid input must produce zero side effects: discovery (and any
324
+ I/O behind it) must never run for an invalid network argument."""
325
+ from pivotcheck import cli
326
+
327
+ def _must_not_run(*_args, **_kwargs):
328
+ raise AssertionError("discovery must not run for invalid input")
329
+
330
+ monkeypatch.setattr(cli, "run_discovery", _must_not_run)
331
+ assert _run(["gaps", "not-a-cidr"]) == EXIT_USAGE
332
+ assert "Invalid network argument" in capsys.readouterr().err
333
+
334
+ def test_valid_cidr_behavior_unchanged(self, capsys):
335
+ assert _run(["gaps", "10.50.0.0/16"]) == EXIT_OK
336
+ out = capsys.readouterr().out
337
+ assert "10.50.0.0/16" in out
338
+ assert "EVIDENCE GAP" in out
339
+
340
+ def test_bare_ip_still_valid(self, capsys):
341
+ assert _run(["explain", "10.50.5.1"]) == EXIT_OK
342
+ assert "10.50.0.0/16" in capsys.readouterr().out
343
+
344
+ def test_host_bits_set_still_accepted(self, capsys):
345
+ # strict=False semantics are preserved: host bits are normalized,
346
+ # exactly as in 2.0.0.
347
+ assert _run(["gaps", "10.50.5.1/16"]) == EXIT_OK
348
+
349
+ def test_ipv6_cidr_still_valid(self, capsys):
350
+ assert _run(["explain", "2001:db8:a::/48"]) == EXIT_OK
351
+ out = capsys.readouterr().out
352
+ assert "2001:db8:a::/48" in out
353
+ assert "CURRENT_EVIDENCE" in out
354
+
355
+ def test_explain_unobserved_network_is_not_observed(self, capsys):
356
+ """Stage 9 DEFECT-001 regression at the CLI boundary: absence of
357
+ observation must never be promoted to CURRENT_EVIDENCE. Fails
358
+ against 2.0.0, which reported CURRENT_EVIDENCE here."""
359
+ assert _run(["explain", "192.168.99.0/24"]) == EXIT_OK
360
+ out = capsys.readouterr().out
361
+ assert "NOT_OBSERVED" in out
362
+ assert "CURRENT_EVIDENCE" not in out
363
+
364
+ def test_explain_unobserved_network_json_classification(self, capsys):
365
+ assert _run(["explain", "192.168.99.0/24", "--json"]) == EXIT_OK
366
+ payload = json.loads(capsys.readouterr().out)
367
+ assert payload["classification"] == "NOT_OBSERVED"
368
+ assert payload["reason"] == "Network not found in current discovery evidence."
@@ -63,3 +63,32 @@ def test_recommendations_are_deterministic_when_input_order_changes():
63
63
  first = DiscoverySnapshot("h", "o", networks=(DiscoveredNetwork("192.168.0.0/24", NetworkOrigin.CONNECTED, Confidence.HIGH), DiscoveredNetwork("172.16.0.0/24", NetworkOrigin.CONNECTED, Confidence.HIGH)))
64
64
  second = DiscoverySnapshot("h", "o", networks=tuple(reversed(first.networks)))
65
65
  assert recommend(first, report) == recommend(second, report)
66
+
67
+
68
+ def test_explain_unobserved_network_is_not_reported_as_observed():
69
+ """Stage 9 regression: explain() on a network absent from the current
70
+ snapshot must be classified NOT_OBSERVED - never CURRENT_EVIDENCE."""
71
+ snapshot = DiscoverySnapshot("host", "OS", networks=(
72
+ DiscoveredNetwork("172.16.0.0/24", NetworkOrigin.CONNECTED, Confidence.HIGH, "eth0"),
73
+ ))
74
+ standalone = explain_network("10.9.9.0/24", snapshot)
75
+ assert standalone.classification == "NOT_OBSERVED"
76
+ assert "not found in current discovery evidence" in standalone.reason.lower()
77
+ assert standalone.origin is None and standalone.interface is None
78
+
79
+ with_report = explain_network(
80
+ "10.9.9.0/24", snapshot,
81
+ compare(perspective(BaselineNetwork("172.16.0.0/24", NetworkOrigin.CONNECTED, Confidence.HIGH)), perspective()),
82
+ )
83
+ assert with_report.classification == "NOT_OBSERVED"
84
+ assert "not found in current discovery evidence" in with_report.reason.lower()
85
+
86
+
87
+ def test_explain_observed_network_still_current_evidence():
88
+ snapshot = DiscoverySnapshot("host", "OS", networks=(
89
+ DiscoveredNetwork("172.16.0.0/24", NetworkOrigin.CONNECTED, Confidence.HIGH, "eth0"),
90
+ ))
91
+ explanation = explain_network("172.16.0.0/24", snapshot)
92
+ assert explanation.classification == "CURRENT_EVIDENCE"
93
+ assert "observed in current discovery evidence" in explanation.reason.lower()
94
+ assert explanation.origin == "connected"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes