← back to Domain Sniper
step 4: honeypot.js — controlled leak-attribution hunt
28b7d6333fdf7785abbd87d967de94f2efc62ae2 · 2026-05-12 13:30:49 -0700 · Steve Abrams
Fires 50 throwaway 5-letter names across 5 channels (4 leak-suspect + 1
control). After 24-48h, re-checks each via Cloudflare DoH to detect snipes,
then WHOIS-fingerprints every sniped name to cluster by registrar + IANA ID
+ nameserver root + country. Output names the most-likely attacker and
shows their NS signature for comparison against the butlr.app evidence
(ns112.ovh.net = OVH FR).
Names: random CVCVC consonant-vowel patterns, banned-list excludes any
stem near Steve's brand vocabulary. Bucket E is no-query control = baseline.
Usage:
node honeypot.js lure
# wait 24-48h
node honeypot.js check data/honeypot-<ts>.json
Files touched
Diff
commit 28b7d6333fdf7785abbd87d967de94f2efc62ae2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 12 13:30:49 2026 -0700
step 4: honeypot.js — controlled leak-attribution hunt
Fires 50 throwaway 5-letter names across 5 channels (4 leak-suspect + 1
control). After 24-48h, re-checks each via Cloudflare DoH to detect snipes,
then WHOIS-fingerprints every sniped name to cluster by registrar + IANA ID
+ nameserver root + country. Output names the most-likely attacker and
shows their NS signature for comparison against the butlr.app evidence
(ns112.ovh.net = OVH FR).
Names: random CVCVC consonant-vowel patterns, banned-list excludes any
stem near Steve's brand vocabulary. Bucket E is no-query control = baseline.
Usage:
node honeypot.js lure
# wait 24-48h
node honeypot.js check data/honeypot-<ts>.json
---
honeypot.js | 354 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 354 insertions(+)
diff --git a/honeypot.js b/honeypot.js
new file mode 100644
index 0000000..d47cb3e
--- /dev/null
+++ b/honeypot.js
@@ -0,0 +1,354 @@
+#!/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 args = process.argv.slice(2);
+const subcommand = args[0] || 'help';
+
+// ---------- 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) {
+ const C = 'bcdfghjklmnprstvwz';
+ const V = 'aeiou';
+ const r = (max) => Math.floor(rng() * max);
+ for (let tries = 0; tries < 50; tries++) {
+ const n = C[r(C.length)] + V[r(V.length)] + C[r(C.length)] + V[r(V.length)] + C[r(C.length)];
+ if (/(.)\1/.test(n)) continue;
+ if (BANNED_STEMS.has(n)) continue;
+ return n;
+ }
+ throw new Error('genName: too many retries');
+}
+
+const BUCKETS = [
+ { id: 'A', label: 'Verisign WHOIS (.com)', tld: 'com', method: 'whois-verisign-com' },
+ { id: 'B', label: 'Google WHOIS (.app)', tld: 'app', method: 'whois-google-app' },
+ { id: 'C', label: 'nic.co WHOIS (.co)', tld: 'co', method: 'whois-nic-co' },
+ { id: 'D', label: 'GoDaddy API (.com)', tld: 'com', method: 'godaddy-api-com' },
+ { id: 'E', label: 'CONTROL no-query (.com)', tld: 'com', 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: `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 lure() {
+ const batchTs = new Date().toISOString().replace(/[:.]/g, '-');
+ const seed = Date.now() & 0x7fffffff;
+ const rng = mulberry32(seed);
+ const batch = {
+ batchTs,
+ seed,
+ queriedFromIp: 'local (check `curl ifconfig.me` if you need to record)',
+ items: [],
+ };
+
+ // Generate 50 names, 10 per bucket
+ for (const bucket of BUCKETS) {
+ for (let i = 0; i < 10; i++) {
+ const stem = genName(rng);
+ batch.items.push({
+ bucket: bucket.id,
+ label: bucket.label,
+ method: bucket.method,
+ tld: bucket.tld,
+ stem,
+ domain: `${stem}.${bucket.tld}`,
+ queriedAt: null,
+ queryResult: null,
+ });
+ }
+ }
+
+ fs.mkdirSync(DATA_DIR, { recursive: true });
+ const batchPath = path.join(DATA_DIR, `honeypot-${batchTs}.json`);
+ fs.writeFileSync(batchPath, JSON.stringify(batch, null, 2));
+
+ console.log(`\n honeypot batch ${batchTs}`);
+ console.log(` seed=${seed} buckets=${BUCKETS.length} names=${batch.items.length}`);
+ console.log(` → ${batchPath}\n`);
+ console.log(` firing bait queries (this DELIBERATELY leaks — that's the point):\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));
+ // pace it so we look like a curious human, not a scanner
+ await sleep(1800 + Math.random() * 1400);
+ }
+
+ console.log(`\n bait deployed. ${batch.items.length} honeypots in the wild.`);
+ 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.queryResult === 'AVAILABLE' && !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.queryResult === 'AVAILABLE') 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);
+ const f = item.attackerFingerprint;
+ console.log(
+ ` ${item.domain.padEnd(14)} registrar=${(f.registrar || '?').slice(0, 30).padEnd(30)} ns=${(f.nameservers[0] || '?').slice(0, 28)}`,
+ );
+ 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)'}`);
+ }
+ } 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);
+}
← 4296639 step 3: live web dashboard at http://127.0.0.1:9895
·
back to Domain Sniper
·
honeypot v2: DoH pre-check is ground-truth; 6-letter .com to c8f80e8 →