diff --git a/netdiff/cli.py b/netdiff/cli.py index 6c9d221..9629e41 100644 --- a/netdiff/cli.py +++ b/netdiff/cli.py @@ -9,9 +9,11 @@ import textwrap import urllib.error import urllib.request +from datetime import datetime +from pathlib import Path from . import audit as audit_rules -from . import mdns, oui, store, upnp +from . import mdns, oui, report, store, upnp from .diff import diff, summarise from .scan import discover, grab_banners @@ -212,6 +214,18 @@ def placeholders(text): (f, bool(seen) and (f.rule, f.device, f.title) not in seen) for f in findings ] + if args.html: + page = report.render( + annotated, + args.subnet, + scan_id, + datetime.now().astimezone().strftime("%Y-%m-%d %H:%M %Z"), + audit_rules.summarise(findings), + ) + Path(args.html).write_text(page, encoding="utf-8") + print(f"wrote {args.html} - {len(findings)} finding(s), open it in a browser") + return 0 + if args.json: print( json.dumps( @@ -339,6 +353,11 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="expand every finding into its evidence, why, fix and verify", ) + aud.add_argument( + "--html", + metavar="PATH", + help="write the report as one self-contained HTML file, for sending on", + ) aud.add_argument( "--explain", metavar="RULE", help="print the lesson for a rule and exit" ) diff --git a/netdiff/report.py b/netdiff/report.py new file mode 100644 index 0000000..ebccd59 --- /dev/null +++ b/netdiff/report.py @@ -0,0 +1,115 @@ +"""Render an audit as one self-contained HTML file. + +The terminal report answers "what is wrong here" for the person who ran it. This +answers "what is wrong here" for the person they forward it to - a housemate, a +landlord, whoever actually administers the router. That reader will not run the +tool, so the file has to carry the whole lesson with it: no server, no assets, no +network. One file, opened by double-clicking it. + +Progressive disclosure is `
`/``, which every browser has +implemented for a decade. Scripted show/hide would be a dependency, a bug +surface, and a thing that breaks when the file is emailed through something that +strips scripts. The same ladder as the terminal: headline collapsed, the lesson +one click away. + +Everything interpolated here comes off the network - device names, banners, mDNS +labels, a router's own description of a port forward. This module is where that +becomes markup, so `esc()` is not a formality: it is the same class of hole as +the shell injection in the `verify` commands, one language over. Nothing reaches +the page except through it. +""" + +from __future__ import annotations + +from html import escape + +from .audit import SEVERITY_ORDER + +# Deliberately drab. A report that looks like a security product invites the +# reader to skim the colours instead of the sentences, and the whole thesis here +# is that the sentences are the product. +STYLE = """ +:root { color-scheme: light dark; } +body { font: 16px/1.6 system-ui, -apple-system, Segoe UI, sans-serif; + max-width: 46rem; margin: 3rem auto; padding: 0 1.25rem; } +h1 { font-size: 1.4rem; margin: 0 0 .25rem; } +.sub { opacity: .7; font-size: .9rem; margin: 0 0 2rem; } +details { border-top: 1px solid rgba(128,128,128,.3); padding: .7rem 0; } +details:last-of-type { border-bottom: 1px solid rgba(128,128,128,.3); } +summary { cursor: pointer; display: flex; gap: .7rem; align-items: baseline; } +summary::marker { color: rgba(128,128,128,.7); } +.sev { flex: none; font-size: .72rem; letter-spacing: .06em; + text-transform: uppercase; padding: .1rem .45rem; border-radius: .2rem; + border: 1px solid currentColor; opacity: .85; } +.critical, .high { color: #c0392b; } +.medium { color: #b9770e; } +.info { color: #5d6d7e; } +.new { flex: none; font-size: .7rem; font-weight: 600; letter-spacing: .06em; } +dl { margin: .9rem 0 .3rem; } +dt { font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; + opacity: .6; margin-top: .9rem; } +dd { margin: .2rem 0 0; } +pre { white-space: pre-wrap; word-break: break-word; background: rgba(128,128,128,.12); + padding: .6rem .75rem; border-radius: .25rem; font-size: .85rem; margin: .2rem 0 0; } +footer { margin-top: 2.5rem; font-size: .85rem; opacity: .7; } +""" + +FIELDS = ( + ("evidence", "what was observed", True), + ("why", "why it matters", False), + ("fix", "how to fix it", False), + ("verify", "confirm it yourself", True), +) + + +def esc(value) -> str: + """Everything on this page came off the network. Nothing skips this.""" + return escape(str(value), quote=True) + + +def _finding_html(finding, is_new: bool) -> str: + rows = [] + for name, label, preformatted in FIELDS: + text = esc(getattr(finding, name)) + body = f"
{text}
" if preformatted else f"

{text}

" + rows.append(f"
{label}
{body}
") + new = 'NEW' if is_new else "" + return ( + "
" + f'' + f"{esc(finding.severity)}" + f"{esc(finding.title)}{new}" + f"
{''.join(rows)}
" + "
" + ) + + +def render(annotated, subnet: str, scan_id: int, started: str, summary: str) -> str: + """One HTML document for an audit. + + `annotated` is the (finding, is_new) list the terminal report renders, so + both views are fed by the same thing and cannot drift apart. + """ + findings = sorted( + annotated, key=lambda pair: (SEVERITY_ORDER[pair[0].severity], pair[0].device) + ) + body = "".join(_finding_html(f, is_new) for f, is_new in findings) or ( + "

Nothing to report - no devices answered, or none had open ports.

" + ) + return ( + "\n" + '' + '' + f"netdiff audit {esc(scan_id)} - {esc(subnet)}" + f"" + f"

{esc(subnet)} - {esc(summary)}

" + f'

audit {esc(scan_id)}, {esc(started)}. ' + "Click any finding for the evidence it rests on, why it matters, how to " + "fix it, and a command you can run yourself to confirm it.

" + f"{body}" + "
An open port is not a vulnerability - it is what a working " + "device looks like. Only the findings above are claims about this " + "network, and each one quotes the observation that produced it. " + "Do not take a scanner's word for anything, including this one.
" + "\n" + ) diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..d819e29 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,122 @@ +"""The HTML report, and the escaping that has to hold it together. + +Everything on that page came off the network: device names, banners, mDNS +labels, a router's own description of a port forward. This is the second place +in the codebase where untrusted strings become a language other than Python - +the first was the `verify` commands, where an unquoted value became shell. The +lesson transfers exactly, so half of this file is that lesson. + +Pure rendering, no sockets, no database. +""" + +import re + +from netdiff import report +from netdiff.audit import RULES, SEVERITY_ORDER, Finding + +HOSTILE = '" onload="x' + + +def finding(severity="high", **kw): + fields = dict( + rule="plaintext-protocol", + severity=severity, + device="192.168.1.23", + title="Telnet on port 23 sends passwords in cleartext", + evidence="220 router ready", + why="Telnet has no encryption.", + fix="Use SSH.", + verify="nc 192.168.1.23 23", + ) + fields.update(kw) + return Finding(**fields) + + +def page(*annotated, subnet="192.168.1.0/24", summary="1 high"): + return report.render(list(annotated), subnet, 7, "2026-08-01 12:00 BST", summary) + + +# --- escaping --------------------------------------------------------------- + + +def test_a_hostile_device_name_cannot_open_a_tag(): + out = page((finding(title=HOSTILE), False)) + assert "