feat(devices): arp-scan parser + randomized-MAC detection
This commit is contained in:
31
lib/infra/scan.js
Normal file
31
lib/infra/scan.js
Normal file
@@ -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 "IP<ws>MAC<ws>[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);
|
||||||
|
}
|
||||||
35
tests/infra/scan.test.js
Normal file
35
tests/infra/scan.test.js
Normal file
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user