DevLab / security / nmap-xml-parser-and-html-report-generator
SECURITY PYTHON INTERMEDIATE

Nmap XML Parser and HTML Report Generator

Nmap is the de facto standard for network reconnaissance, but its default text output is difficult to share with stakeholders, hard to diff across scans, a

Python3.10+
OSLinux / macOS
Lines~285
Packages1 deps
Install Dependencies
$ pip install jinja2
Usage
$ python3 nmap_report.py --xml scan.xml --output report.html
#security#python#intermediate#jinja2

Nmap XML Parser and HTML Report Generator

Problem Statement

Nmap is the de facto standard for network reconnaissance, but its default text output is difficult to share with stakeholders, hard to diff across scans, and nearly impossible to filter when you're dealing with hundreds of hosts. The -oX XML output option preserves all the rich detail Nmap collects (open ports, service versions, OS fingerprints, NSE script results, CPE identifiers), but raw XML is not something a security manager or client will read. Meanwhile, tools like Zenmap are desktop-only and don't fit into CI/CD pipelines or automated reporting workflows.

This script bridges that gap: it parses Nmap's XML output and renders a clean, self-contained HTML report you can email, publish to a wiki, or attach to a ticket. It's designed to be run non-interactively from a shell script, a cron job, or a CI pipeline — with a stable exit code and no interactive prompts.

How It Works

The script uses the standard library's xml.etree.ElementTree to walk the Nmap XML tree. It extracts a normalized data model — a list of host dictionaries containing addresses, hostnames, status, ports, services, and OS matches — so the rendering layer is decoupled from the XML schema. This separation means you can swap in a different data source (e.g., a database of past scans) without touching the template.

Rendering is handled by Jinja2. The template is embedded as a string constant so the script remains a single portable file; it uses a small amount of inline CSS so the report looks reasonable without any external assets. The output is a single HTML file with no JavaScript dependencies, making it safe to open offline and easy to attach to emails. Errors are surfaced through a custom NmapParseError exception, and the CLI uses argparse for a familiar interface. Environment variables provide defaults for output path and log level so the same script can be dropped into different environments without modification.

Full Script

#!/usr/bin/env python3
"""
Nmap XML Parser and HTML Report Generator

Parses an Nmap XML file (produced with `nmap -oX`) and renders a
self-contained HTML report using Jinja2.

Usage:
    python3 nmap_report.py --xml scan.xml --output report.html
"""

from __future__ import annotations

import argparse
import logging
import os
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable

from jinja2 import Environment, BaseLoader, select_autoescape

# ---------------------------------------------------------------------------
# Configuration (overridable via environment variables)
# ---------------------------------------------------------------------------
DEFAULT_OUTPUT = os.environ.get("NMAP_REPORT_OUTPUT", "nmap_report.html")
LOG_LEVEL = os.environ.get("NMAP_REPORT_LOG_LEVEL", "INFO").upper()
REPORT_TITLE = os.environ.get("NMAP_REPORT_TITLE", "Nmap Scan Report")

logging.basicConfig(
    level=LOG_LEVEL,
    format="%(asctime)s [%(levelname)s] %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("nmap_report")


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class NmapParseError(Exception):
    """Raised when the supplied file is not valid Nmap XML."""


# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class Port:
    protocol: str
    portid: int
    state: str
    service: str = ""
    product: str = ""
    version: str = ""
    extra_info: str = ""

    @property
    def banner(self) -> str:
        """Human-readable service banner, e.g. 'Apache httpd 2.4.52'."""
        parts = [p for p in (self.product, self.version, self.extra_info) if p]
        return " ".join(parts) if parts else self.service or "unknown"


@dataclass
class Host:
    addresses: list[str] = field(default_factory=list)
    hostnames: list[str] = field(default_factory=list)
    status: str = "unknown"
    ports: list[Port] = field(default_factory=list)
    os_matches: list[str] = field(default_factory=list)

    @property
    def display_name(self) -> str:
        if self.hostnames:
            return self.hostnames[0]
        return self.addresses[0] if self.addresses else "unknown"

    @property
    def open_ports(self) -> list[Port]:
        return [p for p in self.ports if p.state == "open"]


@dataclass
class ScanReport:
    scanner: str = "nmap"
    args: str = ""
    started: str = ""
    finished: str = ""
    hosts: list[Host] = field(default_factory=list)

    @property
    def total_hosts(self) -> int:
        return len(self.hosts)

    @property
    def hosts_up(self) -> int:
        return sum(1 for h in self.hosts if h.status == "up")

    @property
    def total_open_ports(self) -> int:
        return sum(len(h.open_ports) for h in self.hosts)


# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------
def parse_nmap_xml(xml_path: Path) -> ScanReport:
    """Parse an Nmap XML file into a ScanReport. Raises NmapParseError."""
    try:
        tree = ET.parse(xml_path)
    except FileNotFoundError as exc:
        raise NmapParseError(f"XML file not found: {xml_path}") from exc
    except ET.ParseError as exc:
        raise NmapParseError(f"Malformed XML in {xml_path}: {exc}") from exc

    root = tree.getroot()
    if root.tag != "nmaprun":
        raise NmapParseError(
            f"Root element is <{root.tag}>, expected <nmaprun>. "
            "Is this really an Nmap XML file?"
        )

    report = ScanReport(
        scanner=root.get("scanner", "nmap"),
        args=root.get("args", ""),
        started=root.get("startstr", ""),
        finished=root.get("timestr", ""),
    )

    for host_el in root.findall("host"):
        host = Host()

        # Addresses (can be multiple: IPv4, IPv6, MAC)
        for addr_el in host_el.findall("address"):
            addr = addr_el.get("addr")
            if addr:
                host.addresses.append(addr)

        # Status
        status_el = host_el.find("status")
        if status_el is not None:
            host.status = status_el.get("state", "unknown")

        # Hostnames
        hostnames_el = host_el.find("hostnames")
        if hostnames_el is not None:
            for hn in hostnames_el.findall("hostname"):
                name = hn.get("name")
                if name:
                    host.hostnames.append(name)

        # Ports
        ports_el = host_el.find("ports")
        if ports_el is not None:
            for port_el in ports_el.findall("port"):
                state_el = port_el.find("state")
                service_el = port_el.find("service")
                port = Port(
                    protocol=port_el.get("protocol", "tcp"),
                    portid=int(port_el.get("portid", "0")),
                    state=state_el.get("state", "unknown") if state_el is not None else "unknown",
                    service=service_el.get("name", "") if service_el is not None else "",
                    product=service_el.get("product", "") if service_el is not None else "",
                    version=service_el.get("version", "") if service_el is not None else "",
                    extra_info=service_el.get("extrainfo", "") if service_el is not None else "",
                )
                host.ports.append(port)

        # OS matches — keep the top 3 for brevity
        os_el = host_el.find("os")
        if os_el is not None:
            for match_el in os_el.findall("osmatch")[:3]:
                name = match_el.get("name")
                accuracy = match_el.get("accuracy")
                if name:
                    host.os_matches.append(f"{name} ({accuracy}%)" if accuracy else name)

        report.hosts.append(host)

    if not report.hosts:
        log.warning("No <host> elements found in %s", xml_path)

    return report


# ---------------------------------------------------------------------------
# HTML template (embedded so the script is a single portable file)
# ---------------------------------------------------------------------------
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>
  body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; color: #222; }
  h1 { border-bottom: 2px solid #333; padding-bottom: .3rem; }
  .meta { color: #666; font-size: .9rem; margin-bottom: 1.5rem; }
  .summary { display: flex; gap: 1.5rem; margin: 1rem 0 2rem; }
  .card { background: #f4f6f8; border-radius: 6px; padding: .8rem 1.2rem; }
  .card .num { font-size: 1.6rem; font-weight: 700; }
  .card .label { font-size: .8rem; color: #555; text-transform: uppercase; }
  table { border-collapse: collapse; width: 100%; margin-bottom: 2rem; }
  th, td { border: 1px solid #ddd; padding: .5rem .7rem; text-align: left; vertical-align: top; }
  th { background: #333; color: #fff; }
  tr:nth-child(even) { background: #fafafa; }
  .state-open { color: #0a7d2c; font-weight: 600; }
  .state-closed { color: #888; }
  .state-filtered { color: #b58900; }
  .host-header { background: #eef2f5; font-weight: 600; }
  .os { color: #555; font-style: italic; }
  footer { color: #888; font-size: .8rem; margin-top: 2rem; border-top: 1px solid #ddd; padding-top: .5rem; }
</style>
</head>
<body>
<h1>{{ title }}</h1>
<div class="meta">
  <div><strong>Scanner:</strong> {{ report.scanner }}</div>
  <div><strong>Command:</strong> <code>{{ report.args or "n/a" }}</code></div>
  <div><strong>Started:</strong> {{ report.started or "n/a" }}</div>
  <div><strong>Finished:</strong> {{ report.finished or "n/a" }}</div>
  <div><strong>Generated:</strong> {{ generated_at }}</div>
</div>

<div class="summary">
  <div class="card"><div class="num">{{ report.total_hosts }}</div><div class="label">Hosts</div></div>
  <div class="card"><div class="num">{{ report.hosts_up }}</div><div class="label">Up</div></div>
  <div class="card"><div class="num">{{ report.total_open_ports }}</div><div class="label">Open Ports</div></div>
</div>

{% for host in report.hosts %}
  <table>
    <tr class="host-header">
      <td colspan="4">
        {{ host.display_name }}
        {% if host.addresses %} &mdash; {{ host.addresses | join(", ") }}{% endif %}
        &mdash; <span class="state-{{ host.status }}">{{ host.status }}</span>
        {% if host.os_matches %}
          <div class="os">OS: {{ host.os_matches | join(" | ") }}</div>
        {% endif %}
      </td>
    </tr>
    {% if host.ports %}
    <tr>
      <th>Port</th><th>Proto</th><th>State</th><th>Service / Version</th>
    </tr>
    {% for p in host.ports %}
    <tr>
      <td>{{ p.portid }}</td>
      <td>{{ p.protocol }}</td>
      <td class="state-{{ p.state }}">{{ p.state }}</td>
      <td>{{ p.service }}{% if p.banner and p.banner != p.service %} &mdash; {{ p.banner }}{% endif %}</td>
    </tr>
    {% endfor %}
    {% else %}
    <tr><td colspan="4">No ports reported.</td></tr>
    {% endif %}
  </table>
{% endfor %}

<footer>Generated by nmap_report.py</footer>
</body>
</html>
"""


# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def render_report(report: ScanReport, title: str = REPORT_TITLE) -> str:
    """Render a ScanReport to an HTML string."""
    env = Environment(
        loader=BaseLoader(),
        autoescape=select_autoescape(["html", "xml"]),
        trim_blocks=True,
        lstrip_blocks=True,
    )
    template = env.from_string(HTML_TEMPLATE)
    return template.render(
        title=title,
        report=report,
        generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
    )


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_arg_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="Parse Nmap XML output and generate an HTML report.",
    )
    p.add_argument("--xml", required=True, type=Path, help="Path to Nmap XML file.")
    p.add_argument(
        "--output",
        type=Path,
        default=Path(DEFAULT_OUTPUT),
        help=f"Output HTML path (default: {DEFAULT_OUTPUT}).",
    )
    p.add_argument("--title", default=REPORT_TITLE, help="Report title.")
    return p


def main(argv: Iterable[str] | None = None) -> int:
    args = build_arg_parser().parse_args(argv)

    log.info("Parsing %s", args.xml)
    try:
        report = parse_nmap_xml(args.xml)
    except NmapParseError as exc:
        log.error("Parse failed: %s", exc)
        return 2

    log.info(
        "Parsed %d hosts (%d up), %d open ports",
        report.total_hosts,
        report.hosts_up,
        report.total_open_ports,
    )

    html = render_report(report, title=args.title)

    try:
        args.output.write_text(html, encoding="utf-8")
    except OSError as exc:
        log.error("Could not write report to %s: %s", args.output, exc)
        return 3

    log.info("Report written to %s", args.output)
    return 0


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

Configuration

The script reads three environment variables at import time. All have sensible defaults, so it runs out of the box:

Variable Default Purpose
NMAP_REPORT_OUTPUT nmap_report.html Default path for the generated HTML file. Overridden by --output.
NMAP_REPORT_LOG_LEVEL INFO Standard Python logging level (DEBUG, INFO, WARNING, ERROR).
NMAP_REPORT_TITLE Nmap Scan Report Title shown at the top of the HTML report. Overridden by --title.

Set them in your shell or CI environment, e.g. export NMAP_REPORT_LOG_LEVEL=DEBUG.

Usage Examples

Basic scan → report:

nmap -sV -oX scan.xml 192.168.1.0/24
python3 nmap_report.py --xml scan.xml --output report.html
# 2024-06-12 10:14:02 [INFO] Parsing scan.xml
# 2024-06-12 10:14:02 [INFO] Parsed 24 hosts (21 up), 87 open ports
# 2024-06-12 10:14:02 [INFO] Report written to report.html

Custom title and path via env vars (useful in CI):

export NMAP_REPORT_TITLE="Acme Corp — Weekly External Scan"
python3 nmap_report.py --xml nightly.xml
# Writes to ./nmap_report.html with the custom title

Debugging a malformed file:

NMAP_REPORT_LOG_LEVEL=DEBUG python3 nmap_report.py --xml broken.xml
# 2024-06-12 10:15:01 [ERROR] Parse failed: Malformed XML in broken.xml: ...
# exit code 2

Error Handling

The script uses a single custom exception (NmapParseError) to wrap all parsing failures and returns distinct exit codes so it can be used in shell conditionals:

  • Exit 2 — XML file missing, malformed, or root element is not <nmaprun>. This catches the common mistake of feeding it a Nessus or OpenVAS XML file.
  • Exit 3 — The output directory is not writable (permissions, full disk, bad path).
  • Exit 0 — Success.

Hosts with no <ports> section, missing <status>, or absent <os> data are handled gracefully: the parser uses sensible defaults (unknown, empty lists) and the template renders "No ports reported." rather than crashing. Jinja2's select_autoescape is enabled on the HTML template, so any hostname or service banner containing angle brackets is HTML-escaped — this matters because Nmap sometimes captures attacker-controlled banners during a scan.

Extending Ideas

  • Diff two scans: Load two ScanReport objects and add a template section highlighting newly opened ports or newly discovered hosts. This is a killer feature for change-detection pipelines.
  • Add NSE script output: The <hostscript> and per-port <script> elements are easy to extract; render them in collapsible <details> blocks for a much richer report.
  • Export to other formats: Because the data model is decoupled from rendering, you can add a --format json|csv|html flag and write a second renderer without touching the parser.
  • Bulk pipeline mode: Accept a directory of XML files, generate one report per scan, and produce an index page sorted by timestamp — handy for maintaining a rolling history of network state.
// RELATED_SCRIPTS
← Back to DevLab valtersit.com