Skill: arp-scan
Patterns for ARP table reading, ping-sweep discovery, and OUI vendor lookup.
Best use case
Skill: arp-scan is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
Patterns for ARP table reading, ping-sweep discovery, and OUI vendor lookup.
Teams using Skill: arp-scan should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/arp-scan/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How Skill: arp-scan Compares
| Feature / Agent | Skill: arp-scan | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
Patterns for ARP table reading, ping-sweep discovery, and OUI vendor lookup.
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# Skill: arp-scan
Patterns for ARP table reading, ping-sweep discovery, and OUI vendor lookup.
## ARP table parsing
Read the system ARP table after a ping sweep. The `arp -a` output format differs between macOS and Linux.
```ts
// packages/server/src/scanner.ts
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
interface ArpEntry {
ip: string;
mac: string;
}
async function readArpTable(): Promise<ArpEntry[]> {
const { stdout } = await execFileAsync('arp', ['-a']);
const entries: ArpEntry[] = [];
// macOS / BSD: "hostname (192.168.1.1) at d8:47:32:11:22:33 on en0 ifscope [ethernet]"
// Linux: "hostname (192.168.1.1) at D8:47:32:11:22:33 [ether] on eth0"
// Incomplete: "(192.168.1.100) at <incomplete> on en0"
const RE = /\((\d{1,3}(?:\.\d{1,3}){3})\)\s+at\s+([0-9a-fA-F:]{17})/g;
let m: RegExpExecArray | null;
while ((m = RE.exec(stdout)) !== null) {
entries.push({ ip: m[1], mac: m[2].toLowerCase() });
}
return entries;
}
```
## Ping sweep
Send ICMP pings to all addresses in the subnet to populate the ARP table before reading it.
Use `execFile` with the system `ping` command. Errors are ignored (host offline = exit code 1).
```ts
import { CIDR } from 'cidr-tools'; // npm i cidr-tools
async function pingSweep(cidr: string, timeoutSec = 1): Promise<void> {
const ips = CIDR.expand(cidr); // returns string[]
// Ping all hosts concurrently. Individual failures are expected.
await Promise.allSettled(
ips.map((ip) =>
execFileAsync('ping', ['-c', '1', '-W', String(timeoutSec * 1000), ip]).catch(() => {})
)
);
}
```
For large subnets (e.g. /16 = 65534 hosts) batch into groups of 256 to avoid system limits:
```ts
async function pingSweepBatched(cidr: string, batchSize = 256, timeoutSec = 1) {
const ips = CIDR.expand(cidr);
for (let i = 0; i < ips.length; i += batchSize) {
await pingSweep_batch(ips.slice(i, i + batchSize), timeoutSec);
}
}
```
## Full scan function
```ts
import type { Database } from 'better-sqlite3';
import { lookupVendor } from './oui';
import { resolveHostname } from './dns';
export interface ScanProgress {
progress: number; // 0-100
found: number;
checking: string | null;
}
export type ProgressCallback = (p: ScanProgress) => void;
export async function runScan(
db: Database,
cidr: string,
onProgress: ProgressCallback
): Promise<number> {
// 1. Ping sweep to populate ARP table
onProgress({ progress: 0, found: 0, checking: 'pinging...' });
await pingSweep(cidr, 1);
// 2. Read ARP table
const arpEntries = await readArpTable();
const now = Date.now();
// 3. Create scan_history record
const scanRow = db
.prepare(`INSERT INTO scan_history (started_at, subnet) VALUES (@started_at, @subnet)`)
.run({ started_at: now, subnet: cidr });
const scanId = scanRow.lastInsertRowid as number;
// 4. Upsert each discovered device
const upsert = db.prepare(`
INSERT INTO devices (mac, ip, hostname, vendor, is_gateway, first_seen, last_seen)
VALUES (@mac, @ip, @hostname, @vendor, @is_gateway, @now, @now)
ON CONFLICT(mac) DO UPDATE SET
ip = excluded.ip,
hostname = excluded.hostname,
vendor = COALESCE(excluded.vendor, vendor),
last_seen = excluded.last_seen
`);
const total = arpEntries.length;
for (let i = 0; i < total; i++) {
const { ip, mac } = arpEntries[i];
onProgress({ progress: Math.round(((i + 1) / total) * 100), found: i + 1, checking: ip });
const hostname = await resolveHostname(ip);
const vendor = lookupVendor(mac);
const is_gateway = (i === 0 && ip.endsWith('.1')) ? 1 : 0; // heuristic: first .1 = gateway
upsert.run({ mac, ip, hostname, vendor, is_gateway, now });
}
// 5. Finalize scan record
db.prepare(`
UPDATE scan_history
SET finished_at = @now, device_count = @count, status = 'done'
WHERE id = @id
`).run({ now: Date.now(), count: total, id: scanId });
return scanId;
}
```
## Gateway detection
The gateway is the device whose IP ends in `.1` in the subnet (e.g. `192.168.1.1` for `192.168.1.0/24`).
A more reliable method parses the routing table:
```ts
async function detectGateway(cidr: string): Promise<string | null> {
try {
// macOS
const { stdout } = await execFileAsync('route', ['-n', 'get', cidr.split('/')[0]]);
const m = stdout.match(/gateway:\s+([\d.]+)/);
return m ? m[1] : null;
} catch {
return null;
}
}
```
## Hostname resolution
```ts
// packages/server/src/dns.ts
import dns from 'dns/promises';
export async function resolveHostname(ip: string): Promise<string | null> {
try {
const result = await dns.reverse(ip);
return result[0] ?? null;
} catch {
return null;
}
}
```
## Scan progress via SSE
Stream scan progress to the browser using Server-Sent Events instead of polling:
```ts
// packages/server/src/routes/scan.ts
router.get('/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const send = (data: object) => res.write(`data: ${JSON.stringify(data)}\n\n`);
const unsubscribe = scanEmitter.on('progress', send);
req.on('close', unsubscribe);
});
// In scanner.ts, emit via scanEmitter
scanEmitter.emit('progress', { progress, found, checking });
```
Client side:
```ts
const es = new EventSource('/api/scan/stream');
es.onmessage = (e) => {
const data = JSON.parse(e.data);
useNetmapStore.getState().setScan(data);
};
// close when scan is done
```
## Known `arp -a` quirks
- macOS reports `<incomplete>` for hosts that did not respond to ARP; skip these.
- Some Linux distros require `arp-scan` package instead of base `arp`. Check availability with `which arp`.
- `arp -a` only shows hosts that have communicated recently. Run the ping sweep first to ensure entries are fresh.
- MAC addresses from `arp -a` are lowercase on Linux but mixed-case on macOS. Normalise with `.toLowerCase()`.
## OUI database
The OUI file is the IEEE MA-L registry in the format distributed at:
`https://regauth.standards.ieee.org/standards-ra-web/pub/view.html#registries`
Store it at `packages/server/data/oui.txt` and load once at startup.
Prefix format in the file is `AA-BB-CC` (hyphen separated); normalise to `AA:BB:CC` for map keys.
The lookup key is the first 3 octets of the MAC: `mac.toUpperCase().slice(0, 8)`.
## SQLite schema reminder
```sql
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mac TEXT NOT NULL UNIQUE,
ip TEXT NOT NULL,
hostname TEXT,
vendor TEXT,
label TEXT,
grp TEXT, -- NOT 'group' - reserved word in SQL
is_gateway INTEGER NOT NULL DEFAULT 0,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS scan_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at INTEGER NOT NULL,
finished_at INTEGER,
subnet TEXT NOT NULL,
device_count INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'running' -- running | done | failed
);
```
WAL mode is required to allow concurrent reads during a long scan:
```ts
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
```Related Skills
secret-scanner
Scan codebases for accidentally committed secrets using the secret-scanner CLI.
container-image-scanner
No description provided.
Skill: port-scanner CLI
## When to use
Skill: Network scanning with Node.js net.Socket
## When to use
Skill: Uptime Monitoring
## Overview
Skill: Status Page
## Overview
Skill: unit-conversion
## Overview
Skill: recipe-scaler
## Overview
reading-list
Operate the reading-list API to save, manage, tag, search, and export articles.
email-digest
Configure, test, and troubleshoot the reading-list daily email digest delivered via nodemailer.
websocket-realtime
Use the WebSocket connection in poll-builder to receive live vote updates. Use when you need to stream real-time poll results, monitor a poll for new votes, or build a live dashboard. Triggers include "live results", "real-time updates", "stream votes", "watch poll", or "WebSocket".
poll-builder
Self-hosted poll creation tool with real-time results. Use when you need to create a poll, check vote counts, close a poll, export results, or get the shareable link for a poll. Triggers include "create poll", "vote", "poll results", "survey", "collect votes", "share poll", or any task involving polling or voting.