← Files OGENIC GOD TOOLKITARCHIVED FILE

scripts/netwalk_report.py

36.4 KB · Oct 4, 2026 · 12:32 UTC

↓ Download file

#!/usr/bin/env python3
"""Render a netwalk scan record into one self-contained HTML report.

  netwalk_report.py record.json -o report.html [--public] [--title "..."]

The report is deterministic: the same record always produces the same HTML, so two
scans of one site diff cleanly. Nothing is fetched at view time - the diagram, the
CSS and the fonts stack are all inline, so the file survives being emailed to a site
owner who opens it offline.

Before rendering, the record is swept for credential material. If anything that
looks like a secret is in there the render is REFUSED, because this is the file that
leaves the building.
"""
from __future__ import annotations

import argparse
import html
import json
import os
import re
import sys
from collections import Counter

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import netwalk_map  # noqa: E402
import netwalk_common as C  # noqa: E402

# ------------------------------------------------------------- secret sweep

FORBIDDEN_KEYS = re.compile(
    r"(pass(word|phrase)?|secret|token|api[_-]?key|private[_-]?key|key[_-]?path|"
    r"credential|psk|pre[_-]?shared|community|shared[_-]?key|wpa[_-]?key|auth[_-]?key|"
    r"enable[_-]?pass|bind[_-]?dn|client[_-]?secret)", re.I)

SECRET_VALUE_PATTERNS = [
    (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), "private key block"),
    (re.compile(r"\bssh-(rsa|ed25519|dss)\s+AAAA[0-9A-Za-z+/=]{40,}"), "ssh key blob"),
    (re.compile(r"(?i)\bpassword\s*[:=]\s*(?!<redacted>)\S+"), "an inline password= assignment"),
    (re.compile(r"(?i)\bwpa[-_ ]?(psk|passphrase)\s*[:=]\s*(?!<redacted>)\S+"), "a wireless pre-shared key"),
    # `trap-community: x` is a community string and the old pattern, anchored on the word
    # "snmp", walked straight past it - a real customer report went out with one in an
    # evidence excerpt. Match the word wherever it appears, however it is prefixed.
    (re.compile(r"(?i)\b[\w-]*community\s*[:=]\s*(?!<redacted>)\S+"), "a community string"),
    (re.compile(r"(?i)\b[\w-]*(secret|passphrase|pre-?shared-?key|psk)\s*[:=]\s*(?!<redacted>)\S+"),
     "a secret-shaped value"),
    (re.compile(r"(?i)\bsecret\s+\d?\s*\$?\d?\$\S+"), "a Cisco enable secret hash"),
]


def sweep(node, path: str = "$") -> list[str]:
    """Walk the record and report anything that must not reach a customer."""
    hits: list[str] = []
    if isinstance(node, dict):
        for k, v in node.items():
            here = f"{path}.{k}"
            if FORBIDDEN_KEYS.search(str(k)) and v not in (None, "", [], {}):
                hits.append(f"{here}  (key name looks like a secret)")
            hits += sweep(v, here)
    elif isinstance(node, list):
        for i, v in enumerate(node):
            hits += sweep(v, f"{path}[{i}]")
    elif isinstance(node, str):
        for pat, what in SECRET_VALUE_PATTERNS:
            if pat.search(node):
                hits.append(f"{path}  (value contains {what})")
                break
    return hits


# ------------------------------------------------------------------ helpers

def esc(v) -> str:
    return html.escape("" if v is None else str(v))


def num(v, unit: str = "", digits: int = 0) -> str:
    if v is None:
        return "—"
    try:
        f = float(v)
    except (TypeError, ValueError):
        return esc(v)
    return f"{f:,.{digits}f}{unit}"


def mb(v) -> str:
    if v is None:
        return "—"
    v = float(v)
    return f"{v/1024:.1f} GB" if v >= 1024 else f"{v:.0f} MB"


def bps(v) -> str:
    if v is None:
        return "—"
    v = float(v)
    for lim, suf in ((1e9, "Gbps"), (1e6, "Mbps"), (1e3, "kbps")):
        if v >= lim:
            return f"{v/lim:.1f} {suf}"
    return f"{v:.0f} bps"


def pct(used, total):
    if not total:
        return None
    return 100.0 * float(used) / float(total)


def table(headers: list[str], rows: list[list[str]], empty: str = "nothing recorded") -> str:
    if not rows:
        return f'<p class="empty">{esc(empty)}</p>'
    head = "".join(f"<th>{esc(h)}</th>" for h in headers)
    body = "".join("<tr>" + "".join(f"<td>{c}</td>" for c in r) + "</tr>" for r in rows)
    return f'<div class="scroll"><table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>'


SEV_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}


# -------------------------------------------------------------------- pieces

def sec_summary(rec: dict, findings: list[dict], public: bool) -> str:
    devs = rec.get("devices", [])
    reach = [d for d in devs if d.get("reachable")]
    counts = Counter(f.get("severity", "info") for f in findings)
    roles = Counter(d.get("role", "unknown") for d in devs)

    stats = [("Devices found", str(len(devs))),
             ("Reached and read", str(len(reach))),
             ("Links mapped", str(len(rec.get("topology_edges", [])))),
             ("Internet uplinks", str(len(rec.get("wan_links", []))))]
    if findings:
        stats.append(("Findings", str(len(findings))))
    cards = "".join(f'<div class="stat"><div class="k">{esc(v)}</div>'
                    f'<div class="l">{esc(k)}</div></div>' for k, v in stats)

    sev = "".join(
        f'<span class="pill sev-{s}">{counts[s]} {s}</span>'
        for s in ("critical", "high", "medium", "low", "info") if counts[s])
    mix = ", ".join(f"{n}× {r}" for r, n in roles.most_common())

    top = [f for f in findings if f.get("severity") in ("critical", "high")][:5]
    if top:
        lead = ("<p>The findings that would change something if acted on this week:</p><ol class=\"lead\">"
                + "".join(f'<li><b>{esc(f.get("title"))}</b>'
                          f'{" — " + esc(f.get("host_id")) if f.get("host_id") else ""}</li>'
                          for f in top) + "</ol>")
    elif findings:
        lead = "<p>Nothing critical or high severity was found. The remaining items are listed below.</p>"
    else:
        lead = "<p>No findings were recorded for this scan.</p>"

    return f"""<section id="summary"><h2>Summary</h2>
<div class="stats">{cards}</div>
{f'<p class="pills">{sev}</p>' if sev else ''}
<p class="muted">Device mix: {esc(mix) or '—'}</p>
{lead}</section>"""


def sec_method(rec: dict, public: bool) -> str:
    site = rec.get("site", {})
    cov = rec.get("coverage") or {}
    ep = rec.get("entry_point") or {}
    rows = []
    if site.get("customer"):
        rows.append(("Customer", esc(site["customer"])))
    if site.get("address"):
        rows.append(("Site", esc(site["address"])))
    if site.get("engineer") and not public:
        rows.append(("Engineer", esc(site["engineer"])))
    if rec.get("scanned_at") and not public:
        rows.append(("Scanned", esc(rec["scanned_at"])))
    if ep.get("host_id"):
        rows.append(("Entry point", f'{esc(ep["host_id"])} ({esc(ep.get("ip",""))}, via {esc(ep.get("how","ssh"))})'))
    if site.get("scope_note"):
        rows.append(("Authorised scope", esc(site["scope_note"])))
    meta = "".join(f'<div class="kv"><dt>{k}</dt><dd>{v}</dd></div>' for k, v in rows)

    gaps = cov.get("not_covered") or []
    gap_html = ""
    if gaps or cov.get("stopped_early"):
        items = list(gaps)
        if cov.get("stopped_early"):
            items.insert(0, f"The crawl stopped before it ran out of neighbours: "
                            f"{cov.get('stopped_reason') or 'reason not recorded'}")
        gap_html = ('<div class="callout"><h3>What this report does not cover</h3><ul>'
                    + "".join(f"<li>{esc(g)}</li>" for g in items) + "</ul></div>")

    return f"""<section id="method"><h2>How this was produced</h2>
<dl class="meta">{meta}</dl>
<div class="callout ok"><h3>Read-only, enforced in code</h3>
<p>Every command in this survey was checked against a read-only allowlist before it was sent,
and anything that writes configuration, clears a counter, or restarts a service was refused by
the tool rather than by convention. No device configuration was changed. Configuration was
<i>exported</i> for reading; nothing was imported.</p></div>
{sec_artefacts(rec, public)}
{gap_html}</section>"""


def sec_artefacts(rec: dict, public: bool) -> str:
    """Where the sensitive by-products of this survey are sitting, on whose machine.

    The report is the thing people keep; the credential store and the config exports are
    the things they forget. A survey that hands over a polished document and leaves a
    plaintext credential file on an engineer's laptop, unmentioned, has moved the risk
    rather than reported it. Paths only - never a value, and never in the public copy.
    """
    if public:
        return ""
    slug = C.slugify(str((rec.get("site") or {}).get("id") or "")) if rec.get("site") else ""
    if not slug:
        return ""
    creds = C.creds_dir() / f"{slug}.json"
    exports = [d for d in (rec.get("devices") or []) if d.get("config_export_path")]
    lines = [
        f"<li><b>Credentials</b> — <code>{esc(creds)}</code>. Plain JSON, readable only by "
        f"the account that ran the survey. netwalk never deletes this on its own: remove it "
        f"with <code>netwalk_cred.py forget --site {esc(slug)}</code> when the engagement is "
        f"over. Deletion overwrites the file first, which is not a forensic wipe on an SSD — "
        f"if any of these credentials matter, rotate them rather than trusting the delete.</li>",
    ]
    if exports:
        lines.append(
            f"<li><b>Configuration exports</b> — {len(exports)} file(s) under "
            f"<code>{esc(C.site_dir(slug) / 'configs')}</code>. These contain PSKs, SNMP "
            f"communities and password hashes in clear text. They are deliberately not part "
            f"of this report; do not forward them.</li>")
    return ('<div class="callout"><h3>Where this survey left sensitive files</h3>'
            '<p>On the machine that ran the survey, not on your equipment:</p><ul>'
            + "".join(lines) + "</ul></div>")


def sec_diagram(rec: dict, public: bool) -> str:
    try:
        svg = netwalk_map.render(rec, public=public, standalone=False)
    except Exception as e:  # noqa: BLE001
        return f'<section id="diagram"><h2>Topology</h2><p class="empty">diagram failed: {esc(e)}</p></section>'
    return f"""<section id="diagram"><h2>Topology</h2>
<p class="muted">Reads left to right, the way traffic travels. Solid links were seen by LLDP, CDP or
MNDP; dashed links are inferred — something is physically there that does not announce itself.
Access points are grouped per switch, with their count and address range; every one of them is
listed individually in the inventory below.</p>
<div class="diagbar">
  <button type="button" id="diagopen">Open full size &#8599;</button>
  <span class="hint">Scaled to the page. Click the diagram to open it full size in a new tab.</span>
</div>
<div class="scroll diagram fit" id="diagwrap" title="Click to open full size in a new tab">{svg}</div>
<script>
(function(){{
  var w=document.getElementById('diagwrap'), b=document.getElementById('diagopen');
  if(!w||!b) return;
  var svg=w.querySelector('svg');
  function openFull(){{
    if(!svg) return;
    // about:blank is same-origin, so this works from a file:// copy of the report and
    // from a mail client temp folder. A blob: URL does not, reliably.
    var t=window.open('','_blank');
    if(!t){{ alert('The browser blocked the new tab. Allow pop-ups for this page.'); return; }}
    var title=(document.title||'network diagram').replace(/[<>]/g,'');
    t.document.write('<!doctype html><html><head><meta charset="utf-8"><title>'+title+
      '</title><style>html,body{{margin:0;background:#fff}}'+
      '@media(prefers-color-scheme:dark){{html,body{{background:#0e1116}}}}'+
      'svg{{display:block;margin:0 auto}}</style></head><body>'+
      svg.outerHTML+'</body></html>');
    t.document.close();
  }}
  b.addEventListener('click', openFull);
  w.addEventListener('click', openFull);
}})();
</script>
</section>"""


def sec_inventory(rec: dict) -> str:
    rows = []
    for d in sorted(rec.get("devices", []), key=lambda d: (netwalk_map.ROLE_RANK.get(d.get("role", "unknown"), 9),
                                                           d.get("host_id", ""))):
        h = d.get("health") or {}
        cpu = f'{h["cpu_load_pct"]:g}%' if h.get("cpu_load_pct") is not None else "—"
        p = pct(h.get("memory_used_mb") if h.get("memory_used_mb") is not None
                else (h["memory_total_mb"] - h["memory_free_mb"]) if h.get("memory_total_mb") and h.get("memory_free_mb") is not None else None,
                h.get("memory_total_mb"))
        mem = f"{p:.0f}%" if p is not None else "—"
        state = ('<span class="ok-dot">reached</span>' if d.get("reachable")
                 else f'<span class="bad-dot">{esc(d.get("unreachable_reason") or "not reached")}</span>')
        rows.append([
            f'<b>{esc(d.get("hostname") or d.get("host_id"))}</b>',
            f'<code>{esc(d.get("mgmt_ip") or "—")}</code>',
            esc(d.get("vendor") or "—"),
            esc(d.get("model") or "—"),
            esc(d.get("os_version") or d.get("firmware") or "—"),
            esc(netwalk_map.ROLE_LABEL.get(d.get("role", "unknown"), d.get("role", "—")).title()),
            esc(d.get("uptime") or "—"), cpu, mem, state,
        ])
    return (f'<section id="inventory"><h2>Device inventory</h2>'
            + table(["Host", "Management IP", "Vendor", "Model", "Version", "Role",
                     "Uptime", "CPU", "RAM", "Status"], rows, "no devices in this record")
            + "</section>")


def device_block(d: dict, public: bool) -> str:
    parts = []
    hid = d.get("hostname") or d.get("host_id")
    sub = " · ".join(x for x in [d.get("mgmt_ip"), d.get("vendor"), d.get("model"),
                                 d.get("os_version") or d.get("firmware")] if x)
    parts.append(f'<h3 id="dev-{esc(d.get("host_id"))}">{esc(hid)}'
                 f'<span class="hsub">{esc(sub)}</span></h3>')

    h = d.get("health") or {}
    if h:
        cells = []
        if h.get("cpu_load_pct") is not None:
            cells.append(("CPU", f'{h["cpu_load_pct"]:g}%', float(h["cpu_load_pct"]) >= 80))
        used = h.get("memory_used_mb")
        if used is None and h.get("memory_total_mb") and h.get("memory_free_mb") is not None:
            used = h["memory_total_mb"] - h["memory_free_mb"]
        p = pct(used, h.get("memory_total_mb"))
        if p is not None:
            cells.append(("Memory", f'{p:.0f}% of {mb(h["memory_total_mb"])}', p >= 85))
        if h.get("storage_total_mb") and h.get("storage_free_mb") is not None:
            sp = 100 * (1 - h["storage_free_mb"] / h["storage_total_mb"])
            cells.append(("Storage", f'{sp:.0f}% of {mb(h["storage_total_mb"])}', sp >= 85))
        if h.get("temperature_c") is not None:
            cells.append(("Temperature", f'{h["temperature_c"]:g} °C', float(h["temperature_c"]) >= 65))
        if h.get("poe_budget_w"):
            pp = pct(h.get("poe_used_w"), h["poe_budget_w"]) or 0
            cells.append(("PoE", f'{num(h.get("poe_used_w"),"W")} of {num(h["poe_budget_w"],"W")}', pp >= 85))
        if h.get("session_count") is not None:
            sp = pct(h["session_count"], h.get("session_max"))
            cells.append(("Sessions", f'{num(h["session_count"])}'
                                      + (f' of {num(h["session_max"])}' if h.get("session_max") else ""),
                          bool(sp and sp >= 80)))
        if cells:
            parts.append('<div class="stats small">' + "".join(
                f'<div class="stat{" hot" if hot else ""}"><div class="k">{esc(v)}</div>'
                f'<div class="l">{esc(k)}</div></div>' for k, v, hot in cells) + "</div>")

    ifaces = d.get("interfaces") or []
    if ifaces:
        rows = []
        for i in ifaces:
            link = ('<span class="ok-dot">up</span>' if i.get("link_up")
                    else '<span class="bad-dot">down</span>' if i.get("link_up") is False
                    else "—")
            if i.get("admin_up") is False:
                link = '<span class="muted">disabled</span>'
            errs = sum(float(i.get(k) or 0) for k in ("rx_errors", "tx_errors", "crc_errors"))
            errcell = f'<b class="bad">{num(errs)}</b>' if errs else "0"
            flaps = i.get("link_downs")
            flapcell = f'<b class="bad">{flaps}</b>' if flaps and int(flaps) > 3 else (str(flaps) if flaps is not None else "—")
            macs = i.get("mac_table") or []
            rows.append([f'<code>{esc(i.get("name"))}</code>', esc(i.get("alias") or "—"), link,
                         esc(i.get("speed") or "—"),
                         ", ".join(esc(x) for x in (i.get("ips") or [])) or "—",
                         bps(i.get("rx_bps")), bps(i.get("tx_bps")), errcell, flapcell,
                         str(len(macs)) if macs else "—"])
        parts.append("<h4>Interfaces</h4>" + table(
            ["Port", "Description", "Link", "Speed", "IP", "Rx", "Tx", "Errors", "Flaps", "MACs"], rows))

    vlans = d.get("vlans") or []
    if vlans:
        rows = [[str(v.get("id", "—")), esc(v.get("name") or "—"), esc(v.get("subnet") or "—"),
                 ", ".join(esc(p) for p in (v.get("tagged_ports") or [])) or "—",
                 ", ".join(esc(p) for p in (v.get("untagged_ports") or [])) or "—"] for v in vlans]
        parts.append("<h4>VLANs</h4>" + table(["ID", "Name", "Subnet", "Tagged", "Untagged"], rows))

    wl = d.get("wireless_networks") or []
    if wl:
        rows = []
        for w in wl:
            secu = w.get("security") or "unknown"
            secc = f'<b class="bad">{esc(secu)}</b>' if secu in ("open", "wep", "wpa-personal") else esc(secu)
            iso = w.get("client_isolation")
            isoc = ("—" if iso is None else
                    '<span class="ok-dot">on</span>' if iso else '<b class="bad">off</b>')
            rows.append([f'<b>{esc(w.get("ssid"))}</b>', esc(w.get("band") or "—"),
                         esc(w.get("channel") or "—"), esc(w.get("width") or "—"), secc,
                         str(w.get("vlan") if w.get("vlan") is not None else "—"),
                         str(w.get("clients") if w.get("clients") is not None else "—"),
                         "yes" if w.get("guest") else "—", isoc])
        parts.append("<h4>Wireless</h4>" + table(
            ["SSID", "Band", "Channel", "Width", "Security", "VLAN", "Clients", "Guest", "Isolation"], rows))

    svcs = d.get("services") or []
    if svcs:
        rows = []
        for s in svcs:
            st = s.get("state", "unknown")
            stc = (f'<span class="ok-dot">{esc(st)}</span>' if st == "running"
                   else f'<b class="bad">{esc(st)}</b>' if st in ("failed", "degraded") else esc(st))
            rows.append([f'<code>{esc(s.get("name"))}</code>', stc,
                         "yes" if s.get("enabled") else "—",
                         str(s.get("restarts") if s.get("restarts") is not None else "—"),
                         ", ".join(esc(x) for x in (s.get("listen") or [])) or "—",
                         esc(s.get("note") or "")])
        parts.append("<h4>Services</h4>" + table(
            ["Service", "State", "Enabled", "Restarts", "Listening on", "Note"], rows))

    logs = d.get("log_excerpts") or []
    if logs:
        rows = [[esc(l.get("at") or "—"), esc(l.get("severity") or "—"), esc(l.get("topic") or "—"),
                 f'<code>{esc(l.get("message"))}</code>',
                 str(l.get("count")) if l.get("count") else "—"] for l in logs[:80]]
        parts.append("<h4>Log excerpts</h4>" + table(["When", "Severity", "Topic", "Message", "Repeats"], rows))

    exp = d.get("mgmt_exposure") or {}
    if exp and not public:
        bits = []
        if exp.get("services"):
            bits.append("Answering: " + ", ".join(f"<code>{esc(s)}</code>" for s in exp["services"]))
        if exp.get("reachable_from_wan"):
            bits.append('<b class="bad">Reachable from the internet: '
                        + ", ".join(esc(s) for s in exp["reachable_from_wan"]) + "</b>")
        if exp.get("note"):
            bits.append(esc(exp["note"]))
        parts.append("<h4>Management exposure</h4><p>" + "<br>".join(bits) + "</p>")

    if d.get("config_export_path"):
        parts.append(f'<p class="muted">Configuration exported to '
                     f'<code>{esc(d["config_export_path"])}</code> (read-only dump, kept with the scan record).</p>')
    return '<article class="device">' + "".join(parts) + "</article>"


def sec_devices(rec: dict, public: bool) -> str:
    devs = [d for d in rec.get("devices", []) if d.get("reachable")]
    if not devs:
        return ""
    devs.sort(key=lambda d: (netwalk_map.ROLE_RANK.get(d.get("role", "unknown"), 9), d.get("host_id", "")))
    return ('<section id="devices"><h2>Device detail</h2>'
            + "".join(device_block(d, public) for d in devs) + "</section>")


def sec_findings(findings: list[dict], public: bool) -> str:
    if not findings:
        return ('<section id="findings"><h2>Findings</h2>'
                '<p class="empty">No findings were recorded.</p></section>')
    out = []
    for f in findings:
        sev = f.get("severity", "info")
        conf = f.get("confidence")
        ev = f.get("evidence") or []
        ev_html = ""
        if ev and not public:
            ev_html = ('<details class="evidence"><summary>Evidence '
                       f'({len(ev)})</summary><dl>'
                       + "".join(f'<dt><code>{esc(e.get("source"))}</code></dt>'
                                 f'<dd><pre>{esc(e.get("excerpt") or "")}</pre></dd>' for e in ev)
                       + "</dl></details>")
        out.append(f"""<article class="finding sev-{esc(sev)}">
<header><span class="pill sev-{esc(sev)}">{esc(sev)}</span>
{f'<span class="pill cat">{esc(f.get("category"))}</span>' if f.get("category") else ''}
{f'<span class="pill conf">{esc(conf)}</span>' if conf else ''}
<h3>{esc(f.get("title"))}</h3>
{f'<p class="where">on <code>{esc(f.get("host_id"))}</code></p>' if f.get("host_id") else ''}</header>
{f'<p>{esc(f.get("detail"))}</p>' if f.get("detail") else ''}
{ev_html}
{f'<p class="rec"><b>Recommended:</b> {esc(f.get("recommendation"))}</p>' if f.get("recommendation") else ''}
</article>""")
    note = ('<p class="muted">Every recommendation below is a proposal. netwalk did not apply '
            'any of them — the site owner decides what changes and when.</p>')
    return f'<section id="findings"><h2>Findings and recommendations</h2>{note}{"".join(out)}</section>'


def _ip_key(host: dict):
    """Sort addresses numerically. String order puts .140 before .99 and looks broken."""
    import ipaddress  # noqa: PLC0415
    try:
        ip = ipaddress.ip_address(str(host.get("ip")))
        return (0, ip.version, ip.packed, "")
    except ValueError:
        return (1, 0, b"", str(host.get("ip")))


def _count(n, noun: str) -> str:
    n = n or 0
    return f"{num(n)} {noun}{'' if n == 1 else 'es' if noun.endswith('ss') else 's'}"


def sec_sweeps(rec: dict, public: bool) -> str:
    """What answered on the ranges the owner authorised - and what the method cannot see.

    The blind spots are printed next to the results on purpose. A list of open ports with
    no caveat reads as "this is everything that is open", which is never true of a TCP
    connect sweep.
    """
    sweeps = rec.get("sweeps") or []
    if not sweeps or public:
        # A per-address list of open ports is a shopping list. The public copy says a
        # sweep happened, in Method; it does not print the results.
        return ""
    blocks = []
    for s in sweeps:
        hosts = s.get("hosts") or []
        rows = []
        for h in sorted(hosts, key=_ip_key):
            ports = h.get("open_ports") or []
            rows.append([
                f'<code>{esc(h.get("ip"))}</code>',
                esc(", ".join(str(p) for p in ports) or "—"),
                esc(", ".join(h.get("services") or []) or "—"),
                "in the inventory" if h.get("in_record") else
                '<b class="bad">not identified</b>',
            ])
        unknown = sum(1 for h in hosts if not h.get("in_record"))
        head = (f'<h3>{esc(s.get("range"))}</h3>'
                f'<p class="muted">{esc(s.get("method") or "tcp-connect")}'
                f'{" through " + esc(s.get("via")) if s.get("via") else ""} · '
                f'{_count(s.get("addresses_probed"), "address")} probed · '
                f'{num(s.get("hosts_found"))} answered'
                f'{" · " + str(unknown) + " not in the inventory" if unknown else ""}</p>')
        auth = s.get("authorized_by")
        auth_html = (f'<p class="muted">Authorised by: {esc(auth)}</p>' if auth else
                     '<p class="muted"><b class="bad">No authorisation was recorded for this '
                     'sweep.</b></p>')
        blind = s.get("not_visible") or []
        blind_html = (f'<p class="muted">Not visible to this method: '
                      f'{esc(", ".join(blind))}.</p>' if blind else "")
        blocks.append(head + auth_html +
                      table(["Address", "Open TCP ports", "Services", "Known?"], rows,
                            "nothing on this range answered") + blind_html)
    intro = ('<p class="muted">A sweep only covers ranges the site owner explicitly '
             'authorised, and it opens a TCP connection and closes it again — nothing is '
             'sent to any service.</p>')
    return f'<section id="sweeps"><h2>Address sweep</h2>{intro}{"".join(blocks)}</section>'


def sec_evidence(rec: dict) -> str:
    log = rec.get("evidence_log") or []
    if not log:
        return ""
    rows = [[esc(e.get("at")), f'<code>{esc(e.get("host_id"))}</code>',
             f'<code>{esc(e.get("command"))}</code>',
             ('<b class="bad">blocked</b>' if e.get("blocked")
              else str(e.get("exit_code")) if e.get("exit_code") is not None else "—"),
             num(e.get("bytes_out"))] for e in log]
    return ('<section id="evidence"><h2>Command log</h2>'
            '<p class="muted">Everything netwalk ran on your equipment, in order. '
            'If a command is not in this list, it was not run.</p>'
            + table(["When", "Host", "Command", "Exit", "Bytes"], rows) + "</section>")


# ---------------------------------------------------------------------- CSS

CSS = """
:root{--bg:#f7f8fa;--card:#fff;--ink:#12151a;--muted:#5f6874;--line:#e1e6ec;
--accent:#1f6feb;--ok:#0f7b45;--okbg:#e9f7ef;--bad:#c0392b;--badbg:#fdecea;
--warn:#8a5a00;--warnbg:#fff6e5;--chip:#eef1f5;--code:#f2f4f7;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#0e1116;--card:#161b22;
--ink:#e6eaf0;--muted:#98a4b3;--line:#252c36;--accent:#5b9dff;--ok:#4ade80;--okbg:#122a1d;
--bad:#ff7b6b;--badbg:#331a17;--warn:#ffd479;--warnbg:#2c2410;--chip:#212932;--code:#1b2129}}
:root[data-theme=dark]{--bg:#0e1116;--card:#161b22;--ink:#e6eaf0;--muted:#98a4b3;--line:#252c36;
--accent:#5b9dff;--ok:#4ade80;--okbg:#122a1d;--bad:#ff7b6b;--badbg:#331a17;--warn:#ffd479;
--warnbg:#2c2410;--chip:#212932;--code:#1b2129}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:15.5px/1.62 var(--sans)}
.wrap{max-width:1120px;margin:0 auto;padding:32px 20px 80px}
header.cover{border-bottom:2px solid var(--line);padding-bottom:22px;margin-bottom:30px}
header.cover .eyebrow{font:600 11px var(--sans);letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin:0 0 6px}
header.cover h1{font-size:clamp(26px,4vw,38px);line-height:1.15;margin:0 0 8px;letter-spacing:-.02em}
header.cover p{margin:0;color:var(--muted)}
nav.toc{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 34px}
nav.toc a{font-size:13px;padding:6px 12px;border:1px solid var(--line);border-radius:99px;
background:var(--card);color:var(--ink);text-decoration:none}
nav.toc a:hover{border-color:var(--accent);color:var(--accent)}
section{margin:0 0 44px}
h2{font-size:22px;margin:0 0 14px;padding-bottom:8px;border-bottom:1px solid var(--line);letter-spacing:-.01em}
h3{font-size:17px;margin:26px 0 8px}
h4{font-size:13px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin:20px 0 8px}
p{margin:0 0 12px}
.muted{color:var(--muted)}
.empty{color:var(--muted);font-style:italic}
code{font:.88em var(--mono);background:var(--code);padding:1px 5px;border-radius:4px}
pre{font:12.5px/1.5 var(--mono);background:var(--code);padding:10px 12px;border-radius:8px;
overflow-x:auto;margin:4px 0 10px;white-space:pre-wrap;word-break:break-word}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:12px;margin:0 0 18px}
.stats.small{grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px;margin:10px 0 4px}
.stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px}
.stat .k{font:600 20px var(--sans);letter-spacing:-.02em}
.stats.small .stat .k{font-size:15px}
.stat .l{font-size:12px;color:var(--muted);margin-top:2px}
.stat.hot{border-color:var(--bad);background:var(--badbg)}
.stat.hot .k{color:var(--bad)}
.pills{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 12px}
.pill{display:inline-block;font:600 11px var(--sans);padding:3px 9px;border-radius:99px;
background:var(--chip);color:var(--muted);letter-spacing:.02em}
.pill.sev-critical,.pill.sev-high{background:var(--badbg);color:var(--bad)}
.pill.sev-medium{background:var(--warnbg);color:var(--warn)}
.pill.sev-low,.pill.sev-info{background:var(--chip);color:var(--muted)}
ol.lead{margin:6px 0 0;padding-left:22px}
ol.lead li{margin:0 0 5px}
dl.meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px 22px;margin:0 0 18px}
.kv dt{font-size:11.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
.kv dd{margin:2px 0 0}
.callout{border:1px solid var(--line);border-left:3px solid var(--warn);background:var(--warnbg);
border-radius:0 10px 10px 0;padding:12px 16px;margin:0 0 16px}
.callout.ok{border-left-color:var(--ok);background:var(--okbg)}
.callout h3{margin:0 0 6px;font-size:14px}
.callout p,.callout ul{margin:0;font-size:14px}
.callout ul{padding-left:20px}
.scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--line);
border-radius:10px;background:var(--card);margin:0 0 14px}
.scroll.diagram{padding:14px}
/* Never shrink the diagram to fit the column. A site map squeezed from 3000px to
   1000px is legible only as a shape - the hostnames, IPs and port names, which are
   the entire reason to look at it, become unreadable. It renders at full size and
   scrolls inside its own box instead. */
/* margin:0, not auto: centring an over-wide child inside a scroll box pushes its
   left edge out of reach, and the left edge is where the diagram starts. */
.scroll.diagram{cursor:zoom-in}
.scroll.diagram svg{max-width:none;width:auto;height:auto;display:block;margin:0}
/* Fitted is the default inside the report: the page should read as a page. Full size
   lives one click away in its own tab, where it has room to be legible. */
.scroll.diagram.fit{overflow-x:hidden}
.scroll.diagram.fit svg{max-width:100%;height:auto;margin:0 auto}
.diagbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:0 0 10px}
.diagbar button{font:600 12.5px var(--sans);padding:6px 12px;border-radius:99px;
border:1px solid var(--line);background:var(--card);color:var(--ink);cursor:pointer}
.diagbar button:hover{border-color:var(--accent);color:var(--accent)}
.diagbar .hint{font-size:12.5px;color:var(--muted)}
table{border-collapse:collapse;width:100%;font-size:13.5px}
th{text-align:left;font:600 11.5px var(--sans);text-transform:uppercase;letter-spacing:.05em;
color:var(--muted);padding:9px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
td{padding:8px 12px;border-bottom:1px solid var(--line);vertical-align:top}
tbody tr:last-child td{border-bottom:0}
.ok-dot::before,.bad-dot::before{content:"\\25CF";margin-right:5px}
.ok-dot{color:var(--ok)}.bad-dot{color:var(--bad)}
b.bad,.bad{color:var(--bad)}
article.device{border:1px solid var(--line);border-radius:12px;background:var(--card);
padding:4px 18px 18px;margin:0 0 18px}
article.device h3{display:flex;flex-wrap:wrap;align-items:baseline;gap:10px}
.hsub{font:400 13px var(--mono);color:var(--muted)}
article.finding{border:1px solid var(--line);border-left:3px solid var(--muted);border-radius:0 12px 12px 0;
background:var(--card);padding:14px 18px;margin:0 0 12px}
article.finding.sev-critical,article.finding.sev-high{border-left-color:var(--bad)}
article.finding.sev-medium{border-left-color:var(--warn)}
article.finding header{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:0 0 6px}
article.finding h3{margin:0;font-size:16px;flex:1 1 260px}
.where{margin:0;font-size:13px;color:var(--muted)}
.rec{margin:8px 0 0;padding:9px 12px;background:var(--chip);border-radius:8px;font-size:14px}
details.evidence{margin:8px 0 0}
details.evidence summary{cursor:pointer;font-size:13px;color:var(--muted)}
details.evidence dl{margin:8px 0 0}
details.evidence dt{font-size:12.5px;margin:8px 0 2px}
footer{border-top:1px solid var(--line);padding-top:16px;color:var(--muted);font-size:13px}
@media print{body{background:#fff}nav.toc{display:none}.scroll{overflow:visible}
section{break-inside:avoid}article.finding,article.device{break-inside:avoid}}
"""


def render(rec: dict, public: bool = False, title: str | None = None) -> str:
    site = rec.get("site", {})
    findings = [f for f in (rec.get("findings") or [])
                if not (public and f.get("public_safe") is False)]
    findings.sort(key=lambda f: (SEV_ORDER.get(f.get("severity", "info"), 9), f.get("host_id") or ""))

    name = title or site.get("name") or site.get("id") or "Network survey"
    parts = [sec_summary(rec, findings, public), sec_method(rec, public),
             sec_diagram(rec, public), sec_inventory(rec),
             sec_devices(rec, public), sec_sweeps(rec, public),
             sec_findings(findings, public)]
    if not public:
        parts.append(sec_evidence(rec))

    toc = [("summary", "Summary"), ("method", "Method"), ("diagram", "Topology"),
           ("inventory", "Inventory"), ("devices", "Device detail")]
    if rec.get("sweeps") and not public:
        toc.append(("sweeps", "Address sweep"))
    toc.append(("findings", "Findings"))
    if not public and rec.get("evidence_log"):
        toc.append(("evidence", "Command log"))
    nav = "".join(f'<a href="#{i}">{esc(t)}</a>' for i, t in toc)

    stamp = "" if public else f' · {esc(rec.get("scanned_at",""))}'
    mode = "Public copy — hardening detail and the command log are omitted." if public else ""

    return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{esc(name)} — network survey</title>
<style>{CSS}</style>
</head>
<body>
<div class="wrap">
<header class="cover">
  <p class="eyebrow">Network survey{stamp}</p>
  <h1>{esc(name)}</h1>
  <p>{esc(site.get("customer") or "")}{" · " if site.get("customer") and site.get("address") else ""}{esc(site.get("address") or "")}</p>
  {f'<p class="muted">{esc(mode)}</p>' if mode else ''}
</header>
<nav class="toc">{nav}</nav>
{"".join(parts)}
<footer><p>Produced by <b>netwalk</b> — a read-only network survey toolkit.
No configuration on any surveyed device was modified.</p></footer>
</div>
</body>
</html>"""


def main() -> int:
    ap = argparse.ArgumentParser(prog="netwalk_report.py", description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("record")
    ap.add_argument("-o", "--out", required=True)
    ap.add_argument("--public", action="store_true",
                    help="copy for the site owner: no command log, no scan date, "
                         "no findings marked public_safe=false")
    ap.add_argument("--title")
    args = ap.parse_args()

    with open(args.record, encoding="utf-8") as fh:
        rec = json.load(fh)

    leaks = sweep(rec)
    if leaks:
        print("REFUSING TO RENDER - the scan record contains credential material.\n"
              "This file is meant to leave the building; fix the record first.\n",
              file=sys.stderr)
        for h in leaks[:25]:
            print(f"  {h}", file=sys.stderr)
        if len(leaks) > 25:
            print(f"  ... and {len(leaks)-25} more", file=sys.stderr)
        return 5

    html_out = render(rec, public=args.public, title=args.title)
    with open(args.out, "w", encoding="utf-8") as fh:
        fh.write(html_out)
    print(f"wrote {args.out} ({len(html_out):,} bytes, "
          f"{len(rec.get('devices', []))} device(s), "
          f"{len(rec.get('findings') or [])} finding(s)"
          f"{', PUBLIC copy' if args.public else ''})")
    return 0


if __name__ == "__main__":
    sys.exit(main())

SHA-256: 07755a0ce976640fbd0ee52255b0f5914c23db0c41fd39315f0fdf84d183b1ea