← back to Domain Sniper
watch-ct.js
165 lines
#!/usr/bin/env node
// Live Certificate Transparency watcher.
// Default source: direct CT-log polling via ct-source-ctlog (6-log fan-out).
// Fallback: CERTSTREAM_URL via WebSocket (CT_SOURCE=certstream).
// Use cases:
// 1. INTEL — see what kinds of names other people are registering right now
// 2. BRAND DEFENSE — alert if someone registers a typo of one of Steve's brands
// 3. TIMING — measure latency between WHOIS-check and cert-issue
//
// Reminder: certs = ALREADY REGISTERED domains. This is not a "drops" feed.
// For actual snipe targets (pending-delete), see watch-drops.js.
const WebSocket = require('ws');
const fs = require('fs');
const path = require('path');
const ctLogSource = require('./ct-source-ctlog');
const CERTSTREAM_URL = process.env.CERTSTREAM_URL || 'wss://certstream.calidog.io/';
const CT_SOURCE = process.env.CT_SOURCE || 'ctlog';
const CT_LOG = process.env.CT_LOG || 'argon';
const CT_LOGS = (process.env.CT_LOGS || 'argon,xenon,wyvern,sphinx,elephant,tiger').split(',').map((s) => s.trim()).filter(Boolean);
const CTLOG_POLL_MS = parseInt(process.env.CTLOG_POLL_MS || '5000', 10);
const CTLOG_BATCH = parseInt(process.env.CTLOG_BATCH || '32', 10);
const DATA_DIR = path.join(__dirname, 'data');
const HITS_LOG = path.join(DATA_DIR, 'ct-hits.jsonl');
const STATS_INTERVAL_MS = 30_000;
const BRAND_KEYWORDS = (process.env.BRAND_KEYWORDS || [
'designerwallcoverings',
'philipperomano',
'venturacorridor',
'venturaclaw',
'starsofdesign',
'bubbesblock',
'wholivedthere',
'agentabrams',
'nationalpaperhangers',
'lawyer-directory',
'novasuede',
'callr',
'butlr',
'flockedwallpaper',
'grasscloth',
'glassbeaded',
].join(',')).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const HOT_TLDS = new Set(['com', 'app', 'io', 'co', 'ai', 'dev']);
const MAX_STEM_LEN = 8;
function looksHot(domain) {
if (!domain || domain.startsWith('*.')) return false;
const parts = domain.split('.');
if (parts.length < 2) return false;
const tld = parts[parts.length - 1].toLowerCase();
if (!HOT_TLDS.has(tld)) return false;
const stem = parts[parts.length - 2].toLowerCase();
if (stem.length > MAX_STEM_LEN || stem.length < 3) return false;
if (/[-0-9]/.test(stem)) return false;
if (!/[aeiou]/.test(stem)) return false;
if (!/[bcdfghjklmnpqrstvwxyz]/.test(stem)) return false;
return true;
}
function brandMatch(domain) {
const d = domain.toLowerCase().replace(/^\*\./, '');
return BRAND_KEYWORDS.find((kw) => d.includes(kw));
}
let count = { total: 0, hot: 0, brand: 0 };
let buffer = [];
function appendHit(rec) {
buffer.push(JSON.stringify(rec));
if (buffer.length >= 25) flushHits();
}
function flushHits() {
if (!buffer.length) return;
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.appendFileSync(HITS_LOG, buffer.join('\n') + '\n');
buffer = [];
}
function ts() {
return new Date().toISOString();
}
function handleCertEvent(msg) {
if (msg.message_type !== 'certificate_update') return;
const domains = (msg.data && msg.data.leaf_cert && msg.data.leaf_cert.all_domains) || [];
const seenAt = ts();
for (const d of domains) {
count.total++;
const brand = brandMatch(d);
const hot = looksHot(d);
if (brand) {
count.brand++;
const line = { ts: seenAt, type: 'BRAND', domain: d, keyword: brand, cert_index: msg.data.cert_index, source: msg.data.log_source || 'certstream' };
console.log(`\x1b[31m[BRAND]\x1b[0m ${d} (matches: ${brand})`);
appendHit(line);
} else if (hot) {
count.hot++;
const line = { ts: seenAt, type: 'HOT', domain: d, cert_index: msg.data.cert_index, source: msg.data.log_source || 'certstream' };
console.log(`\x1b[33m[HOT]\x1b[0m ${d}`);
appendHit(line);
}
}
}
let reconnectDelay = 1000;
function connectCertstream() {
console.error(`[${ts()}] CT_SOURCE=certstream — connecting to ${CERTSTREAM_URL}`);
const ws = new WebSocket(CERTSTREAM_URL, { handshakeTimeout: 15_000 });
ws.on('open', () => {
console.error(`[${ts()}] connected — watching for hot + brand hits`);
reconnectDelay = 1000;
});
ws.on('message', (raw) => {
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
handleCertEvent(msg);
});
ws.on('close', (code) => {
console.error(`[${ts()}] connection closed (code ${code}). reconnecting in ${reconnectDelay}ms`);
setTimeout(connectCertstream, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
});
ws.on('error', (err) => console.error(`[${ts()}] ws error: ${err.message}`));
}
function connectCtLog() {
const opts = CT_LOGS.length > 1
? { logKeys: CT_LOGS, pollMs: CTLOG_POLL_MS, batchSize: CTLOG_BATCH }
: { logKey: CT_LOG, pollMs: CTLOG_POLL_MS, batchSize: CTLOG_BATCH };
console.error(`[${ts()}] CT_SOURCE=ctlog ${CT_LOGS.length > 1 ? 'multi=[' + CT_LOGS.join(',') + ']' : 'log=' + CT_LOG} poll=${CTLOG_POLL_MS}ms batch=${CTLOG_BATCH}`);
ctLogSource.start({
...opts,
onEvent: handleCertEvent,
onStatus: (st) => {
if (st.type === 'sth_err' || st.type === 'fetch_err' || st.type === 'error') {
console.error(`[${ts()}] ctlog ${st.type} on ${st.log || '?'}: ${st.msg || JSON.stringify(st)}`);
} else if (st.type === 'seeded') {
console.error(`[${ts()}] seeded ${st.log} at tree_size=${st.position}`);
} else if (st.type === 'resumed') {
console.error(`[${ts()}] resumed ${st.log} at cursor=${st.position}`);
}
},
});
}
function connect() {
if (CT_SOURCE === 'certstream') return connectCertstream();
return connectCtLog();
}
setInterval(() => {
console.error(`[${ts()}] seen=${count.total} hot=${count.hot} brand=${count.brand}`);
flushHits();
}, STATS_INTERVAL_MS);
process.on('SIGINT', () => { flushHits(); process.exit(0); });
process.on('SIGTERM', () => { flushHits(); process.exit(0); });
connect();