Note on #184. @rachit367 opened a PR for this in June that I didn't see before starting — I looked at the issue thread rather than its linked PRs, which was my mistake. I've commented there with the differences. I'm happy to close this in favour of #184, or to contribute the tests and the state/banner handling to that branch instead — whichever the maintainers prefer.
Imports an nmap XML report (nmap -oX) that the investigator produced themselves, so a sketch can be enriched from a scan that was already run rather than scanning the target again. It goes through the existing import path — no new active-collection surface.
Mapping
Matches the shape the naabu enricher already produces:
each host reported up → Ip
each <hostname> → Domain, with REVERSE_RESOLVES_TO for a PTR record and RESOLVES_TO for a name given on the command line
each <port> → Port, linked HAS_PORT from its host
Two deliberate differences from naabu
Both are about not overstating what a scan found:
Port state is carried through verbatim rather than flattened to open, so filtered stays distinguishable from closed.
banner is only set when nmap actually fingerprinted the service. A service name guessed from the port number isn't presented as an identified one, and a TLS-wrapped service reads as ssl/http, the way nmap's own output prints it.
Security
Parsing goes through defusedxml. An uploaded report is untrusted input, and its service banners are whatever the scanned hosts sent back.
Known limitation — port identity is not host-scoped
I want to flag this rather than have it surface in review.
repository.py MERGEs on (nodeLabel, sketch_id), and Port.compute_label builds the label from number + service + protocol with no host in it. So two hosts in one report that both have 80/tcp/http collapse into a single shared Port node, and SET n += $props means the last host in the file wins its state and banner. I reproduced this end to end against the existing MERGE semantics: host A's port 80 open/nginx was overwritten to filtered/null by host B.
This is pre-existing — the naabu enricher uses the identical MERGE. But this PR is what makes it reachable: naabu hardcodes state="open" and runs one IP at a time, so conflicting states weren't previously possible. A /24 scan is the ordinary way people use nmap, and it's the first thing anyone will feed this.
The parser can't fix it. The label is computed by Port.compute_label in flowsint-types, and changing it would change the naabu enricher's node identity too — that's a maintainer's call, not a first-PR call. Happy to follow up with it if you tell me which way you want it to go.
Tests
637 lines covering the mapping, port-state handling, banner suppression, PTR vs command-line hostname edge direction, and malformed/hostile XML.
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.
## 📋 Pull Request Information
**Original PR:** https://github.com/reconurge/flowsint/pull/216
**Author:** [@3moorydxb](https://github.com/3moorydxb)
**Created:** 8/24/2026
**Status:** 🔄 Open
**Base:** `main` ← **Head:** `feat/nmap-importer`
---
### 📝 Commits (1)
- [`45c90b2`](https://github.com/reconurge/flowsint/commit/45c90b2d3af8dc792d9140221de6ddc76f32d358) feat(core): add nmap XML import support
### 📊 Changes
**8 files changed** (+953 additions, -11 deletions)
<details>
<summary>View changed files</summary>
📝 `flowsint-api/app/api/routes/sketches.py` (+5 -3)
📝 `flowsint-app/src/components/sketches/import-sheet.tsx` (+6 -5)
📝 `flowsint-core/pyproject.toml` (+1 -0)
📝 `flowsint-core/src/flowsint_core/imports/file_parser.py` (+6 -3)
➕ `flowsint-core/src/flowsint_core/imports/nmap/__init__.py` (+5 -0)
➕ `flowsint-core/src/flowsint_core/imports/nmap/parse_nmap.py` (+282 -0)
➕ `flowsint-core/tests/import/nmap/test_nmap_import.py` (+637 -0)
📝 `uv.lock` (+11 -0)
</details>
### 📄 Description
Closes #107.
> **Note on #184.** @rachit367 opened a PR for this in June that I didn't see before starting — I looked at the issue thread rather than its linked PRs, which was my mistake. I've commented there with the differences. I'm happy to close this in favour of #184, or to contribute the tests and the state/banner handling to that branch instead — whichever the maintainers prefer.
Imports an nmap XML report (`nmap -oX`) that the investigator produced themselves, so a sketch can be enriched from a scan that was already run rather than scanning the target again. It goes through the existing import path — no new active-collection surface.
## Mapping
Matches the shape the naabu enricher already produces:
- each host reported up → `Ip`
- each `<hostname>` → `Domain`, with `REVERSE_RESOLVES_TO` for a PTR record and `RESOLVES_TO` for a name given on the command line
- each `<port>` → `Port`, linked `HAS_PORT` from its host
## Two deliberate differences from naabu
Both are about not overstating what a scan found:
- **Port state is carried through verbatim** rather than flattened to open, so `filtered` stays distinguishable from `closed`.
- **`banner` is only set when nmap actually fingerprinted the service.** A service name guessed from the port number isn't presented as an identified one, and a TLS-wrapped service reads as `ssl/http`, the way nmap's own output prints it.
## Security
Parsing goes through `defusedxml`. An uploaded report is untrusted input, and its service banners are whatever the scanned hosts sent back.
## Known limitation — port identity is not host-scoped
I want to flag this rather than have it surface in review.
`repository.py` MERGEs on `(nodeLabel, sketch_id)`, and `Port.compute_label` builds the label from number + service + protocol with **no host in it**. So two hosts in one report that both have `80/tcp/http` collapse into a single shared `Port` node, and `SET n += $props` means the last host in the file wins its state and banner. I reproduced this end to end against the existing MERGE semantics: host A's port 80 `open`/`nginx` was overwritten to `filtered`/`null` by host B.
**This is pre-existing** — the naabu enricher uses the identical MERGE. But this PR is what makes it reachable: naabu hardcodes `state="open"` and runs one IP at a time, so conflicting states weren't previously possible. A `/24` scan is the ordinary way people use nmap, and it's the first thing anyone will feed this.
The parser can't fix it. The label is computed by `Port.compute_label` in `flowsint-types`, and changing it would change the naabu enricher's node identity too — that's a maintainer's call, not a first-PR call. Happy to follow up with it if you tell me which way you want it to go.
## Tests
637 lines covering the mapping, port-state handling, banner suppression, PTR vs command-line hostname edge direction, and malformed/hostile XML.
---
<sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
📋 Pull Request Information
Original PR: https://github.com/reconurge/flowsint/pull/216
Author: @3moorydxb
Created: 8/24/2026
Status: 🔄 Open
Base:
main← Head:feat/nmap-importer📝 Commits (1)
45c90b2feat(core): add nmap XML import support📊 Changes
8 files changed (+953 additions, -11 deletions)
View changed files
📝
flowsint-api/app/api/routes/sketches.py(+5 -3)📝
flowsint-app/src/components/sketches/import-sheet.tsx(+6 -5)📝
flowsint-core/pyproject.toml(+1 -0)📝
flowsint-core/src/flowsint_core/imports/file_parser.py(+6 -3)➕
flowsint-core/src/flowsint_core/imports/nmap/__init__.py(+5 -0)➕
flowsint-core/src/flowsint_core/imports/nmap/parse_nmap.py(+282 -0)➕
flowsint-core/tests/import/nmap/test_nmap_import.py(+637 -0)📝
uv.lock(+11 -0)📄 Description
Closes #107.
Imports an nmap XML report (
nmap -oX) that the investigator produced themselves, so a sketch can be enriched from a scan that was already run rather than scanning the target again. It goes through the existing import path — no new active-collection surface.Mapping
Matches the shape the naabu enricher already produces:
Ip<hostname>→Domain, withREVERSE_RESOLVES_TOfor a PTR record andRESOLVES_TOfor a name given on the command line<port>→Port, linkedHAS_PORTfrom its hostTwo deliberate differences from naabu
Both are about not overstating what a scan found:
filteredstays distinguishable fromclosed.banneris only set when nmap actually fingerprinted the service. A service name guessed from the port number isn't presented as an identified one, and a TLS-wrapped service reads asssl/http, the way nmap's own output prints it.Security
Parsing goes through
defusedxml. An uploaded report is untrusted input, and its service banners are whatever the scanned hosts sent back.Known limitation — port identity is not host-scoped
I want to flag this rather than have it surface in review.
repository.pyMERGEs on(nodeLabel, sketch_id), andPort.compute_labelbuilds the label from number + service + protocol with no host in it. So two hosts in one report that both have80/tcp/httpcollapse into a single sharedPortnode, andSET n += $propsmeans the last host in the file wins its state and banner. I reproduced this end to end against the existing MERGE semantics: host A's port 80open/nginxwas overwritten tofiltered/nullby host B.This is pre-existing — the naabu enricher uses the identical MERGE. But this PR is what makes it reachable: naabu hardcodes
state="open"and runs one IP at a time, so conflicting states weren't previously possible. A/24scan is the ordinary way people use nmap, and it's the first thing anyone will feed this.The parser can't fix it. The label is computed by
Port.compute_labelinflowsint-types, and changing it would change the naabu enricher's node identity too — that's a maintainer's call, not a first-PR call. Happy to follow up with it if you tell me which way you want it to go.Tests
637 lines covering the mapping, port-state handling, banner suppression, PTR vs command-line hostname edge direction, and malformed/hostile XML.
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.