← 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);
}