From e3b482624d35f79cef20a4f43b0c320eb26622a4 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 8 Jun 2026 20:56:40 +1000 Subject: [PATCH] feat(devices): arp-scan parser + randomized-MAC detection --- lib/infra/scan.js | 31 +++++++++++++++++++++++++++++++ tests/infra/scan.test.js | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 lib/infra/scan.js create mode 100644 tests/infra/scan.test.js diff --git a/lib/infra/scan.js b/lib/infra/scan.js new file mode 100644 index 0000000..d41f60d --- /dev/null +++ b/lib/infra/scan.js @@ -0,0 +1,31 @@ +// Decoupled LAN scanner: pure parser + a thin arp-scan runner (exec injected +// for tests). The repo/cron own persistence — this module only produces rows. +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const pexec = promisify(execFile); + +// A locally-administered (randomized) MAC has bit 0x02 set in its first octet. +export function isRandomizedMac(mac) { + const first = parseInt(String(mac).split(':')[0], 16); + return Number.isFinite(first) && (first & 0x02) === 0x02; +} + +// Keep only "IPMAC[vendor]" lines; ignore banner/footer/garbage. +export function parseArpScan(text) { + const re = /^(\d{1,3}(?:\.\d{1,3}){3})\s+([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\s*(.*)$/; + const out = []; + for (const line of String(text).split('\n')) { + const m = line.match(re); + if (!m) continue; + const mac = m[2].toLowerCase(); + out.push({ ip: m[1], mac, vendor: m[3].trim(), randomized: isRandomizedMac(mac) }); + } + return out; +} + +// Run arp-scan on the local /24. `exec(file, args) -> {stdout}` injected for tests. +export async function runScan({ exec = pexec } = {}) { + const { stdout } = await exec('arp-scan', ['--localnet', '--plain', '--retry=2']); + return parseArpScan(stdout); +} diff --git a/tests/infra/scan.test.js b/tests/infra/scan.test.js new file mode 100644 index 0000000..98d4ce7 --- /dev/null +++ b/tests/infra/scan.test.js @@ -0,0 +1,35 @@ +// tests/infra/scan.test.js +import { describe, it, expect } from 'vitest'; +import { isRandomizedMac, parseArpScan, runScan } from '../../lib/infra/scan.js'; + +const SAMPLE = [ + 'Interface: eth0, type: EN10MB, MAC: bc:24:11:9b:b7:3a, IPv4: 192.168.1.216', + 'Starting arp-scan 1.10.0', + '192.168.1.13\tbc:a5:11:3e:06:88\tNetgear', + '192.168.1.171\t5a:da:61:7a:0f:12\t(Unknown)', + '192.168.1.1\t44:A5:6E:68:D0:E9\tNetgear Inc.', + 'garbage line that is not a host', + '', + '3 packets received by filter, 0 packets dropped' +].join('\n'); + +describe('scan parsing', () => { + it('isRandomizedMac flags locally-administered MACs', () => { + expect(isRandomizedMac('5a:da:61:7a:0f:12')).toBe(true); // 0x5a & 0x02 + expect(isRandomizedMac('bc:a5:11:3e:06:88')).toBe(false); // 0xbc & 0x02 == 0 + expect(isRandomizedMac('44:A5:6E:68:D0:E9')).toBe(false); + }); + + it('parseArpScan keeps only host lines, lowercases MAC, flags randomized', () => { + const rows = parseArpScan(SAMPLE); + expect(rows).toHaveLength(3); + expect(rows[0]).toEqual({ ip: '192.168.1.13', mac: 'bc:a5:11:3e:06:88', vendor: 'Netgear', randomized: false }); + expect(rows[1]).toEqual({ ip: '192.168.1.171', mac: '5a:da:61:7a:0f:12', vendor: '(Unknown)', randomized: true }); + expect(rows[2].mac).toBe('44:a5:6e:68:d0:e9'); // lowercased + }); + + it('runScan parses the injected exec stdout', async () => { + const rows = await runScan({ exec: async () => ({ stdout: SAMPLE }) }); + expect(rows.map(r => r.ip)).toEqual(['192.168.1.13', '192.168.1.171', '192.168.1.1']); + }); +});