[object Object]

← 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 →