← back to Domain Sniper
honeypot.js
428 lines
#!/usr/bin/env node
// honeypot.js — controlled leak-attribution experiment.
//
// Fires 50 throwaway 5-letter names across 5 channels. After 24-48h, re-checks
// each via Cloudflare DoH (leak-minimal). Whichever channel has the highest
// snipe rate is the confirmed leak. Names are random consonant-vowel-consonant-
// vowel-consonant strings — nothing Steve would actually want.
//
// Usage:
// node honeypot.js lure # generate 50 names + fire bait queries
// node honeypot.js check <f> # 24-48h later, re-test each name + report
//
// The lure DELIBERATELY leaks via WHOIS/RDAP/GoDaddy paths. That's the experiment.
// Don't run it if you're not committed to seeing the bait potentially get sniped.
const https = require('https');
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(__dirname, 'data');
const FINGERPRINTS_FILE = path.join(__dirname, 'registrar-fingerprints.json');
const args = process.argv.slice(2);
const subcommand = args[0] || 'help';
// Load known-aggregator fingerprints (file is always present in repo)
let FINGERPRINTS = { operators: {} };
try {
FINGERPRINTS = JSON.parse(fs.readFileSync(FINGERPRINTS_FILE, 'utf8'));
} catch (e) {
// Non-fatal: just means we can't annotate operators
}
function lookupOperator(fp) {
if (!fp) return null;
// First try by IANA ID (most reliable)
if (fp.registrarIanaId && FINGERPRINTS.operators[fp.registrarIanaId]) {
return { id: fp.registrarIanaId, ...FINGERPRINTS.operators[fp.registrarIanaId] };
}
// Fall back to NS pattern match
for (const ns of fp.nameservers || []) {
for (const [id, op] of Object.entries(FINGERPRINTS.operators)) {
for (const pat of op.nsPatterns || []) {
try {
if (new RegExp(pat).test(ns)) return { id, ...op };
} catch {}
}
}
}
return null;
}
// ---------- deterministic RNG so we can reproduce the batch if needed ----------
function mulberry32(a) {
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---------- name generator: 5-letter CVCVC, no triple-letter, dictionary-light ----------
const BANNED_STEMS = new Set([
// any Steve-brand-adjacent stems live here so we never bait something he'd want
'callr', 'butlr', 'callr', 'doctr', 'lawyr', 'rentr',
]);
function genName(rng, len = 6) {
const C = 'bcdfghjklmnprstvwz';
const V = 'aeiou';
const r = (max) => Math.floor(rng() * max);
for (let tries = 0; tries < 50; tries++) {
// CVCVCV(C) alternating
let n = '';
for (let i = 0; i < len; i++) n += (i % 2 === 0 ? C : V)[r((i % 2 === 0 ? C : V).length)];
if (/(.)\1/.test(n)) continue;
if (BANNED_STEMS.has(n)) continue;
return n;
}
throw new Error('genName: too many retries');
}
const BUCKETS = [
// nameLen 6 for .com to escape the saturated 5-letter namespace
{ id: 'A', label: 'Verisign WHOIS (.com)', tld: 'com', nameLen: 6, method: 'whois-verisign-com' },
{ id: 'B', label: 'Google WHOIS (.app)', tld: 'app', nameLen: 5, method: 'whois-google-app' },
{ id: 'C', label: 'nic.co WHOIS (.co)', tld: 'co', nameLen: 5, method: 'whois-nic-co' },
{ id: 'D', label: 'GoDaddy API (.com)', tld: 'com', nameLen: 6, method: 'godaddy-api-com' },
{ id: 'E', label: 'CONTROL no-query (.com)', tld: 'com', nameLen: 6, method: 'none' },
];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ---------- query implementations ----------
function whoisQuery(server, domain) {
const r = spawnSync('whois', ['-h', server, domain], { encoding: 'utf8', timeout: 15_000 });
const out = ((r.stdout || '') + (r.stderr || '')).toLowerCase();
if (out.match(/no match|not found|no data found|domain not found|available|free/)) return 'AVAILABLE';
if (out.match(/registry domain id|registrar:|creation date:|name server:|nserver/)) return 'TAKEN';
return 'UNKNOWN';
}
function godaddyApi(domain) {
const key = process.env.GODADDY_KEY;
const secret = process.env.GODADDY_SECRET;
if (!key || !secret) return Promise.resolve('SKIPPED:no-godaddy-key');
return new Promise((resolve) => {
https
.get(
{
hostname: 'api.godaddy.com',
path: '/v1/domains/available?domain=' + encodeURIComponent(domain),
headers: { Authorization: process.env.GODADDY_PAT ? `Bearer ${process.env.GODADDY_PAT}` : `sso-key ${key}:${secret}` },
},
(res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
try {
const j = JSON.parse(d);
resolve(j.available === true ? 'AVAILABLE' : 'TAKEN');
} catch {
resolve(`HTTP-${res.statusCode}`);
}
});
},
)
.on('error', (e) => resolve('ERROR:' + e.message));
});
}
function doh(name, type) {
return new Promise((resolve, reject) => {
https
.get(
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`,
{ headers: { Accept: 'application/dns-json' } },
(res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
try {
resolve(JSON.parse(d));
} catch (e) {
reject(e);
}
});
},
)
.on('error', reject);
});
}
async function dohAvailable(domain) {
const [ns, soa] = await Promise.all([doh(domain, 'NS'), doh(domain, 'SOA')]);
return ns.Status === 3 && soa.Status === 3;
}
// ---------- WHOIS fingerprinting (safe to call on already-sniped names) ----------
function whoisFingerprint(domain) {
const r = spawnSync('whois', [domain], { encoding: 'utf8', timeout: 20_000 });
const out = r.stdout || '';
const grab = (re) => {
const m = out.match(re);
return m ? m[1].trim() : null;
};
const nameservers = [...out.matchAll(/name server:\s*(\S+)/gi)]
.map((m) => m[1].toLowerCase().replace(/\.$/, ''))
.filter((v, i, a) => a.indexOf(v) === i);
return {
registrar: grab(/registrar:\s*(.+)/i),
registrarIanaId: grab(/registrar iana id:\s*(\d+)/i),
registrarAbuseEmail: grab(/registrar abuse contact email:\s*(\S+)/i),
nameservers,
nsRoot: nameservers[0] ? nameservers[0].split('.').slice(-2).join('.') : null,
created: grab(/creation date:\s*(.+)/i) || grab(/created:\s*(.+)/i),
updated: grab(/updated date:\s*(.+)/i) || grab(/last updated:\s*(.+)/i),
registrantOrg: grab(/registrant organization:\s*(.+)/i),
registrantCountry: grab(/registrant country:\s*(.+)/i),
};
}
// ---------- LURE: generate names + fire bait queries ----------
async function findAvailable(bucket, rng, maxTries = 30) {
for (let i = 0; i < maxTries; i++) {
const stem = genName(rng, bucket.nameLen);
const domain = `${stem}.${bucket.tld}`;
if (await dohAvailable(domain)) return { stem, domain };
await sleep(180);
}
return null;
}
async function lure() {
const batchTs = new Date().toISOString().replace(/[:.]/g, '-');
const seed = Date.now() & 0x7fffffff;
const rng = mulberry32(seed);
const batch = {
batchTs,
seed,
note: 'DoH pre-check is ground-truth baseline. WHOIS/RDAP/API queries are pure bait — their results are recorded but not used for snipe detection.',
items: [],
};
fs.mkdirSync(DATA_DIR, { recursive: true });
const batchPath = path.join(DATA_DIR, `honeypot-${batchTs}.json`);
console.log(`\n honeypot batch ${batchTs}`);
console.log(` seed=${seed}\n`);
console.log(` phase 1: finding 10 verifiably-available names per bucket via Cloudflare DoH...\n`);
for (const bucket of BUCKETS) {
let found = 0;
while (found < 10) {
const cand = await findAvailable(bucket, rng);
if (!cand) {
console.log(` [${bucket.id}] giving up at ${found}/10 — namespace too saturated`);
break;
}
batch.items.push({
bucket: bucket.id,
label: bucket.label,
method: bucket.method,
tld: bucket.tld,
stem: cand.stem,
domain: cand.domain,
preCheckAvailable: true,
preCheckedAt: new Date().toISOString(),
queriedAt: null,
queryResult: null,
});
found++;
process.stdout.write(` [${bucket.id}] ${cand.domain.padEnd(14)} confirmed AVAILABLE via DoH\n`);
}
fs.writeFileSync(batchPath, JSON.stringify(batch, null, 2));
}
console.log(`\n ${batch.items.length} fresh-available bait names secured.\n`);
console.log(` phase 2: firing bait queries through suspect leak channels...\n`);
for (let i = 0; i < batch.items.length; i++) {
const item = batch.items[i];
item.queriedAt = new Date().toISOString();
try {
switch (item.method) {
case 'whois-verisign-com':
item.queryResult = whoisQuery('whois.verisign-grs.com', item.domain);
break;
case 'whois-google-app':
item.queryResult = whoisQuery('whois.nic.google', item.domain);
break;
case 'whois-nic-co':
item.queryResult = whoisQuery('whois.nic.co', item.domain);
break;
case 'godaddy-api-com':
item.queryResult = await godaddyApi(item.domain);
break;
case 'none':
item.queryResult = 'CONTROL';
break;
}
} catch (e) {
item.queryResult = 'ERROR:' + e.message;
}
console.log(
` [${item.bucket}] ${item.domain.padEnd(14)} ${item.method.padEnd(22)} ${item.queryResult}`,
);
fs.writeFileSync(batchPath, JSON.stringify(batch, null, 2));
await sleep(1500 + Math.random() * 1200);
}
console.log(`\n bait deployed. ${batch.items.length} honeypots in the wild.`);
console.log(` → ${batchPath}\n`);
console.log(` wait 24-48h, then:`);
console.log(` node honeypot.js check ${path.relative(__dirname, batchPath)}\n`);
}
// ---------- CHECK: re-test each name via DoH + tally per bucket ----------
async function check(batchPath) {
if (!batchPath) {
console.error('usage: node honeypot.js check <path/to/honeypot-*.json>');
process.exit(2);
}
const batch = JSON.parse(fs.readFileSync(batchPath, 'utf8'));
const results = {
batchTs: batch.batchTs,
checkedAt: new Date().toISOString(),
elapsedHours: (Date.now() - new Date(batch.batchTs.replace(/-/g, ':').replace(/:/, '-')).getTime()) / 3.6e6,
items: [],
};
console.log(`\n checking ${batch.items.length} honeypots via leak-minimal DoH...\n`);
for (const item of batch.items) {
const stillAvailable = await dohAvailable(item.domain);
const sniped = item.preCheckAvailable && !stillAvailable;
results.items.push({ ...item, stillAvailable, sniped });
const flag = sniped ? '\x1b[31m🎯 SNIPED\x1b[0m' : stillAvailable ? ' open ' : ' taken ';
console.log(` [${item.bucket}] ${item.domain.padEnd(14)} ${flag} was=${item.queryResult.padEnd(10)} now=${stillAvailable ? 'avail' : 'taken'}`);
await sleep(350);
}
// Tally per bucket
const tally = {};
for (const item of results.items) {
tally[item.bucket] = tally[item.bucket] || {
label: item.label,
method: item.method,
total: 0,
wasAvailable: 0,
sniped: 0,
snipedNames: [],
};
const t = tally[item.bucket];
t.total++;
if (item.preCheckAvailable) t.wasAvailable++;
if (item.sniped) {
t.sniped++;
t.snipedNames.push(item.domain);
}
}
// ---------- attacker fingerprinting: WHOIS-lookup every sniped name ----------
console.log(`\n fingerprinting attacker via WHOIS on sniped names...\n`);
const snipedItems = results.items.filter((i) => i.sniped);
for (const item of snipedItems) {
item.attackerFingerprint = whoisFingerprint(item.domain);
item.knownOperator = lookupOperator(item.attackerFingerprint);
const f = item.attackerFingerprint;
const op = item.knownOperator;
const opTag = op ? (op.aggressive ? `\x1b[31m[KNOWN-AGGRESSIVE: ${op.name}]\x1b[0m` : `[${op.name}]`) : '[unknown operator]';
console.log(
` ${item.domain.padEnd(14)} registrar=${(f.registrar || '?').slice(0, 26).padEnd(26)} ns=${(f.nameservers[0] || '?').slice(0, 26).padEnd(26)} ${opTag}`,
);
await sleep(800);
}
const resultsPath = batchPath.replace(/\.json$/, '-results.json');
fs.writeFileSync(resultsPath, JSON.stringify(results, null, 2));
// ---------- cluster sniped names by attacker fingerprint ----------
if (snipedItems.length) {
const byRegistrar = {};
const byNsRoot = {};
for (const item of snipedItems) {
const f = item.attackerFingerprint;
const k = f.registrar || 'unknown';
byRegistrar[k] = byRegistrar[k] || { count: 0, ianaId: f.registrarIanaId, countries: new Set(), nsRoots: new Set(), examples: [] };
byRegistrar[k].count++;
if (f.registrantCountry) byRegistrar[k].countries.add(f.registrantCountry);
if (f.nsRoot) byRegistrar[k].nsRoots.add(f.nsRoot);
byRegistrar[k].examples.push(item.domain);
const nsKey = f.nsRoot || 'unknown';
byNsRoot[nsKey] = (byNsRoot[nsKey] || 0) + 1;
}
console.log(`\n attacker fingerprint cluster:\n`);
console.log(` registrar count iana-id countries ns-root example`);
console.log(` ` + '-'.repeat(100));
const ranked = Object.entries(byRegistrar).sort((a, b) => b[1].count - a[1].count);
for (const [r, v] of ranked) {
console.log(
` ${r.slice(0, 33).padEnd(33)} ${String(v.count).padStart(3)}/${snipedItems.length} ${(v.ianaId || '?').padEnd(7)} ${[...v.countries].join(',').padEnd(9)} ${[...v.nsRoots].slice(0, 1).join('').padEnd(18)} ${v.examples[0]}`,
);
}
const top = ranked[0];
if (top) {
console.log(`\n most-likely attacker: \x1b[31m${top[0]}\x1b[0m (${top[1].count}/${snipedItems.length} sniped names)`);
console.log(` ns signature: ${[...top[1].nsRoots].join(', ')}`);
console.log(` iana id: ${top[1].ianaId || 'unknown'}`);
console.log(` abuse: ${snipedItems[0].attackerFingerprint.registrarAbuseEmail || '(none in WHOIS)'}`);
console.log(` countries: ${[...top[1].countries].join(', ') || '(privacy-protected)'}`);
}
// Cross-reference against known-aggressive operator database
const knownAggressive = snipedItems.filter((i) => i.knownOperator && i.knownOperator.aggressive);
if (knownAggressive.length) {
console.log(`\n \x1b[31m▲ KNOWN-AGGRESSIVE OPERATORS DETECTED:\x1b[0m`);
const byOp = {};
for (const item of knownAggressive) {
const k = item.knownOperator.name;
byOp[k] = byOp[k] || { count: 0, country: item.knownOperator.country, notes: item.knownOperator.notes, examples: [] };
byOp[k].count++;
byOp[k].examples.push(item.domain);
}
for (const [name, v] of Object.entries(byOp).sort((a, b) => b[1].count - a[1].count)) {
console.log(` ${name} (${v.country}) — ${v.count}/${snipedItems.length} sniped names`);
console.log(` example: ${v.examples[0]}`);
console.log(` notes: ${v.notes}`);
}
} else {
console.log(`\n no match against known-aggressive operator database — this attacker is new. Add their fingerprint to registrar-fingerprints.json.`);
}
} else {
console.log(`\n no snipes detected. either (a) we got lucky, (b) the aggregator paused, (c) different leak path. consider re-running with different channels or larger N.`);
}
console.log(`\n snipe rate by leak channel:\n`);
console.log(` bucket channel was-available sniped rate sample`);
console.log(` ` + '-'.repeat(86));
for (const [b, t] of Object.entries(tally)) {
const rate = t.wasAvailable === 0 ? 'n/a' : `${Math.round((100 * t.sniped) / t.wasAvailable)}%`;
const sample = t.snipedNames.slice(0, 3).join(', ');
const flag = t.sniped > 0 && b !== 'E' ? '\x1b[31m' : '';
const end = flag ? '\x1b[0m' : '';
console.log(
` ${flag}${b} ${t.label.padEnd(26)} ${String(t.wasAvailable).padStart(3)}/${t.total} ${String(t.sniped).padStart(2)}/${t.total} ${rate.padStart(4)} ${sample}${end}`,
);
}
console.log(`\n full results → ${resultsPath}`);
console.log(`\n interpretation:`);
console.log(` - E (CONTROL) should be 0 snipes — it's the baseline. >0 = something other than queries is leaking.`);
console.log(` - Whichever non-E bucket has the highest snipe-rate is the confirmed leak channel.`);
console.log(` - 0/n on all non-E buckets in 24h means either (a) lucky, (b) the aggregator paused, (c) different leak path. Re-run with larger N or different channels.\n`);
}
if (subcommand === 'lure') {
lure().catch((e) => { console.error(e); process.exit(1); });
} else if (subcommand === 'check') {
check(args[1]).catch((e) => { console.error(e); process.exit(1); });
} else {
console.error('usage:');
console.error(' node honeypot.js lure # fire 50 bait queries across 5 channels');
console.error(' node honeypot.js check data/honeypot-*.json # 24-48h later, tally snipes');
process.exit(2);
}