Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion netdiff/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"
)
Expand Down
115 changes: 115 additions & 0 deletions netdiff/report.py
Original file line number Diff line number Diff line change
@@ -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 `<details>`/`<summary>`, 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"<pre>{text}</pre>" if preformatted else f"<p>{text}</p>"
rows.append(f"<dt>{label}</dt><dd>{body}</dd>")
new = '<span class="new">NEW</span>' if is_new else ""
return (
"<details>"
f'<summary><span class="sev {esc(finding.severity)}">'
f"{esc(finding.severity)}</span>"
f"<span>{esc(finding.title)}</span>{new}</summary>"
f"<dl>{''.join(rows)}</dl>"
"</details>"
)


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 (
"<p>Nothing to report - no devices answered, or none had open ports.</p>"
)
return (
"<!doctype html>\n"
'<html lang="en"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
f"<title>netdiff audit {esc(scan_id)} - {esc(subnet)}</title>"
f"<style>{STYLE}</style></head><body>"
f"<h1>{esc(subnet)} - {esc(summary)}</h1>"
f'<p class="sub">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.</p>"
f"{body}"
"<footer>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.</footer>"
"</body></html>\n"
)
122 changes: 122 additions & 0 deletions tests/test_report.py
Original file line number Diff line number Diff line change
@@ -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 = '<script>alert(1)</script>" 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 "<script>" not in out
assert "&lt;script&gt;" in out


def test_every_field_of_a_finding_is_escaped():
"""Not just the title - a banner is attacker-chosen too, and it is quoted."""
for field in ("title", "evidence", "why", "fix", "verify"):
out = page((finding(**{field: HOSTILE}), False))
assert "<script>" not in out, f"{field} reached the page unescaped"
assert "&lt;script&gt;" in out


def test_the_only_attribute_we_interpolate_into_is_a_closed_set():
"""`severity` is the one value that lands inside `class="..."`.

It is safe because it cannot be attacker-chosen - it comes from the rule
table, not from the network. That is the actual guarantee, so pin it here:
if a rule ever grows a free-form severity, this fails and the quoting in the
template becomes load-bearing.
"""
assert set(SEVERITY_ORDER) == {"critical", "high", "medium", "info"}
for spec in RULES.values():
assert spec["severity"] in SEVERITY_ORDER


def test_the_subnet_and_summary_are_escaped_too():
out = page(subnet=HOSTILE, summary=HOSTILE)
assert "<script>" not in out


def test_a_quote_in_a_finding_cannot_reach_the_page_raw():
"""`esc` is called with quote=True, so `"` is neutralised everywhere."""
out = page((finding(evidence='banner says "hello"'), False))
assert '"hello"' not in out
assert "&quot;hello&quot;" in out


# --- structure --------------------------------------------------------------


def test_the_page_stands_alone():
"""No server, no assets, no network - it gets emailed and still works."""
out = page((finding(), False))
assert "<style>" in out and "</style>" in out
assert "<script" not in out
assert not re.search(r'(src|href)="(?!#)', out), "no external references"


def test_progressive_disclosure_is_native():
out = page((finding(), False))
assert out.count("<details>") == 1
assert "<summary>" in out
assert "Telnet on port 23" in out


def test_findings_are_ordered_worst_first():
out = page(
(finding(severity="info", title="ports noted"), False),
(finding(severity="critical", title="reachable from the internet"), False),
(finding(severity="medium", title="router lets any device in"), False),
)
assert out.index("reachable from the internet") < out.index("router lets any")
assert out.index("router lets any") < out.index("ports noted")


def test_new_findings_are_marked():
assert "NEW" in page((finding(), True))
assert "NEW" not in page((finding(), False))


def test_an_empty_audit_says_so_rather_than_rendering_a_blank_page():
out = page()
assert "Nothing to report" in out
assert "<details>" not in out


def test_the_footer_repeats_that_an_open_port_is_not_a_vulnerability():
"""The thesis has to survive the trip to whoever the file gets sent to."""
assert "not a vulnerability" in page((finding(), False))
Loading