watchpost-cli 0.0.1__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.
watchpost.py ADDED
@@ -0,0 +1,304 @@
1
+ """Inspect, compare, and report saved Nmap observations without network requests."""
2
+
3
+ import argparse
4
+ import re
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from ipaddress import IPv4Address
8
+ from pathlib import Path
9
+ from xml.etree.ElementTree import Element, ParseError
10
+
11
+ from defusedxml import ElementTree
12
+ from defusedxml.common import DefusedXmlException
13
+
14
+ from watchpost_report import render_markdown, write_report
15
+
16
+ # ponytail: cap snapshots at 10 MiB for small labs; stream if larger inputs are needed.
17
+ MAX_XML_BYTES = 10 * 1024 * 1024
18
+ PORT_STATES = {"open", "closed", "filtered", "unfiltered", "open|filtered", "closed|filtered"}
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Snapshot:
23
+ source: Path
24
+ started: datetime
25
+ finished: datetime
26
+ method: str
27
+ version: str
28
+ coverage: frozenset[int]
29
+ inventory: dict[str, dict[int, str]]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Change:
34
+ address: str
35
+ port: int | None
36
+ before: str | None
37
+ after: str | None
38
+
39
+
40
+ def _read_root(path: Path) -> Element:
41
+ """Safely load a completed TCP Nmap export without resolving external content."""
42
+ if not path.is_file():
43
+ raise ValueError("Snapshot must be an existing regular file. Check the input path.")
44
+ with path.open("rb") as source:
45
+ data = source.read(MAX_XML_BYTES + 1)
46
+ if len(data) > MAX_XML_BYTES:
47
+ raise ValueError("Snapshot exceeds the 10 MiB limit. Use a smaller lab snapshot.")
48
+ try:
49
+ root = ElementTree.fromstring(data, forbid_entities=True, forbid_external=True)
50
+ except DefusedXmlException as exc:
51
+ raise ValueError(
52
+ "Unsafe XML. Entity declarations and external entities are not allowed."
53
+ ) from exc
54
+ except (ParseError, LookupError, ValueError) as exc:
55
+ raise ValueError(
56
+ "Malformed XML or unsupported encoding. Use a complete UTF-8 Nmap export."
57
+ ) from exc
58
+
59
+ if root.tag != "nmaprun" or root.get("scanner") != "nmap":
60
+ raise ValueError("Expected an Nmap XML export with an nmaprun root.")
61
+ finished = root.findall("runstats/finished")
62
+ if len(finished) != 1 or finished[0].get("exit") != "success":
63
+ raise ValueError("Scan is not confirmed complete. Use a successfully finished Nmap export.")
64
+ scan_info = root.findall("scaninfo")
65
+ if not scan_info or any(info.get("protocol") != "tcp" for info in scan_info):
66
+ raise ValueError("Only scans with TCP scaninfo are supported. Use a TCP-only export.")
67
+
68
+ return root
69
+
70
+
71
+ def _inventory_from_xml(root: Element) -> dict[str, dict[int, str]]:
72
+ inventory = {}
73
+ for host in root.findall("host"):
74
+ if host.get("timedout") not in (None, "false", "0"):
75
+ raise ValueError("Host scan is incomplete (timeout). Use a completed snapshot.")
76
+ if host.find("address[@addrtype='ipv6']") is not None:
77
+ raise ValueError("Only IPv4 addresses are supported. Use an IPv4-only export.")
78
+ addresses = host.findall("address[@addrtype='ipv4']")
79
+ if len(addresses) != 1:
80
+ raise ValueError("Each host must have exactly one IPv4 address.")
81
+ try:
82
+ address = str(IPv4Address(addresses[0].get("addr", "")))
83
+ except ValueError as exc:
84
+ raise ValueError("Invalid IPv4 address. Check the host address in the export.") from exc
85
+ if address in inventory:
86
+ raise ValueError(f"Duplicate IPv4 host {address}. Use an unambiguous snapshot.")
87
+
88
+ ports = {}
89
+ for port in host.findall("ports/port"):
90
+ if port.get("protocol") != "tcp":
91
+ raise ValueError("Only TCP port observations are supported.")
92
+ port_id = port.get("portid", "")
93
+ if not port_id.isascii() or not port_id.isdecimal() or len(port_id) > 5:
94
+ raise ValueError("Invalid TCP port number. Expected a number from 0 to 65535.")
95
+ number = int(port_id)
96
+ if number > 65535:
97
+ raise ValueError("Invalid TCP port number. Expected a number from 0 to 65535.")
98
+ if number in ports:
99
+ raise ValueError(f"Duplicate TCP port {number} for {address}.")
100
+ states = port.findall("state")
101
+ if len(states) != 1 or states[0].get("state") not in PORT_STATES:
102
+ raise ValueError("Each explicit port must have one recognized Nmap port state.")
103
+ ports[number] = states[0].attrib["state"]
104
+ inventory[address] = ports
105
+ return inventory
106
+
107
+
108
+ def read_inventory(path: Path) -> dict[str, dict[int, str]]:
109
+ """Read explicit IPv4/TCP observations. Do not expand aggregated port counts."""
110
+ return _inventory_from_xml(_read_root(path))
111
+
112
+
113
+ def _scan_time(value: str) -> datetime:
114
+ if not re.fullmatch(r"[0-9]{1,12}", value):
115
+ raise ValueError("Comparison requires valid Unix start and finish timestamps.")
116
+ try:
117
+ return datetime.fromtimestamp(int(value), UTC)
118
+ except (ValueError, OverflowError, OSError) as exc:
119
+ raise ValueError("Scan timestamp is outside the supported calendar range.") from exc
120
+
121
+
122
+ def _port_coverage(services: str) -> frozenset[int]:
123
+ ports: set[int] = set()
124
+ for token in services.split(","):
125
+ if not re.fullmatch(r"[0-9]{1,5}(?:-[0-9]{1,5})?", token):
126
+ raise ValueError(
127
+ "Invalid TCP coverage. Expected ports or ranges such as 22,80,443-445."
128
+ )
129
+ bounds = [int(value) for value in token.split("-")]
130
+ first, last = bounds[0], bounds[-1]
131
+ if first > last or last > 65535:
132
+ raise ValueError("Invalid TCP coverage range. Ports must be between 0 and 65535.")
133
+ for number in range(first, last + 1):
134
+ if number in ports:
135
+ raise ValueError("Duplicate or overlapping ports in declared TCP coverage.")
136
+ ports.add(number)
137
+ return frozenset(ports)
138
+
139
+
140
+ def read_snapshot(path: Path) -> Snapshot:
141
+ """Read the stronger metadata needed for a conservative comparison."""
142
+ root = _read_root(path)
143
+ inventory = _inventory_from_xml(root)
144
+ scans = root.findall("scaninfo")
145
+ if len(scans) != 1 or scans[0].get("type") not in {"connect", "syn"}:
146
+ raise ValueError("Comparison supports exactly one TCP connect or SYN scan per snapshot.")
147
+ version = root.get("version", "")
148
+ if not re.fullmatch(r"[0-9][A-Za-z0-9._+-]{0,63}", version):
149
+ raise ValueError("Comparison requires a valid Nmap version in each snapshot.")
150
+ started = _scan_time(root.get("start", ""))
151
+ finished = _scan_time(root.findall("runstats/finished")[0].get("time", ""))
152
+ if finished < started:
153
+ raise ValueError(
154
+ "Scan finish time is before its start time. Check the snapshot timestamps."
155
+ )
156
+ coverage = _port_coverage(scans[0].get("services", ""))
157
+ count = scans[0].get("numservices", "")
158
+ if not re.fullmatch(r"[0-9]{1,5}", count) or int(count) != len(coverage):
159
+ raise ValueError("Declared TCP port count does not match the port coverage.")
160
+ for ports in inventory.values():
161
+ if not set(ports).issubset(coverage):
162
+ raise ValueError("Explicit port observations fall outside the declared TCP coverage.")
163
+ return Snapshot(path, started, finished, scans[0].attrib["type"], version, coverage, inventory)
164
+
165
+
166
+ def compare_snapshots(
167
+ before: Snapshot, after: Snapshot, *, same_context_confirmed: bool = False
168
+ ) -> list[Change]:
169
+ """Compare supported observations; None means not observed, never closed."""
170
+ if not same_context_confirmed:
171
+ raise ValueError(
172
+ "Confirm the same scan location, intended targets, and other scan settings."
173
+ )
174
+ if before.method != after.method:
175
+ raise ValueError("Scan methods differ. Compare snapshots from the same scan method.")
176
+ if before.version != after.version:
177
+ raise ValueError("Nmap versions differ. Compare snapshots from the same Nmap version.")
178
+ if before.coverage != after.coverage:
179
+ raise ValueError(
180
+ "TCP port coverage differs. Compare snapshots with identical port coverage."
181
+ )
182
+ if after.started < before.started:
183
+ raise ValueError("Snapshots are reversed. Supply the earlier scan first.")
184
+ repeat_observations = (
185
+ before.started == after.started
186
+ and before.finished == after.finished
187
+ and before.inventory == after.inventory
188
+ )
189
+ if after.started < before.finished and not repeat_observations:
190
+ raise ValueError("Scan windows overlap. Use non-overlapping earlier and later scans.")
191
+
192
+ changes = []
193
+ for address in sorted(before.inventory.keys() | after.inventory.keys(), key=IPv4Address):
194
+ if (address in before.inventory) != (address in after.inventory):
195
+ changes.append(
196
+ Change(
197
+ address,
198
+ None,
199
+ "observed" if address in before.inventory else None,
200
+ "observed" if address in after.inventory else None,
201
+ )
202
+ )
203
+ old_ports = before.inventory.get(address, {})
204
+ new_ports = after.inventory.get(address, {})
205
+ for port in sorted(old_ports.keys() | new_ports.keys()):
206
+ old_state, new_state = old_ports.get(port), new_ports.get(port)
207
+ if old_state != new_state:
208
+ changes.append(Change(address, port, old_state, new_state))
209
+ return changes
210
+
211
+
212
+ def _format_comparison(before: Snapshot, after: Snapshot, changes: list[Change]) -> str:
213
+ lines = [
214
+ "Snapshot comparison (not a security verdict)",
215
+ f"Earlier: {str(before.source)!a}",
216
+ f" UTC window: {before.started.isoformat()} -> {before.finished.isoformat()}",
217
+ f"Later: {str(after.source)!a}",
218
+ f" UTC window: {after.started.isoformat()} -> {after.finished.isoformat()}",
219
+ f"Scan: TCP {before.method}; Nmap {before.version}; {len(before.coverage)} declared ports",
220
+ "Context: same location, intended targets, and other settings confirmed by user.",
221
+ f"Observation differences: {len(changes)}",
222
+ ]
223
+ if not changes:
224
+ lines.append("No observed changes in explicit IPv4/TCP records.")
225
+ for change in changes:
226
+ target = change.address
227
+ if change.port is None:
228
+ label = "ADDRESS OBSERVATION"
229
+ else:
230
+ target += f" {change.port}/tcp"
231
+ label = (
232
+ "STATE CHANGE"
233
+ if change.before is not None and change.after is not None
234
+ else "PORT OBSERVATION"
235
+ )
236
+ lines.append(
237
+ f"{label} {target}: {change.before or 'not observed'} -> "
238
+ f"{change.after or 'not observed'}"
239
+ )
240
+ lines.extend(
241
+ [
242
+ "Missing and grouped port records are not classified as closed.",
243
+ "IP addresses are not device identities; port numbers are not application identities.",
244
+ "Context is user-confirmed, not independently verified by Watchpost.",
245
+ ]
246
+ )
247
+ return "\n".join(lines)
248
+
249
+
250
+ def main(argv: list[str] | None = None) -> int:
251
+ parser = argparse.ArgumentParser(
252
+ prog="watchpost",
253
+ description="Inspect and compare saved Nmap XML locally. No scans or network requests.",
254
+ )
255
+ commands = parser.add_subparsers(dest="command", required=True)
256
+ inspect = commands.add_parser(
257
+ "inspect", help="List explicit IPv4/TCP observations in one scan."
258
+ )
259
+ inspect.add_argument("snapshot", type=Path, help="Path to a completed Nmap XML export.")
260
+ compare = commands.add_parser("compare", help="Compare two saved IPv4/TCP scans.")
261
+ compare.add_argument("before", type=Path, help="Earlier completed Nmap XML export.")
262
+ compare.add_argument("after", type=Path, help="Later completed Nmap XML export.")
263
+ compare.add_argument(
264
+ "--confirm-same-context",
265
+ action="store_true",
266
+ required=True,
267
+ help="Confirm the same scan location, intended targets, and other Nmap settings.",
268
+ )
269
+ compare.add_argument(
270
+ "--output", type=Path, help="Save a Markdown report to a new file; never overwrite."
271
+ )
272
+ args = parser.parse_args(argv)
273
+ try:
274
+ if args.command == "compare":
275
+ before, after = read_snapshot(args.before), read_snapshot(args.after)
276
+ changes = compare_snapshots(
277
+ before, after, same_context_confirmed=args.confirm_same_context
278
+ )
279
+ if args.output is None:
280
+ print(_format_comparison(before, after, changes))
281
+ else:
282
+ write_report(args.output, render_markdown(before, after, changes))
283
+ print(f"Report saved to {str(args.output)!a}")
284
+ return 0
285
+ inventory = read_inventory(args.snapshot)
286
+ except (OSError, ValueError) as exc:
287
+ parser.exit(2, f"watchpost: error: {exc}\n")
288
+
289
+ print("IPv4/TCP observations (not a security verdict)")
290
+ if not inventory:
291
+ print("No IPv4 host observations.")
292
+ for address in sorted(inventory, key=IPv4Address):
293
+ print(address)
294
+ ports = inventory[address]
295
+ if not ports:
296
+ print(" No explicit TCP port observations.")
297
+ for number, state in sorted(ports.items()):
298
+ print(f" {number}/tcp {state}")
299
+ print("Only explicit port observations are listed; missing ports are not classified.")
300
+ return 0
301
+
302
+
303
+ if __name__ == "__main__":
304
+ raise SystemExit(main())
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.4
2
+ Name: watchpost-cli
3
+ Version: 0.0.1
4
+ Summary: Local-first, evidence-backed reviews of changes between saved Nmap scans.
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/ejames-dev/watchpost
7
+ Project-URL: Documentation, https://github.com/ejames-dev/watchpost/wiki
8
+ Project-URL: Issues, https://github.com/ejames-dev/watchpost/issues
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: defusedxml<0.8,>=0.7.1
13
+ Dynamic: license-file
14
+
15
+ # Watchpost
16
+
17
+ Local-first, evidence-backed reviews of changes between saved Nmap scans.
18
+
19
+ **Status: inspection, comparison, Markdown reporting, and synthetic walkthroughs implemented.**
20
+ Two saved XML files can produce a change-review report. The [three walkthroughs](docs/scenarios.md)
21
+ cover expected deployment, unintended backend exposure, and an incomplete scan.
22
+ [Published releases](https://github.com/ejames-dev/watchpost/releases) are listed on GitHub.
23
+ A source checkout can contain unreleased changes.
24
+ See the [release notes](CHANGELOG.md) and [release-readiness checklist](docs/release-v0.0.1.md).
25
+
26
+ **[User wiki](https://github.com/ejames-dev/watchpost/wiki)** — setup, report interpretation,
27
+ scenarios, troubleshooting, and safety.
28
+
29
+ ## Package name
30
+
31
+ The distribution name for PyPI and TestPyPI is **`watchpost-cli`**.
32
+ The command and Python module remain `watchpost`. The GitHub repository remains `ejames-dev/watchpost`.
33
+ The registry package named `watchpost` belongs to an unrelated project. Do not install it for this tool.
34
+ Until registry publication is confirmed, use the source-checkout instructions below.
35
+ See [publishing setup](docs/publishing.md) for the maintainer workflow.
36
+
37
+ ## Scope
38
+
39
+ Watchpost is for students and home-lab operators working on authorized networks.
40
+ Inspect a saved Nmap XML file or compare two snapshots of explicit IPv4/TCP observations.
41
+ Watchpost does not launch Nmap, discover devices, or make network requests.
42
+
43
+ The approved target is in [the v0.1 project brief](docs/brief-v0.1.md).
44
+
45
+ ## Try the synthetic demo
46
+
47
+ With Python 3.11+ and [uv](https://docs.astral.sh/uv/) installed:
48
+
49
+ ```bash
50
+ git clone https://github.com/ejames-dev/watchpost.git
51
+ cd watchpost
52
+ uv sync --locked --dev --python 3.11
53
+ uv run --offline watchpost inspect examples/baseline.xml
54
+ ```
55
+
56
+ ```text
57
+ IPv4/TCP observations (not a security verdict)
58
+ 192.0.2.10
59
+ 22/tcp open
60
+ 80/tcp closed
61
+ 443/tcp open
62
+ 3000/tcp closed
63
+ 192.0.2.20
64
+ 22/tcp open
65
+ Only explicit port observations are listed; missing ports are not classified.
66
+ ```
67
+
68
+ Nmap is not required. These addresses and states are fictional.
69
+ For an existing checkout, run the final two commands from its root directory.
70
+
71
+ ## Compare two snapshots
72
+
73
+ ```bash
74
+ uv run --offline watchpost compare examples/baseline.xml examples/after.xml \
75
+ --confirm-same-context
76
+ ```
77
+
78
+ The synthetic second scan is one minute later. Its only explicit state change is:
79
+
80
+ ```text
81
+ STATE CHANGE 192.0.2.10 3000/tcp: closed -> open
82
+ ```
83
+
84
+ The output includes both source paths, UTC scan windows, Nmap version, scan method,
85
+ declared port count, differences, and limitations. Without `--output`, the command prints
86
+ only to the terminal.
87
+
88
+ **Before using real inputs, confirm the same scan location, intended targets, and remaining
89
+ Nmap settings.** The flag records your confirmation. It does not let Watchpost verify the
90
+ network location, reconstruct the full command line, or override failed metadata checks.
91
+ Do not use it to force a comparison you know is incompatible.
92
+
93
+ Comparison currently requires:
94
+
95
+ - One TCP connect or SYN scan per snapshot, with the same method and Nmap version.
96
+ - Identical declared TCP port coverage. Equivalent lists and ranges are normalized.
97
+ - A declared port count that matches that coverage, with all explicit ports inside it.
98
+ - Successful scan completion and valid start/end timestamps.
99
+ - Earlier and later scan windows that do not overlap. Equal windows with identical supported
100
+ observations are allowed, so comparing a snapshot with itself produces no observed changes.
101
+
102
+ | Evidence | Output |
103
+ |---|---|
104
+ | Explicit states differ | `STATE CHANGE ... closed -> open` |
105
+ | A port has no earlier explicit record | `PORT OBSERVATION ... not observed -> open` |
106
+ | A port has no later explicit record | `PORT OBSERVATION ... open -> not observed` |
107
+ | An address appears in only one snapshot | `ADDRESS OBSERVATION`, not a device addition/removal claim |
108
+ | Supported explicit observations match | `No observed changes in explicit IPv4/TCP records.` |
109
+
110
+ A newly observed port is not proof that a service just started. A missing port is not proof of
111
+ closure. Grouped records and uncertain states do not become guessed open/closed results.
112
+ See the [comparison milestone](docs/compare-milestone.md) for boundaries and verification.
113
+
114
+ ## Save a Markdown report
115
+
116
+ Create the output directory, then generate a report:
117
+
118
+ ```bash
119
+ mkdir -p reports
120
+ uv run --offline watchpost compare examples/baseline.xml examples/after.xml \
121
+ --confirm-same-context --output reports/review.md
122
+ ```
123
+
124
+ Open `reports/review.md` in a text editor or Markdown viewer.
125
+ See [the synthetic sample report](examples/report.md) for the expected result.
126
+
127
+ Each report includes:
128
+
129
+ - Source paths, UTC scan windows, scan metadata, and user-confirmed assumptions.
130
+ - Separate counts for changed states, newly observed records, and records not observed later.
131
+ - Each finding's address, numeric TCP port ID where applicable, and earlier/later source evidence.
132
+ - Fixed-rule explanations and suggested checks. No AI verdicts or vulnerability scores.
133
+ - Evidence limitations and blank fields for investigation notes and conclusions.
134
+
135
+ **Existing files and notes are never overwritten.** If `reports/review.md` exists, choose a
136
+ new filename, such as `reports/review-02.md`. There is no force-overwrite option.
137
+ The same input paths and contents produce the same report content.
138
+
139
+ Validation finishes before any report is created. Watchpost writes a private temporary file,
140
+ then publishes the complete report with a non-replacing hard link. This also protects a destination
141
+ created by another writer. The output directory must exist and its filesystem must support hard links.
142
+ If it does not, the command fails instead of using an unsafe overwrite fallback.
143
+ This workflow targets Linux and Ubuntu WSL. Use a trusted output directory.
144
+
145
+ Keep the original XML files with your report. Reports reference the supplied files but do not embed
146
+ or authenticate them. Paths appear as escaped literals so filename markup stays plain text.
147
+ Review real reports before sharing: they contain network details and are not anonymous.
148
+ See [the reporting milestone](docs/reporting-milestone.md) for the safety checks and boundaries.
149
+
150
+ ## Practice the three scenarios
151
+
152
+ Follow the [synthetic review walkthroughs](docs/scenarios.md). No live scan is required.
153
+
154
+ 1. **Expected deployment:** compare an observed change with an approved lab deployment plan.
155
+ 2. **Unintended backend exposure:** interpret the same evidence against a different access policy.
156
+ 3. **Incomplete scan:** confirm that a reported host timeout prevents report creation.
157
+
158
+ The first two scenarios use the same XML pair deliberately. Watchpost reports evidence, not intent.
159
+ The walkthroughs supply fictional context and clearly labeled example review notes.
160
+
161
+ ## How inspection works
162
+
163
+ 1. Read the selected local file and reject inputs larger than 10 MiB.
164
+ 2. Parse XML with entity expansion and external entities disabled.
165
+ 3. Check the Nmap root, completion marker, TCP scan metadata, and explicit IPv4/TCP records.
166
+ 4. Print addresses and ports in numerical order.
167
+
168
+ The command rejects malformed XML, unsupported encodings, failed scans, reported host timeouts,
169
+ UDP/IPv6 data, invalid addresses or ports, and duplicate observations.
170
+ Errors exit with code 2, without partial inventory output.
171
+
172
+ Only explicit `<port>` records are listed. This milestone does not expand grouped
173
+ `<extraports>` records, even when additional metadata is present. Hostnames, service names,
174
+ script output, and other free-text scan fields are not printed.
175
+
176
+ This is not full Nmap schema validation. `inspect` focuses on individual records;
177
+ `compare` adds the stronger metadata checks described above. Host status, service names,
178
+ script output, and grouped states are not compared. Matching metadata does not prove identical
179
+ scan conditions, and no-change output is not a safety verdict.
180
+
181
+ ## Development
182
+
183
+ Python 3.11+ and [uv](https://docs.astral.sh/uv/) are required for these commands.
184
+ Dependency installation can need internet access. The application and tests run offline afterward.
185
+
186
+ ```bash
187
+ uv sync --dev --python 3.11
188
+ uv run python -m unittest discover -s tests -v
189
+ uv run ruff format --check .
190
+ uv run ruff check .
191
+ ```
192
+
193
+ The runtime uses `defusedxml` to reject XML entities instead of maintaining a custom XML parser.
194
+ The tests use the standard library's `unittest`. Pytest is an optional development runner.
195
+
196
+ ## Data handling
197
+
198
+ - Only inspect data from networks you own or have permission to assess.
199
+ - All bundled XML examples and the sample report are synthetic and use documentation-only IP addresses.
200
+ - Keep real inputs in `scans/` and reports in `reports/`. Both directories are ignored by Git.
201
+ - Reports are not automatically anonymous. Review files before publishing them.
202
+ - Missing observations do not prove closed ports or removed devices.
203
+ - IP addresses are not device identities. Port numbers are not application identities.
204
+ - An observed change does not prove vulnerability or compromise.
205
+
206
+ ## License
207
+
208
+ Watchpost is licensed under the [MIT License](LICENSE).
209
+ Third-party dependencies retain their own licenses.
210
+
211
+ ## Roadmap
212
+
213
+ - [x] Safely inspect one saved IPv4/TCP scan.
214
+ - [x] Validate and compare two snapshots without inventing missing evidence.
215
+ - [x] Produce Markdown reports with evidence, limitations, and human review notes.
216
+ - [x] Document the three scenarios in the brief.
217
+
218
+ No dashboard, live scanning, scheduling, AI verdicts, or automatic remediation is planned in the v0.1 brief.
@@ -0,0 +1,8 @@
1
+ watchpost.py,sha256=6_Q58hzCZxQMspSbL2W9M2jHw3x4W3T5gfOBEAQtQl4,13379
2
+ watchpost_report.py,sha256=X2jF7fGc9X4UgOWaaMwfNjX4VJY1q1J8cblgJ077Nn4,8531
3
+ watchpost_cli-0.0.1.dist-info/licenses/LICENSE,sha256=h2Fu_9apC0mxJm9UeAsOyilovHbq0hJdg6bN4q4H0t0,1068
4
+ watchpost_cli-0.0.1.dist-info/METADATA,sha256=x4izvWQvmS4qNZ6vID8zmCheYbU7qz5udQM3IzZ8-WY,10022
5
+ watchpost_cli-0.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ watchpost_cli-0.0.1.dist-info/entry_points.txt,sha256=bbBBF7yUYx7-hckl3wrERxvGxHSh8C9h8gMzJCbJkw0,45
7
+ watchpost_cli-0.0.1.dist-info/top_level.txt,sha256=oCVfS7UQ3zD7JkMbcHow37IizEfAZjP68q6Bldp3AHc,27
8
+ watchpost_cli-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ watchpost = watchpost:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evan Newman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ watchpost
2
+ watchpost_report
watchpost_report.py ADDED
@@ -0,0 +1,205 @@
1
+ """Deterministic Markdown for validated comparisons; no scanning or network access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from tempfile import NamedTemporaryFile
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from watchpost import Change, Snapshot
12
+
13
+
14
+ def _finding_text(change: Change) -> tuple[str, str, str]:
15
+ """Return a category, explanation, and suggested human check."""
16
+ if change.port is None:
17
+ return (
18
+ "Address newly observed" if change.before is None else "Address not observed later",
19
+ "This address has a record in only one snapshot. This does not prove a device "
20
+ "was added or removed, or that the same device held this address in both scans.",
21
+ "Confirm target scope, address assignments, host availability, and scan reachability.",
22
+ )
23
+ if change.before is None:
24
+ return (
25
+ "Port newly observed",
26
+ "There is no earlier explicit port record. The later state is observed, but the "
27
+ "earlier state is unknown. This is not proof that a service just started.",
28
+ "Review earlier grouped or omitted records and confirm whether the later "
29
+ "service and exposure are expected from the scan location.",
30
+ )
31
+ if change.after is None:
32
+ return (
33
+ "Port not observed later",
34
+ "There is no later explicit port record. Absence is not a confirmed closure, "
35
+ "even if the later XML contains grouped closed-port counts.",
36
+ "Check host availability, grouped records, and scan conditions before "
37
+ "concluding that the port closed.",
38
+ )
39
+ if change.after == "open":
40
+ explanation = (
41
+ "Both scans contain explicit states, and the later scan reports this port as open. "
42
+ "This does not identify the application or prove Internet exposure or compromise."
43
+ )
44
+ elif change.after == "closed":
45
+ explanation = (
46
+ "Both scans contain explicit states, and the later scan reports this port as closed. "
47
+ "This observation does not prove it is inaccessible from every network location."
48
+ )
49
+ else:
50
+ explanation = (
51
+ "Both scans contain explicit states, but the later state does not establish open "
52
+ "or closed. Filtered and uncertain states must not be relabeled as either."
53
+ )
54
+ return (
55
+ "Port state changed",
56
+ explanation,
57
+ "Check expected service changes, listening interfaces, and firewall rules. "
58
+ "Confirm the intended result from the authorized scan location.",
59
+ )
60
+
61
+
62
+ def _source_section(label: str, snapshot: Snapshot) -> list[str]:
63
+ # Indentation makes all filename markup literal; ascii escapes controls and newlines.
64
+ return [
65
+ f"### {label} snapshot",
66
+ "",
67
+ "Source path (escaped literal):",
68
+ "",
69
+ f" {str(snapshot.source)!a}",
70
+ "",
71
+ f"- UTC start: `{snapshot.started.isoformat()}`",
72
+ f"- UTC finish: `{snapshot.finished.isoformat()}`",
73
+ f"- Nmap version: `{snapshot.version}`",
74
+ f"- Scan method: TCP `{snapshot.method}`",
75
+ f"- Declared TCP ports: {len(snapshot.coverage)}",
76
+ f"- IPv4 address records: {len(snapshot.inventory)}",
77
+ f"- Explicit TCP port records: {sum(len(ports) for ports in snapshot.inventory.values())}",
78
+ "",
79
+ ]
80
+
81
+
82
+ def render_markdown(before: Snapshot, after: Snapshot, changes: list[Change]) -> str:
83
+ """Render read_snapshot/compare_snapshots results after the comparison gate passes."""
84
+ counts = dict.fromkeys(
85
+ (
86
+ "Port state changed",
87
+ "Port newly observed",
88
+ "Port not observed later",
89
+ "Address newly observed",
90
+ "Address not observed later",
91
+ ),
92
+ 0,
93
+ )
94
+ findings = []
95
+ for number, change in enumerate(changes, 1):
96
+ category, explanation, check = _finding_text(change)
97
+ counts[category] += 1
98
+ record = f"IPv4 `{change.address}`"
99
+ if change.port is not None:
100
+ record += f", TCP port `{change.port}` (numeric ID)"
101
+ findings.extend(
102
+ [
103
+ f"### {number}. {category}",
104
+ "",
105
+ f"- **Record:** {record}",
106
+ f"- **Earlier evidence:** `{change.before or 'not observed'}` — Earlier snapshot",
107
+ f"- **Later evidence:** `{change.after or 'not observed'}` — Later snapshot",
108
+ "",
109
+ f"**Explanation:** {explanation}",
110
+ "",
111
+ f"**Suggested check:** {check}",
112
+ "",
113
+ "**Investigation notes:**",
114
+ "",
115
+ "**Conclusion:**",
116
+ "",
117
+ ]
118
+ )
119
+
120
+ lines = [
121
+ "# Watchpost change review",
122
+ "",
123
+ "> Observations, not a security verdict. Changes do not prove vulnerability or compromise.",
124
+ "",
125
+ "## Comparison context",
126
+ "",
127
+ "- Context: same location, intended targets, and other settings confirmed by user.",
128
+ "- Watchpost checked completion, timestamps, Nmap version, scan method, and port coverage.",
129
+ "- Matching metadata does not independently establish matching scan conditions.",
130
+ "",
131
+ *_source_section("Earlier", before),
132
+ *_source_section("Later", after),
133
+ "## Summary",
134
+ "",
135
+ f"Total observation differences: **{len(changes)}**",
136
+ "",
137
+ "| Observation category | Count |",
138
+ "|---|---:|",
139
+ *(f"| {category} | {count} |" for category, count in counts.items()),
140
+ "",
141
+ "Address and port differences are counted separately, "
142
+ "not as distinct incidents or devices.",
143
+ "",
144
+ "## Findings",
145
+ "",
146
+ "Source names below refer to the Earlier and Later snapshot sections above. "
147
+ "Port evidence is the explicit XML state for the listed IPv4 address and numeric TCP "
148
+ "port ID. Address evidence records presence only, not host status or device identity.",
149
+ "",
150
+ "`not observed` means no explicit record in the parsed inventory; it is not an Nmap state.",
151
+ "",
152
+ *(
153
+ findings
154
+ or [
155
+ "No observed changes in explicit IPv4/TCP records. This is not a safety verdict.",
156
+ "",
157
+ ]
158
+ ),
159
+ "## Limitations",
160
+ "",
161
+ "- Missing and grouped port records are not classified as closed.",
162
+ "- IP addresses are not device identities; port numbers are not application identities.",
163
+ "- Filtered and uncertain states are preserved, not treated as confirmed open or closed.",
164
+ "- Host status, hostnames, service names, scripts, and grouped states are not compared.",
165
+ "- These are scan-window observations, not proof of when or why a change happened.",
166
+ "- User confirmation is not independently verified by Watchpost.",
167
+ "- Keep the original XML files with this report; "
168
+ "references do not embed or authenticate them.",
169
+ "- Reports contain network details and are not anonymous. Review before sharing.",
170
+ "",
171
+ "## Human review",
172
+ "",
173
+ "**Reviewer:**",
174
+ "",
175
+ "**Reviewed at (UTC):**",
176
+ "",
177
+ "**Overall investigation notes:**",
178
+ "",
179
+ "**Overall conclusion:**",
180
+ "",
181
+ ]
182
+ return "\n".join(lines)
183
+
184
+
185
+ def write_report(path: Path, content: str) -> None:
186
+ """Publish a complete new file without replacing any existing destination."""
187
+ try:
188
+ with NamedTemporaryFile(
189
+ mode="w", encoding="utf-8", newline="\n", dir=path.parent, prefix=".watchpost-"
190
+ ) as temporary:
191
+ temporary.write(content)
192
+ temporary.flush()
193
+ os.fsync(temporary.fileno())
194
+ # Unlike replace/rename, link refuses even a concurrently created destination.
195
+ os.link(temporary.name, path)
196
+ except FileExistsError as exc:
197
+ raise ValueError(
198
+ f"Report destination already exists: {str(path)!a}. "
199
+ "Choose a new filename; existing files and notes are never overwritten."
200
+ ) from exc
201
+ except OSError as exc:
202
+ raise ValueError(
203
+ f"Cannot save report to {str(path)!a}: {exc.strerror or 'file operation failed'}. "
204
+ "Use an existing, writable directory on a filesystem that supports hard links."
205
+ ) from exc