← back to Domain Sniper

brand-typo-watcher.js

254 lines

#!/usr/bin/env node
// brand-typo-watcher.js — dedicated CertStream listener focused purely on
// brand defense. Watches for new TLS certs whose registrable label matches
// any of Steve's brand keywords by:
//   1. Substring match (catches "buy-philipperomano-now.com" style)
//   2. Levenshtein distance ≤ 2 (catches typos: "phillipperomano", "venturacorridorr")
//   3. Punycode/IDN flag (catches homoglyph attacks: "philippеromano" w/ Cyrillic е)
//
// Differs from watch-ct.js by being narrower + alerting harder. Every hit
// triggers a macOS desktop notification (NOTIFY=0 to disable). Designed to
// run persistently as a pm2 process — minimal console output, JSONL log to
// data/brand-typo-hits.jsonl.

const WebSocket = require('ws');
const { spawn } = require('child_process');
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/';
// CT_SOURCE=ctlog → direct CT-log polling (default, since certstream is dead)
// CT_SOURCE=certstream → original WebSocket flow (kept as fallback)
const CT_SOURCE = process.env.CT_SOURCE || 'ctlog';
const CT_LOG = process.env.CT_LOG || 'argon';
// CT_LOGS comma-separated → multi-log parallel polling (default: argon+xenon+wyvern+sphinx+elephant+tiger, ~8 cert events/sec)
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, 'brand-typo-hits.jsonl');
const MAX_DISTANCE = parseInt(process.env.MAX_DISTANCE || '2', 10);
const NOTIFY = process.env.NOTIFY !== '0';

// Known-noise domain patterns — suppressed from hit-counter, notification, and JSONL log.
// These are catalogued false positives from real CT firehose runs; add new ones here as they
// emerge to keep the signal-to-noise ratio honest. Audit at: data/brand-typo-hits.jsonl.
//
// CAUTION: only add patterns that produce SUBSTRING or TYPO false positives. Do NOT add
// suffixes that only appear via BARE_PUNYCODE rows — those are intentional homoglyph-
// monitoring audit-trail logging, not false positives. Use `npm run audit:noise` to
// distinguish: it now flags only real brand-match suffixes (≥3 hits) as candidates.
//
// How to extend: when minute summary shows persistent hits= against an obvious non-squat
// (legit service, internal infra, third-party app whose stem collides with a brand by
// Levenshtein-2):
//   1. `npm run audit:noise` — look at the "candidate NOISE_PATTERNS to add" block.
//   2. Grep brand-typo-hits.jsonl by domain suffix; verify matchType is SUBSTRING or TYPO.
//   3. Add a regex below that anchors on the right-most labels (\.legit\.com$).
//   4. Bounce the watcher: kill <PID> && nohup node brand-typo-watcher.js >> logs/brand-typo.log 2>&1 &
//   5. Confirm noise= counter climbs and hits= stays clean.
const NOISE_PATTERNS = [
  /\.haplorrhini\.com$/i,         // Let's Encrypt internal HTTP-01 challenge subdomains
  /\.beutl\.in$/i,                // Beutl video-editor app — Levenshtein-2 collision with "butlr"
  /\.cellt\.net$/i,               // pay.cellt.net (Korean payment service) — Lev-2 collision with "callr"
];
function isKnownNoise(d) { return NOISE_PATTERNS.some((r) => r.test(d)); }

const BRAND_KEYWORDS = (
  process.env.BRAND_KEYWORDS ||
  [
    'designerwallcoverings',
    'philipperomano',
    'venturacorridor',
    'venturaclaw',
    'starsofdesign',
    'bubbesblock',
    'wholivedthere',
    'agentabrams',
    'nationalpaperhangers',
    'novasuede',
    'callr',
    'butlr',
    'flockedwallpaper',
    'grasscloth',
    'glassbeaded',
    'lacountyeats',
    'venturablvd',
    'nosarabeachfrontrentals',
  ].join(',')
)
  .split(',')
  .map((s) => s.trim().toLowerCase())
  .filter(Boolean);

// Levenshtein distance with early-out for performance (firehose is ~50/sec)
function lev(a, b) {
  const m = a.length;
  const n = b.length;
  if (Math.abs(m - n) > MAX_DISTANCE) return MAX_DISTANCE + 1;
  const dp = new Array(n + 1);
  for (let j = 0; j <= n; j++) dp[j] = j;
  for (let i = 1; i <= m; i++) {
    let prev = dp[0];
    dp[0] = i;
    let rowMin = i;
    for (let j = 1; j <= n; j++) {
      const tmp = dp[j];
      if (a[i - 1] === b[j - 1]) dp[j] = prev;
      else dp[j] = Math.min(prev, dp[j], dp[j - 1]) + 1;
      prev = tmp;
      if (dp[j] < rowMin) rowMin = dp[j];
    }
    if (rowMin > MAX_DISTANCE) return MAX_DISTANCE + 1; // early-out
  }
  return dp[n];
}

function stem(domain) {
  if (!domain) return '';
  const d = domain.toLowerCase().replace(/^\*\./, '');
  const parts = d.split('.');
  if (parts.length < 2) return d;
  return parts[parts.length - 2];
}

function isPunycode(domain) {
  return domain.toLowerCase().includes('xn--');
}

function matchBrand(domain) {
  const s = stem(domain);
  if (!s) return null;
  for (const kw of BRAND_KEYWORDS) {
    if (s.includes(kw)) return { keyword: kw, distance: 0, matchType: 'SUBSTRING' };
  }
  let best = null;
  for (const kw of BRAND_KEYWORDS) {
    const d = lev(s, kw);
    if (d <= MAX_DISTANCE && (best === null || d < best.distance)) {
      best = { keyword: kw, distance: d, matchType: 'TYPO' };
    }
  }
  return best;
}

function notify(title, msg) {
  if (!NOTIFY) return;
  const safeTitle = title.replace(/"/g, "'").slice(0, 100);
  const safeMsg = msg.replace(/"/g, "'").slice(0, 200);
  try {
    spawn('osascript', ['-e', `display notification "${safeMsg}" with title "${safeTitle}" sound name "Pop"`]);
  } catch {}
}

let count = { seen: 0, hits: 0, substring: 0, typo: 0, puny: 0, noise: 0 };
const perLog = {}; // log_name -> { fetched, x509, precert, emitted }
function ts() { return new Date().toISOString(); }

function logHit(rec) {
  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.appendFileSync(HITS_LOG, JSON.stringify(rec) + '\n');
}

function handleCertEvent(msg) {
  if (msg.message_type !== 'certificate_update') return;
  const domains = (msg.data && msg.data.leaf_cert && msg.data.leaf_cert.all_domains) || [];
  for (const d of domains) {
    count.seen++;
    if (isKnownNoise(d)) { count.noise++; continue; } // drop without further work
    const m = matchBrand(d);
    const puny = isPunycode(d);
    if (puny) count.puny++;
    if (m) {
      count.hits++;
      if (m.matchType === 'SUBSTRING') count.substring++;
      else count.typo++;
      const rec = { ts: ts(), domain: d, ...m, isPunycode: puny, cert_index: msg.data.cert_index, source: msg.data.log_source || 'certstream' };
      const tag = m.matchType === 'SUBSTRING' ? '\x1b[31m[SUBSTRING]\x1b[0m' : `\x1b[33m[TYPO d=${m.distance}]\x1b[0m`;
      const puntag = puny ? ' \x1b[35m⚠ PUNYCODE\x1b[0m' : '';
      console.log(`${tag} ${d}  ~  ${m.keyword}${puntag}`);
      logHit(rec);
      notify(`domain-sniper hit: ${m.keyword}`, `${d} (distance ${m.distance})${puny ? ' — PUNYCODE/IDN' : ''}`);
    } else if (puny) {
      logHit({ ts: ts(), domain: d, matchType: 'BARE_PUNYCODE', cert_index: msg.data.cert_index });
    }
  }
}

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 ${BRAND_KEYWORDS.length} brands (max-distance ${MAX_DISTANCE}, notify=${NOTIFY})`);
    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()}] closed (${code}). reconnect in ${reconnectDelay}ms`);
    setTimeout(connectCertstream, reconnectDelay);
    reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
  });
  ws.on('error', (e) => console.error(`[${ts()}] error: ${e.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}, watching ${BRAND_KEYWORDS.length} brands (max-distance ${MAX_DISTANCE}, notify=${NOTIFY})`);
  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}`);
      } else if (st.type === 'tick' && st.log) {
        const p = (perLog[st.log] = perLog[st.log] || { fetched: 0, x509: 0, precert: 0, emitted: 0, idle: 0, lastSeenMs: 0 });
        p.fetched = st.stats ? st.stats.fetched : p.fetched;
        p.x509    = st.stats ? st.stats.x509    : p.x509;
        p.precert = st.stats ? st.stats.precert : p.precert;
        p.emitted = st.stats ? st.stats.emitted : p.emitted;
        p.lastSeenMs = Date.now();
      } else if (st.type === 'idle' && st.log) {
        const p = (perLog[st.log] = perLog[st.log] || { fetched: 0, x509: 0, precert: 0, emitted: 0, idle: 0, lastSeenMs: 0 });
        p.idle++;
        p.lastSeenMs = Date.now();
      }
    },
  });
}

function connect() {
  if (CT_SOURCE === 'certstream') return connectCertstream();
  return connectCtLog();
}

setInterval(() => {
  console.error(`[${ts()}] seen=${count.seen} hits=${count.hits} (substring=${count.substring} typo=${count.typo}) puny=${count.puny} noise=${count.noise}`);
  const logs = Object.keys(perLog).sort();
  const now = Date.now();
  if (logs.length) {
    const parts = logs.map((l) => {
      const p = perLog[l];
      const short = l.replace(/2026h1$/, '').slice(0, 8);
      const stale = p.lastSeenMs && (now - p.lastSeenMs > 60_000);
      const tag = stale ? '\x1b[31m!\x1b[0m' : '';
      return `${short}=${p.emitted}(${p.x509}x/${p.precert}p i=${p.idle || 0})${tag}`;
    });
    console.error(`[${ts()}]   per-log: ${parts.join('  ')}`);
  }
}, 60_000);

process.on('SIGINT', () => process.exit(0));
process.on('SIGTERM', () => process.exit(0));
connect();