← back to Domain Sniper

honeypot-v2.js

307 lines

#!/usr/bin/env node
// honeypot-v2.js — second-generation leak-attribution experiment.
//
// v1 (honeypot.js) used pure-random CVCVC names (kuwavi, tahoz, fobahu). After
// the 3.87h full check returned 0/50, the working hypothesis became:
// aggregators desirability-filter their leak streams before paying registry
// fees, so pure gibberish doesn't bite.
//
// v2 tests the desirability hypothesis with two new bait cohorts:
//   - 'pronounceable' : English-flavored stems (techly, monax, ziven, palco)
//   - 'brand-adjacent': Levenshtein-1 mutations of real brand stems (callrz,
//                       butlrr, ventur) — close enough to look interesting
//                       but not actually a Steve-owned brand.
//
// SAFETY GATES (read carefully — this fires real leak queries when permitted):
//   1. `generate` is always allowed (just produces names + writes batch JSON).
//   2. `lure` is REFUSED unless the env LURE_CONFIRM_LEAK_2026 is set AND the
//      cohort is explicitly chosen. The env var is a one-shot Steve-typed
//      gesture, not something the YOLO loop should ever set on its own.
//   3. `check` is always allowed — DoH-only, leak-minimal.
//
// Usage:
//   node honeypot-v2.js generate pronounceable             # write batch JSON, no leaks
//   node honeypot-v2.js generate brand-adjacent
//   LURE_CONFIRM_LEAK_2026=yes node honeypot-v2.js lure pronounceable data/honeypot-v2-*.json
//   node honeypot-v2.js check data/honeypot-v2-*.json      # 24-48h later

const fs = require('fs');
const path = require('path');
const https = require('https');
const { spawnSync } = require('child_process');
const { isOwned, loadOwned } = require('./owned-check');

const DATA_DIR = path.join(__dirname, 'data');

// ---------- deterministic RNG (mulberry32) ----------
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;
  };
}

// ---------- generator: pronounceable English-flavored ----------
// Uses common English onset-nucleus-coda triplets so the result reads like a
// startup brand stem (techly, monax, palco). 5-7 chars depending on TLD.
const ONSETS = ['b','br','c','ch','cl','d','dr','f','fl','fr','g','gl','gr','h','j','k','kn','l','m','n','p','pl','pr','qu','r','s','sh','sk','sl','sp','st','sw','t','tr','tw','v','w','wh','y','z'];
const NUCLEI = ['a','e','i','o','u','ai','au','ay','ea','ee','ei','ie','oa','oo','ou','oy','ow'];
const CODAS = ['','b','ck','d','f','g','k','l','ld','lt','m','n','nd','nk','nt','p','r','rd','rk','rn','s','sh','sk','st','t','th','v','x','z'];
function genPronounceable(rng) {
  const r = (a) => a[Math.floor(rng() * a.length)];
  for (let tries = 0; tries < 80; tries++) {
    const n = (r(ONSETS) + r(NUCLEI) + r(CODAS) + (rng() < 0.4 ? r(NUCLEI) + r(CODAS) : '')).toLowerCase();
    if (n.length < 4 || n.length > 8) continue;
    if (/(.)\1\1/.test(n)) continue;             // no triple letter
    if (!/[aeiou]/.test(n)) continue;            // must have vowel
    return n;
  }
  throw new Error('genPronounceable: too many retries');
}

// ---------- generator: brand-adjacent ----------
// Levenshtein-1 mutations of Steve's brand stems. Insert / drop / swap one
// char, but reject anything that resolves back to an exact brand. The point
// is "looks like a possible squat" without actually using a brand name.
const BRAND_STEMS = ['callr','butlr','rentr','doctr','lawyr','venturr','novsuede','flockr','grasscloth','bubbe'];
function brandMutate(base, rng) {
  const r = (n) => Math.floor(rng() * n);
  const choices = ['append','prepend','swap','double'];
  const op = choices[r(choices.length)];
  if (op === 'append')  return base + 'aeiouz'[r(6)];
  if (op === 'prepend') return 'qzxy'[r(4)] + base;
  if (op === 'swap')    { const i = r(base.length); return base.slice(0,i) + (base[i] === 'r' ? 'z' : 'r') + base.slice(i+1); }
  if (op === 'double')  { const i = r(base.length); return base.slice(0,i) + base[i] + base.slice(i); }
  return base + 'x';
}
function genBrandAdjacent(rng) {
  for (let tries = 0; tries < 80; tries++) {
    const base = BRAND_STEMS[Math.floor(rng() * BRAND_STEMS.length)];
    const m = brandMutate(base, rng);
    if (m === base) continue;                   // mutation collapsed
    if (BRAND_STEMS.includes(m)) continue;      // never bait a real brand
    if (m.length < 4 || m.length > 10) continue;
    return m;
  }
  throw new Error('genBrandAdjacent: too many retries');
}

const COHORTS = {
  pronounceable:   { gen: genPronounceable,   notes: 'English-flavored startup-ish stems (techly/monax-style)' },
  'brand-adjacent':{ gen: genBrandAdjacent,   notes: 'Levenshtein-1 mutations of Steve brand stems — squat-shaped, not a real brand' },
};

// Same 5-bucket leak channels as v1 — keeps the experiment comparable.
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' },
];

// ---------- generate (always allowed) ----------
function generate(cohortKey, perBucket = 10) {
  const cohort = COHORTS[cohortKey];
  if (!cohort) {
    console.error(`unknown cohort: ${cohortKey}. valid: ${Object.keys(COHORTS).join(', ')}`);
    process.exit(2);
  }
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
  const seed = Date.now() & 0xffffffff;
  const rng = mulberry32(seed);
  const items = [];
  for (const b of BUCKETS) {
    for (let i = 0; i < perBucket; i++) {
      let stem; let attempts = 0;
      do { stem = cohort.gen(rng); attempts++; } while (items.some((x) => x.stem === stem) && attempts < 50);
      items.push({ bucket: b.id, label: b.label, method: b.method, tld: b.tld, stem, domain: `${stem}.${b.tld}` });
    }
  }
  // Safety gate: never bait a domain Steve owns. If any generated stem
  // collides with owned.json, drop and regenerate. If we somehow still produce
  // a collision after retries, refuse to write the batch.
  const ownedSet = new Set(loadOwned());
  const collided = items.filter((it) => ownedSet.has(it.domain.toLowerCase()));
  if (collided.length) {
    console.error(`\n  REFUSED: ${collided.length} generated stems collide with data/owned.json:`);
    for (const c of collided.slice(0, 5)) console.error(`    ${c.domain}`);
    console.error(`  Regenerate (different seed) or add the colliding stems to BRAND_STEMS exclusion in the generator.\n`);
    process.exit(6);
  }
  fs.mkdirSync(DATA_DIR, { recursive: true });
  const out = path.join(DATA_DIR, `honeypot-v2-${cohortKey}-${ts}.json`);
  const payload = { batchTs: ts, cohort: cohortKey, cohortNotes: cohort.notes, seed, items, lured: false };
  fs.writeFileSync(out, JSON.stringify(payload, null, 2));
  console.log(`\n  generated ${items.length} ${cohortKey} bait names → ${out}`);
  console.log(`  seed=${seed}  (reproducible)\n`);
  console.log('  sample:');
  for (const b of BUCKETS) {
    const samp = items.filter((x) => x.bucket === b.id).slice(0, 3).map((x) => x.domain).join(', ');
    console.log(`    ${b.id} ${b.label.padEnd(28)} ${samp}`);
  }
  console.log(`\n  next step (gated, requires Steve sign-off):`);
  console.log(`    LURE_CONFIRM_LEAK_2026=yes node honeypot-v2.js lure ${cohortKey} ${out}\n`);
}

// ---------- lure (refuses without explicit env gate) ----------
function lureRefusal(cohortKey, file) {
  console.error('\n  REFUSED: honeypot-v2 lure requires explicit Steve sign-off.');
  console.error('  This intentionally leaks bait queries to public WHOIS/registrar endpoints.');
  console.error('  YOLO autonomous loops must NEVER set LURE_CONFIRM_LEAK_2026.');
  console.error('\n  To proceed (Steve only, in a fresh shell):');
  console.error(`    LURE_CONFIRM_LEAK_2026=yes node honeypot-v2.js lure ${cohortKey || '<cohort>'} ${file || '<batch.json>'}\n`);
  process.exit(2);
}

// ---------- query helpers (only invoked from lure() under the env gate) ----------
function dohAvailNs(domain) {
  return new Promise((r) => {
    https.get('https://cloudflare-dns.com/dns-query?name=' + encodeURIComponent(domain) + '&type=NS',
      { headers: { accept: 'application/dns-json' }, timeout: 5000 }, (res) => {
      let body = ''; res.on('data', (c) => body += c); res.on('end', () => {
        try { const j = JSON.parse(body);
          const taken = (j.Answer || []).length > 0 || (j.Authority || []).some((a) => a.type === 6);
          r({ available: j.Status === 3 || (j.Status === 0 && !taken) });
        } catch { r({ available: false, err: 'parse' }); }
      });
    }).on('error', (e) => r({ available: false, err: e.message }));
  });
}

function whoisQ(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 fireBucketQuery(method, domain) {
  switch (method) {
    case 'whois-verisign-com': return Promise.resolve(whoisQ('whois.verisign-grs.com', domain));
    case 'whois-google-app':   return Promise.resolve(whoisQ('whois.nic.google',       domain));
    case 'whois-nic-co':       return Promise.resolve(whoisQ('whois.nic.co',           domain));
    case 'godaddy-api-com':    return godaddyApi(domain);
    case 'none':               return Promise.resolve('CONTROL');
    default:                   return Promise.resolve('UNKNOWN-METHOD');
  }
}

async function lure(cohortKey, file) {
  if (!file) { console.error('usage: lure <cohort> <batch.json>'); process.exit(2); }
  const batch = JSON.parse(fs.readFileSync(file, 'utf8'));
  if (batch.lured) { console.error(`  REFUSED: batch already lured at ${batch.luredAt}. Generate a fresh one.`); process.exit(2); }
  console.log(`\n  honeypot-v2 LURE  cohort=${batch.cohort}  batchTs=${batch.batchTs}`);
  console.log(`  ${batch.items.length} baits across ${new Set(batch.items.map(i=>i.bucket)).size} buckets\n`);
  console.log(`  phase 1: DoH-availability pre-check (only available baits get lured) ...\n`);
  let avail = 0;
  for (const it of batch.items) {
    const r = await dohAvailNs(it.domain);
    it.preCheckAvailable = !!r.available;
    it.preCheckedAt = new Date().toISOString();
    if (r.available) avail++;
    process.stdout.write(`  [${it.bucket}] ${it.domain.padEnd(16)} ${r.available ? 'AVAILABLE' : 'taken/err'}\n`);
    await new Promise((rr) => setTimeout(rr, 200));
  }
  fs.writeFileSync(file, JSON.stringify(batch, null, 2));
  console.log(`\n  ${avail} of ${batch.items.length} baits are DoH-available — these get lured.`);
  console.log(`  phase 2: firing bucket queries (only on the ${avail} available baits) ...\n`);
  for (const it of batch.items) {
    if (!it.preCheckAvailable) { it.queryResult = 'SKIPPED:not-available'; continue; }
    it.queriedAt = new Date().toISOString();
    try { it.queryResult = await fireBucketQuery(it.method, it.domain); }
    catch (e) { it.queryResult = 'ERROR:' + e.message; }
    console.log(`  [${it.bucket}] ${it.domain.padEnd(16)} ${it.method.padEnd(22)} ${it.queryResult}`);
    fs.writeFileSync(file, JSON.stringify(batch, null, 2));
    await new Promise((rr) => setTimeout(rr, 1500 + Math.random() * 1200));
  }
  batch.lured = true;
  batch.luredAt = new Date().toISOString();
  fs.writeFileSync(file, JSON.stringify(batch, null, 2));
  console.log(`\n  bait deployed. ${avail} honeypots in the wild.`);
  console.log(`  wait 24-48h, then:  node honeypot-v2.js check ${path.relative(__dirname, file)}\n`);
}

async function check(file) {
  if (!file) { console.error('usage: node honeypot-v2.js check <path/to/honeypot-v2-*.json>'); process.exit(2); }
  const batch = JSON.parse(fs.readFileSync(file, 'utf8'));
  console.log(`\n  checking ${batch.items.length} v2 baits via DoH (cohort=${batch.cohort})…\n`);
  function dohNs(d) {
    return new Promise((r) => {
      https.get('https://cloudflare-dns.com/dns-query?name=' + encodeURIComponent(d) + '&type=NS',
        { headers: { accept: 'application/dns-json' }, timeout: 5000 }, (res) => {
        let body = ''; res.on('data', (c) => body += c); res.on('end', () => {
          try { const j = JSON.parse(body); r({ status: j.Status, hasNs: (j.Answer || []).length > 0 || (j.Authority || []).some((a) => a.type === 6) }); }
          catch { r({ status: -1, hasNs: false }); }
        });
      }).on('error', () => r({ status: -2, hasNs: false }));
    });
  }
  const tally = {};
  for (const it of batch.items) {
    const r = await dohNs(it.domain);
    const stillAvail = r.status === 3 || (r.status === 0 && !r.hasNs);
    const sniped = batch.lured && !stillAvail;
    tally[it.bucket] = tally[it.bucket] || { total: 0, available: 0, sniped: 0, names: [] };
    tally[it.bucket].total++;
    if (stillAvail) tally[it.bucket].available++;
    if (sniped) { tally[it.bucket].sniped++; tally[it.bucket].names.push(it.domain); }
    await new Promise((rr) => setTimeout(rr, 200));
  }
  console.log('  bucket  total  avail   sniped  rate  sample');
  let cumT = 0, cumA = 0, cumS = 0;
  for (const [k, v] of Object.entries(tally)) {
    cumT += v.total; cumA += v.available; cumS += v.sniped;
    const rate = batch.lured && v.available ? `${Math.round((100 * v.sniped) / v.available)}%` : 'n/a';
    console.log(`  ${k}       ${String(v.total).padStart(2)}     ${String(v.available).padStart(2)}/${v.total}    ${String(v.sniped).padStart(2)}     ${rate.padStart(4)}  ${v.names.slice(0,3).join(', ')}`);
  }
  console.log(`  total:  ${cumT}     ${cumA}/${cumT} (${Math.round(100*cumA/cumT)}% available)   ${cumS} sniped`);
  console.log(batch.lured ? '\n  (batch was lured — sniped column meaningful)\n' : '\n  (batch never lured — these are baseline availability counts only; sniped column always 0)\n');
}

const cmd = process.argv[2] || 'help';
const cohortArg = process.argv[3];
const fileArg = process.argv[4];
if (cmd === 'generate') {
  if (!cohortArg) { console.error(`usage: node honeypot-v2.js generate <cohort>\ncohorts: ${Object.keys(COHORTS).join(', ')}`); process.exit(2); }
  generate(cohortArg);
} else if (cmd === 'lure') {
  if (process.env.LURE_CONFIRM_LEAK_2026 === 'yes') {
    lure(cohortArg, fileArg).catch((e) => { console.error(e); process.exit(1); });
  } else {
    lureRefusal(cohortArg, fileArg);
  }
} else if (cmd === 'check') {
  check(cohortArg).catch((e) => { console.error(e); process.exit(1); });
} else {
  console.error('usage:');
  console.error('  node honeypot-v2.js generate <pronounceable|brand-adjacent>');
  console.error('  LURE_CONFIRM_LEAK_2026=yes node honeypot-v2.js lure <cohort> <batch.json>   (Steve only)');
  console.error('  node honeypot-v2.js check <batch.json>');
  process.exit(2);
}