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