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