~/2026/03/11/node-js-discovery-diffing-two-network-scans-to-flag-new-hosts.md
Node.js: Discovery – Diffing Two Network Scans to Flag New Hosts
--- author: Tom Lasswell date: read: 3 min in: [scripts, engineering] tags: [nodejs, discovery, network] ---
$ grep -n '^#' post.md
A single network scan tells you what's out there today. It's the second scan, compared against the first, that tells you something changed: a rogue device showed up on a subnet it shouldn't be on, a host that was supposed to be decommissioned is still answering, or a service quietly opened a new port. I run a scheduled nmap sweep against the office and datacenter subnets and keep the XML output from each run; this script diffs any two of those XML files and reports new hosts, hosts that dropped off, hosts that moved to a different IP, and hosts whose open-port set changed, without pulling in an XML parsing dependency.
It's the "keep it current" half of the discovery-first approach to documenting an undocumented network: the first scan feeds the inventory, and every scan after that feeds a change report.
Requirements
- A supported Node.js LTS release (22 or later). No
npm install; only the corefsandpathmodules. - nmap on the scanning host, run as root or Administrator if you want SYN scans (
-sS), OS detection, and MAC addresses. Unprivileged nmap falls back to connect scans, and MAC addresses only appear in the XML for targets on the local Ethernet segment. - Two scans taken with the same profile and port range, so the diff is meaningful. Comparing a top-1000-ports scan against a full
-p-scan reports a lot of "new ports" that are only new to the scan. - Written authorization for every range in the schedule.
Usage
Take a baseline and a follow-up scan with an identical command. -oX writes XML; --top-ports 1000 keeps a daily /24 run short; --reason records why each port got its state, which helps when a diff looks wrong:
sudo nmap -sS --top-ports 1000 -T4 --reason -oX baseline.xml 10.0.20.0/24
# ...next day...
sudo nmap -sS --top-ports 1000 -T4 --reason -oX current.xml 10.0.20.0/24
Diff them and print a summary plus the JSON report:
node diff-scans.js baseline.xml current.xml
Baseline: baseline.xml (Tue Mar 10 02:00:01 2026) Current: current.xml (Wed Mar 11 02:00:01 2026)
New hosts: 1, removed: 1, moved: 1, port changes: 2
NEW 10.0.20.87 (unknown) mac 3c:22:fb:4e:91:07 ports: tcp/22, tcp/5900
REMOVED 10.0.20.31 print-2f.corp.example.internal
MOVED b8:ca:3a:6d:12:a5 10.0.20.44 -> 10.0.20.46
PORTS 10.0.20.10 +tcp/8080 (http-proxy)
PORTS 10.0.20.12 -tcp/3389 (ms-wbt-server)
Write the report to a file for a scheduled job, and fail the job when anything new shows up:
node diff-scans.js baseline.xml current.xml --output diff-report.json --fail-on-new
A nightly wrapper that rotates the files (cron runs it as root):
#!/usr/bin/env bash
# nightly-scan.sh: scan, diff against yesterday, keep a dated copy.
set -euo pipefail
cd /var/lib/netscan
TARGETS="10.0.20.0/24 10.0.30.0/24"
nmap -sS --top-ports 1000 -T4 --reason -oX current.xml $TARGETS
cp current.xml "archive/scan-$(date +%F).xml"
if [ -f baseline.xml ]; then
node /opt/netscan/diff-scans.js baseline.xml current.xml --output "archive/diff-$(date +%F).json" --fail-on-new \
|| echo "New hosts detected: see archive/diff-$(date +%F).json" | mail -s "Network scan: new hosts" netops@example.com
fi
mv current.xml baseline.xml
Script
#!/usr/bin/env node
/**
* diff-scans.js
*
* Diff two nmap XML scans (nmap -oX output) and report hosts that appeared,
* disappeared, moved to a new IP (same MAC), or changed their open ports.
*
* Reads: two nmap XML files given as CLI arguments.
* Writes: a JSON report to stdout, or to --output if given.
* Exit code: 2 when --fail-on-new is set and new hosts were found.
*
* Dependency-free: it parses the regular subset of nmap's XML it needs with
* regular expressions. Point it at your own scan output, not untrusted XML.
*/
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = { output: null, failOnNew: false, files: [] };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--output') {
args.output = argv[i + 1];
i += 1;
} else if (arg === '--fail-on-new') {
args.failOnNew = true;
} else {
args.files.push(arg);
}
}
if (args.files.length !== 2) {
throw new Error('Usage: diff-scans.js <baseline.xml> <current.xml> [--output report.json] [--fail-on-new]');
}
return args;
}
function decode(value) {
return value
.replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
}
function attr(tag, name) {
const match = tag.match(new RegExp(`\\s${name}="([^"]*)"`));
return match ? decode(match[1]) : null;
}
function parseScan(xml) {
const runTag = (xml.match(/<nmaprun\s[^>]*>/) || [''])[0];
const hosts = new Map();
// "<host " or "<host>" only: nmap's DTD also allows <hosthint> elements, which a
// looser /<host[\s\S]*?<\/host>/ pattern would swallow along with the next host.
const hostBlocks = xml.match(/<host[\s>][\s\S]*?<\/host>/g) || [];
for (const block of hostBlocks) {
const statusTag = (block.match(/<status\s[^>]*>/) || [''])[0];
if (attr(statusTag, 'state') !== 'up') {
continue;
}
let ip = null;
let mac = null;
let vendor = null;
for (const addressTag of block.match(/<address\s[^>]*>/g) || []) {
const type = attr(addressTag, 'addrtype');
if (type === 'mac') {
mac = attr(addressTag, 'addr').toLowerCase();
vendor = attr(addressTag, 'vendor');
} else if (!ip) {
ip = attr(addressTag, 'addr');
}
}
if (!ip) {
continue;
}
const hostnameTag = (block.match(/<hostname\s[^>]*>/) || [''])[0];
const openPorts = {};
for (const portBlock of block.match(/<port\s[^>]*>[\s\S]*?<\/port>/g) || []) {
const portTag = portBlock.match(/<port\s[^>]*>/)[0];
const stateTag = (portBlock.match(/<state\s[^>]*>/) || [''])[0];
const serviceTag = (portBlock.match(/<service\s[^>]*>/) || [''])[0];
if (attr(stateTag, 'state') === 'open') {
openPorts[`${attr(portTag, 'protocol')}/${attr(portTag, 'portid')}`] = attr(serviceTag, 'name') || '';
}
}
hosts.set(ip, { ip, hostname: attr(hostnameTag, 'name'), mac, vendor, openPorts });
}
return { started: attr(runTag, 'startstr'), hosts };
}
function diffScans(baseline, current) {
const newHosts = [];
const removedHosts = [];
const movedHosts = [];
const changedPorts = [];
const baselineByMac = new Map([...baseline.values()].filter((h) => h.mac).map((h) => [h.mac, h]));
const currentByMac = new Map([...current.values()].filter((h) => h.mac).map((h) => [h.mac, h]));
// Current IP -> baseline host for moved hosts, so their ports get compared too.
const movedFrom = new Map();
for (const [ip, host] of current) {
if (baseline.has(ip)) {
continue;
}
const previous = host.mac ? baselineByMac.get(host.mac) : null;
if (previous && !current.has(previous.ip)) {
movedHosts.push({ mac: host.mac, from: previous.ip, to: ip, hostname: host.hostname });
movedFrom.set(ip, previous);
} else {
newHosts.push(host);
}
}
for (const [ip, host] of baseline) {
if (current.has(ip)) {
continue;
}
const moved = host.mac && currentByMac.has(host.mac) && !baseline.has(currentByMac.get(host.mac).ip);
if (!moved) {
removedHosts.push(host);
}
}
for (const [ip, currentHost] of current) {
const baselineHost = baseline.get(ip) || movedFrom.get(ip);
if (!baselineHost) {
continue;
}
const added = Object.keys(currentHost.openPorts).filter((p) => !(p in baselineHost.openPorts));
const dropped = Object.keys(baselineHost.openPorts).filter((p) => !(p in currentHost.openPorts));
if (added.length || dropped.length) {
changedPorts.push({
ip,
hostname: currentHost.hostname,
addedPorts: added.map((p) => ({ port: p, service: currentHost.openPorts[p] })),
droppedPorts: dropped.map((p) => ({ port: p, service: baselineHost.openPorts[p] })),
});
}
}
return { newHosts, removedHosts, movedHosts, changedPorts };
}
function printSummary(report) {
const { newHosts, removedHosts, movedHosts, changedPorts } = report;
console.error(`Baseline: ${report.baseline} (${report.baselineStarted}) Current: ${report.current} (${report.currentStarted})`);
console.error(`New hosts: ${newHosts.length}, removed: ${removedHosts.length}, moved: ${movedHosts.length}, port changes: ${changedPorts.length}\n`);
for (const h of newHosts) {
const mac = h.mac ? `mac ${h.mac}` : '';
console.error(` NEW ${h.ip.padEnd(12)} ${(h.hostname || '(unknown)').padEnd(20)} ${mac} ports: ${Object.keys(h.openPorts).join(', ') || 'none open'}`);
}
for (const h of removedHosts) {
console.error(` REMOVED ${h.ip.padEnd(12)} ${h.hostname || ''}`);
}
for (const m of movedHosts) {
console.error(` MOVED ${m.mac} ${m.from} -> ${m.to}`);
}
for (const c of changedPorts) {
const parts = [
...c.addedPorts.map((p) => `+${p.port} (${p.service})`),
...c.droppedPorts.map((p) => `-${p.port} (${p.service})`),
];
console.error(` PORTS ${c.ip.padEnd(12)} ${parts.join(' ')}`);
}
}
function main() {
const args = parseArgs(process.argv.slice(2));
const [baselinePath, currentPath] = args.files;
const baseline = parseScan(fs.readFileSync(path.resolve(baselinePath), 'utf8'));
const current = parseScan(fs.readFileSync(path.resolve(currentPath), 'utf8'));
const diff = diffScans(baseline.hosts, current.hosts);
const report = {
baseline: baselinePath,
baselineStarted: baseline.started,
current: currentPath,
currentStarted: current.started,
summary: {
newHosts: diff.newHosts.length,
removedHosts: diff.removedHosts.length,
movedHosts: diff.movedHosts.length,
hostsWithPortChanges: diff.changedPorts.length,
},
...diff,
};
printSummary(report);
const output = JSON.stringify(report, null, 2);
if (args.output) {
fs.writeFileSync(args.output, output, 'utf8');
console.error(`\nReport written to ${args.output}`);
} else {
console.log(output);
}
if (args.failOnNew && diff.newHosts.length > 0) {
process.exitCode = 2;
}
}
main();
Notes
- Bug fixed from the first version:
<hosthint>. nmap's DTD allows<hosthint>elements (a status, address and hostnames block) as siblings of<host>. The original/<host[\s\S]*?<\/host>/pattern matched<hosthint>too, so one match could span a hint and the following real host and attribute the wrong ports to an address. The pattern now requires whitespace or>afterhost. - Only up hosts count. The DTD defines host states
up,down,unknownandskipped, and the first version counted any<host>element as present. The script now checks<status state="up">before counting a host. - MAC-based "moved". On DHCP subnets a device that got a new lease shows up as one "new" and one "removed" host. When both scans include the MAC (root scan, local segment), the script pairs them, reports a move instead, and compares the moved host's ports against its old address. Across routers there's no MAC in the XML and the pairing doesn't happen.
- IPv6 works as long as both scans used
-6against the same targets; the first non-MAC<address>is used as the key. - "Removed" is not "gone". A host missing from the current scan may just not have answered discovery probes that night.
-T4already allows up to 6 retransmissions (--max-retries 6); if a host flaps every run, check it for power management or a host firewall that drops discovery probes before assuming it was decommissioned. - Parsing trade-off. Regex parsing keeps this dependency-free and works on nmap's own consistently formatted output. It is not a general XML parser; if you already have a dependency budget, an XML library is the more robust choice. The discovery-first essay uses Python's
xml.etree.ElementTreefor the same file.