Перейти к содержимому

Парсинг вывода Nmap на Python: от XML до готового отчёта

Парсинг вывода Nmap на Python: от XML до готового отчёта
Время чтения: 15 мин.

Внутренний пентест, сеть на 500 хостов. Nmap (Network Mapper — утилита для обнаружения открытых портов и сервисов в сети) отработал за полтора часа, а сбор отчёта по открытым портам растянулся на остаток дня: текстовый вывод в терминале, ручное копирование в Google Sheets, группировка по сервисам. На третьем таком проекте я написал скрипт. 30 строк на Python — и рутина превращается в три секунды работы процессора. Дальше — пошаговая сборка такого скрипта: от правильных флагов Nmap до генерации CSV-отчёта, который сразу ложится в результаты аудита.

Почему текстовый вывод Nmap — тупик для автоматизации

Nmap по умолчанию выводит результаты в интерактивном текстовом формате — тот самый, который удобно читать глазами в терминале. Типичная картина: строка Nmap scan report for 192.168.1.10, ниже — таблица с колонками PORT, STATE, SERVICE. Вроде бы всё понятно.

Первый инстинкт — парсить этот текст регулярными выражениями (модуль re в Python). На практике это путь в никуда.

Формат нестабилен. Текстовый вывод Nmap — представление для человека, не программный интерфейс. Заголовки, отступы, порядок секций меняются между версиями. Regex, собранный под Nmap 7.93, ломается на 7.94 из-за лишнего пробела или изменённой формулировки. Обратной совместимости текстового формата никто не гарантирует.

Структура неоднородна. Не определил Nmap сервис — строка выглядит иначе. Порт в состоянии filtered может содержать поле, которого нет у open. Хост с IPv6 — ещё один формат. Каждый крайний случай — отдельное регулярное выражение, и скрипт быстро превращается в нечитаемое месиво условий.

Данные теряются. Текстовый формат содержит лишь часть информации. Версия продукта (например, «OpenSSH 8.9p1 Debian 3ubuntu7»), CPE-идентификаторы (стандартизированные имена ПО вроде cpe:/a:openbsd:openssh:8.9p1 — по ним автоматически ищут уязвимости в базах), результаты NSE-скриптов (Nmap Scripting Engine — встроенный движок для автоматических проверок безопасности), данные трассировки — всё это есть в XML, но обрезано или отсутствует в тексте.

Nmap поддерживает пять форматов вывода: интерактивный (по умолчанию), нормальный (-oN), XML (-oX), grepable (-oG) и script kiddie (-oS). Для программной обработки предназначен XML. Автор Nmap прямо пишет в документации: «I strongly recommend that programmers interact with Nmap through the XML interface rather than trying to parse the normal, interactive, or grepable output» (nmap.org/book/output-formats-xml-output.html). XML-формат описан через DTD (Document Type Definition — формальное описание допустимых элементов и атрибутов), актуальная версия — на nmap.org/data/nmap.dtd.

Grepable формат (-oG) иногда используют для быстрой фильтрации через grep и awk — найти все хосты с открытым SSH одной командой в bash. Но сам автор Nmap называет его «нежелательным для использования» и рекомендует XML для новых проектов. Для полноценной автоматизации парсинга вывода Nmap на Python — только XML.

В терминах MITRE ATT&CK (открытая база тактик и техник атак; T-коды вроде T1046 — её идентификаторы) сканирование Nmap соответствует технике Network Service Discovery (T1046, тактика Discovery) — обнаружение запущенных сервисов в сети. Если сканирование выполняется с внешней позиции до получения доступа, точнее подходит T1595.001 Active Scanning: Scanning IP Blocks (тактика Reconnaissance); T1046 актуален при сканировании изнутри уже скомпрометированной сети. Автоматизация обработки результатов через Python (T1059.006, тактика Execution) — стандартная практика и в пентесте, и в защитных операциях: SOC-инженеры парсят результаты для инвентаризации активов, пентестеры — для приоритизации целей.

XML-вывод Nmap: структура, которую парсит скрипт

Зачем понимать структуру XML: каждый элемент напрямую превращается в вызов Python-функции. Знаешь дерево — знаешь код.

Чтобы получить XML, Nmap запускается с флагом -oX: nmap -sV -p 1-1000 -oX scan_results.xml 192.168.1.0/24. Флаг -sV включает определение версий сервисов — без него в XML не будет атрибутов product и version, и отчёт потеряет половину ценности. Если нужны результаты сразу во всех форматах — используй -oA scan_results, Nmap создаст три файла: .xml, .nmap (текст) и .gnmap (grepable).

Ключевой фрагмент XML, который Nmap генерирует для каждого хоста. Структура основана на примере из официальной документации (nmap.org/book/output-formats-xml-output.html):

<host starttime="1315618421" endtime="1315618434">
  <status state="up" reason="echo-reply"/>
  <address addr="192.168.1.10" addrtype="ipv4"/>
  <hostnames>
    <hostname name="server.local" type="PTR"/>
  </hostnames>
  <ports>
    <port protocol="tcp" portid="22">
      <state state="open" reason="syn-ack"/>
      <service name="ssh" product="OpenSSH" version="8.9"/>
    </port>
  </ports>
</host>

Разберём каждый элемент — это карта для будущего Python-кода:

<host> — контейнер для одного хоста. Все данные о машине — внутри. Атрибуты starttime/endtime содержат Unix-timestamp (количество секунд с 1 января 1970 года) начала и окончания сканирования этого хоста. В Python этот элемент находится через root.findall('host').

<status state="up"> — жив ли хост. Если машина не ответила на пробы — state="down", и вложенных <ports> не будет. Скрипт может пропускать такие хосты.

<address addr="..." addrtype="ipv4"/> — IP-адрес. Атрибут addr — сам адрес, addrtype — тип: ipv4, ipv6 или mac. Если Nmap обнаружил MAC-адрес, будет второй элемент <address> с addrtype="mac". В Python: host.find('address').get('addr') вернёт первый найденный.

<port protocol="tcp" portid="22"> — конкретный порт. portid — номер порта (строка, не число!), protocoltcp или udp. В Python: port.get('portid') вернёт строку '22'.

<state state="open"> — состояние порта. Четыре значения: open (порт отвечает, сервис слушает), closed (порт доступен, но сервис не запущен), filtered (фаервол блокирует), open|filtered (Nmap не уверен). Атрибут reason объясняет, почему Nmap так решил — syn-ack означает, что порт ответил на SYN-пакет.

<service name="ssh" product="OpenSSH" version="8.9"/> — информация о сервисе. Атрибуты: name (тип — ssh, http, mysql), product (конкретный продукт — OpenSSH, Apache httpd), version (версия), extrainfo (дополнительные данные). Атрибуты product и version заполняются только при сканировании с -sV. Элемент <service> может отсутствовать вообще — Python-скрипт обязан проверять его наличие перед обращением. Это частый источник ошибок.

Полный путь от корня XML до порта: <nmaprun><host><ports><port>. В коде это tree.getroot().findall('host')host.findall('ports/port').

Скрипт парсинга XML Nmap на Python: ElementTree по шагам

Требования к окружению

Перед запуском скрипта проверь:

  • Python 3.6+ — для f-строк в выводе. Проверить: python3 --version
  • Nmap установлен — Linux: sudo apt install nmap, macOS: brew install nmap, Windows: инсталлятор с nmap.org
  • Root-права для SYN-скана — по умолчанию Nmap использует SYN-сканирование, которое требует raw-сокетов. Запускай sudo nmap .... Без root Nmap переключится на TCP Connect scan — работает, но медленнее
  • XML-файл — сгенерированный командой nmap -sV -oX scan_results.xml <цель>

Пошаговый разбор скрипта

[xml.etree.ElementTree](https://docs.python.org/3/library/xml.etree.elementtree.html) — модуль стандартной библиотеки Python для работы с XML. Устанавливать ничего не нужно: он есть в каждой инсталляции Python 3. Скрипт запустится на любой машине без pip install — в CI/CD, в Docker-контейнере, на свежей Kali.

Скрипт читает XML-файл Nmap и выводит все открытые порты с названиями сервисов:

import xml.etree.ElementTree as ET
tree = ET.parse('scan_results.xml')
for host in tree.getroot().findall('host'):
    addr = host.find('address').get('addr')
    for port in host.findall('ports/port'):
        if port.find('state').get('state') != 'open':
            continue
        svc = port.find('service')
        name = svc.get('name', '?') if svc is not None else '?'
        print(f'{addr}:{port.get("portid")} {name}')

Что происходит на каждом шаге и как понять, что всё работает:

Шаг 1. ET.parse('scan_results.xml') — читает XML-файл и строит в памяти дерево элементов. Если файл не найден — FileNotFoundError. Если XML повреждён (сканирование прервано Ctrl+C и файл не закрыт) — xml.etree.ElementTree.ParseError.

Шаг 2. tree.getroot().findall('host') — получаем корневой элемент <nmaprun> и ищем все <host>. Каждый элемент — один хост. Если живых хостов нет — список пуст, цикл не выполнится. Быстрая проверка: print(len(tree.getroot().findall('host'))) покажет число хостов.

Шаг 3. host.find('address').get('addr') — достаём IP. Метод find() возвращает первый найденный <address>, метод .get('addr') извлекает значение атрибута.

Шаг 4. host.findall('ports/port') — XPath-выражение (синтаксис навигации по XML). Читается: «внутри <host> найди <ports>, внутри него — все <port>». Возвращает список.

Шаг 5. port.find('state').get('state') != 'open' — фильтрация. Порты со статусами closed и filtered пропускаем через continue.

Шаг 6. svc = port.find('service') — ищем <service>. Проверка if svc is not None обязательна: без неё обращение к .get() на несуществующем элементе вызовет AttributeError. Самая частая ошибка у тех, кто впервые работает с ElementTree.

Шаг 7. svc.get('name', '?') — метод .get() с дефолтным значением. Если атрибут name отсутствует, вернёт '?' вместо None.

Ожидаемый вывод — одна строка на каждый открытый порт: 192.168.1.10:22 ssh, 192.168.1.10:80 http, 192.168.1.15:3306 mysql. Если вывод пуст — три проверки: (а) в XML есть хосты со статусом up, (б) среди портов есть open, (в) путь к файлу верный.

Скрипт сбора отчёта по открытым портам: генерация CSV

Вывод в консоль — для быстрой проверки. Для результатов аудита нужен структурированный файл. CSV (Comma-Separated Values — текстовый формат таблиц, разделённых запятыми) — универсальный выбор: открывается в Excel и Google Sheets, импортируется в SIEM-системы (Security Information and Event Management — платформы сбора и корреляции событий безопасности), базы данных и тикет-трекеры.

Скрипт берёт XML-файл Nmap и генерирует CSV с шестью колонками — IP, порт, протокол, сервис, продукт, версия:

import csv, xml.etree.ElementTree as ET
tree = ET.parse('scan_results.xml')
with open('report.csv', 'w', newline='') as f:
    w = csv.writer(f)
    w.writerow(['IP', 'Port', 'Protocol', 'Service', 'Product', 'Version'])
    for h in tree.getroot().findall('host'):
        ip = h.find('address').get('addr')
        for p in h.findall('ports/port'):
            if p.find('state').get('state') != 'open':
                continue
            s = p.find('service')
            w.writerow([ip, p.get('portid'), p.get('protocol'),
                s.get('name','') if s else '', s.get('product','') if s else '',
                s.get('version','') if s else ''])

Ключевые отличия от консольного скрипта:

csv.writer(f) создаёт объект, который правильно форматирует CSV. Надёжнее, чем склеивать строки вручную: csv.writer корректно обрабатывает значения с запятыми и кавычками внутри (например, extrainfo вроде «Ubuntu, protocol 2.0»).

newline='' в open() — обязательный параметр на Windows. Без него csv.writer добавит пустые строки между записями. На Linux и macOS не влияет, но лучше указывать всегда — скрипт станет переносимым.

s.get('name','') if s else '' — тернарный оператор с защитой от None. Если элемент <service> отсутствует (переменная s равна None), подставляется пустая строка вместо падения с ошибкой.

Результат — файл report.csv с таблицей: IP, порт, протокол, имя сервиса, название продукта, версия. Открывается в Excel одним кликом. Скрипт фильтрует только порты open — закрытые и фильтрованные в отчёт не попадают. Нужны все порты — убери строки с проверкой состояния.

Для генерации отчёта в Markdown (удобно, если результаты идут в wiki или Git-репозиторий) замени блок записи в CSV на форматирование строк с разделителями |. Логика парсинга XML остаётся идентичной — меняется только финальный вывод.

Частые ошибки при обработке результатов сканирования Nmap

За полтора года автоматизации отчётов по сканированию я собрал коллекцию типичных ошибок — своих и чужих. Вот что ломает скрипты чаще всего.

Парсинг текстового вывода вместо XML. Об этом вся первая секция, но повторю: если в коде есть строка with open('scan.nmap') as f: for line in f: — остановись. Переключись на -oX. Один раз. Навсегда.

Забытая проверка <service> на None. Элемент <service> появляется только при использовании -sV. Быстрое сканирование вроде nmap -F target его не создаст. Код port.find('service').get('name') упадёт с AttributeError. Решение: всегда svc = port.find('service') и далее svc.get('name') if svc is not None else 'unknown'.

Путаница между find() и findall(). find() возвращает первый найденный элемент или None. findall() возвращает список всех найденных (может быть пустым, но не None). Для IP-адреса — find() (нужен один). Для портов — findall() (нужны все). Перепутаешь и вызовешь find('ports/port') — получишь только первый порт, остальные потеряются.

Битый XML от прерванного скана. Если Nmap прерван Ctrl+C, XML-файл может оказаться незакрытым — нет тега </nmaprun>. ET.parse() выбросит ParseError. Два решения: (а) перед парсингом проверить, заканчивается ли файл на </nmaprun> — прочитай последние 20 байт: with open('scan.xml', 'rb') as f: f.seek(-20, 2); tail = f.read() (файл открывай в бинарном режиме 'rb', иначе Python выбросит io.UnsupportedOperation); (б) использовать библиотеку libnmap, у которой парсинг незавершённых XML-файлов устойчивее.

portid — строка, не число. Атрибут portid в XML всегда строка: '22', '80', '443'. Если нужна сортировка по номеру порта — явное приведение типа: sorted(ports, key=lambda p: int(p.get('portid'))). Без int() сортировка будет лексикографической — '22', '3306', '443', '80' вместо '22', '80', '443', '3306'.

Несколько элементов <address> у одного хоста. Если Nmap обнаружил IPv4 и MAC-адрес, в XML будет два тега <address>. Вызов host.find('address').get('addr') вернёт первый — обычно IPv4. Нужен именно IPv4 гарантированно — фильтруй по addrtype: host.find('address[@addrtype="ipv4"]').get('addr') (XPath-фильтр по значению атрибута).

Кодировка при сохранении в CSV. На Windows Excel по умолчанию открывает CSV в кодировке Windows-1251. Если в данных есть UTF-8 символы (например, имена хостов с кириллицей), таблица «поедет». Решение — открывать файл с кодировкой utf-8-sig: open('report.csv', 'w', newline='', encoding='utf-8-sig') — Python добавит BOM-маркер, и Excel поймёт кодировку.

python-nmap и libnmap: альтернативы для автоматизации Nmap Python

ElementTree закрывает задачу парсинга XML без внешних зависимостей. Но есть библиотеки, которые упрощают работу, когда задача выходит за рамки «прочитать файл и вытащить порты».

python-nmap — обёртка, которая умеет и запускать Nmap из Python, и парсить результаты. Ставится через pip install python-nmap. Основной сценарий — запуск сканирования и обработка результатов в одном скрипте, без промежуточного XML-файла. Класс PortScanner вызывает Nmap как подпроцесс и парсит вывод автоматически. Вызов nm.scan('192.168.1.0/24', '22,80,443') запустит сканирование и вернёт словарь Python с результатами. Ограничения: требует установленный Nmap на машине (это обёртка, не замена), не всегда поддерживает последние флаги новых версий, документация скудная.

libnmap (ставится как pip install python-libnmap) — более продвинутая библиотека с объектной моделью. Важный момент: у пакета есть известные уязвимости (GHSA-9ccv-p7fg-m73x — XML Injection, GHSA-qwqv-j7jr-4hp6 — Argument Injection); используй версию ≥0.7.4 (также учитывай PYSEC-2019-218 — Command Injection, исправлена в 0.7.2) и проверяй актуальные advisory на osv.dev перед установкой. По документации (libnmap.readthedocs.io), NmapParser.parse_fromfile() возвращает объект NmapReport, из которого через .hosts получаешь список объектов NmapHost. У каждого хоста есть метод get_open_ports() (список кортежей (порт, протокол)) и атрибут .services (список объектов NmapService с полями .port, .service, .banner). Libnmap также способна обрабатывать незавершённые XML-файлы.

Libnmap особенно удобна для list comprehension в IPython или Jupyter: конструкция вроде [a.address for a in nmap_report.hosts if 443 in [b[0] for b in a.get_open_ports()]] за одну строку вытаскивает все хосты с открытым 443-м портом. Для интерактивного анализа результатов большого скана — то что нужно.

Подход Зависимости Запуск Nmap Парсинг XML Когда использовать
ElementTree Нет (stdlib) Отдельно Ручной по элементам Простые задачи, CI/CD, нет зависимостей
python-nmap pip install Из Python Автоматический Запуск + парсинг в одном скрипте
libnmap (≥0.7.4) pip install Из Python Объектная модель Сложные запросы, интерактивный анализ

Для большинства задач по генерации отчётов ElementTree достаточно: не требует установки, работает в любом окружении и не ломается при обновлении Nmap — формат XML стабилен. Переход на libnmap оправдан, когда нужно обрабатывать десятки сканов одновременно или строить сложные выборки в интерактивном режиме.

Отдельная тема — импорт результатов в Metasploit Framework (открытый фреймворк для пентеста). Команда db_nmap -A 192.168.1.0/24 в консоли msfconsole запускает Nmap и автоматически сохраняет результаты в базу данных Metasploit, откуда их можно извлечь командами hosts и services. Подход для тех случаев, когда парсинг XML вообще не нужен — данные сразу попадают в рабочую среду. Но если нужен автономный отчёт без Metasploit — возвращаемся к Python и ElementTree.

Я автоматизировал сбор отчётов по Nmap около полутора лет назад и с тех пор не собрал ни одного вручную. Но наблюдаю устойчивый паттерн у новичков в ИБ: люди тратят недели на изучение NSE-скриптов, разбираются с timing templates, учат категории скриптов — а результаты сканирования копируют из терминала в документ руками. Автоматизация разведки в пентесте начинается не с продвинутых техник обхода фаерволов, а с банального «перестать делать руками то, что делает 30-строчный скрипт».

ElementTree и десять строк кода закрывают 80% задач по обработке результатов Nmap. Оставшиеся 20% — парсинг результатов NSE-скриптов (они лежат в тегах <script> с произвольной структурой output), обработка OS fingerprinting и корреляция между несколькими сканированиями. Для этого стоит переходить на libnmap или писать собственные обёртки.

Но дело не в выборе библиотеки. Python для пентестера — инструмент, который превращает три часа рутины в три секунды выполнения. Каждый час, потраченный на автоматизацию отчётов, освобождает десятки часов для работы, которая действительно требует мозга: анализ найденных сервисов, поиск уязвимостей, построение цепочек атак. На IB Basics показываем, что делать в первый месяц после «хочу в ИБ» — включая базовую автоматизацию, которая отличает джуниора от человека, который «просто запускает инструменты».

Эту тему и смежные навыки разбирают на практике в курсе «Основы программирования на Python» Codeby Academy.