~/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: 
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 core fs and path modules.
  • 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:

bash
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:

bash
node diff-scans.js baseline.xml current.xml
text
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:

bash
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):

bash
#!/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

javascript
#!/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(/&lt;/g, '<').replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/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 > after host.
  • Only up hosts count. The DTD defines host states up, down, unknown and skipped, 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 -6 against 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. -T4 already 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.ElementTree for the same file.

References