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