← back to Domain Sniper
step 7: brand-typo-watcher.js — Levenshtein + Punycode brand defense
dc2abc5fa105ffa582ca2e9f7cce4c95a64f4dd7 · 2026-05-12 14:58:54 -0700 · Steve Abrams
Dedicated CertStream listener narrower than watch-ct.js, focused purely on
brand defense:
- Substring match (catches 'buy-philipperomano-now.com' style squat)
- Levenshtein distance ≤ 2 (catches 'phillipperomano', 'venturacorridorr',
'designerwalcoverings' typos)
- Punycode/IDN flag (catches Cyrillic-letter homoglyph attacks like
'philippеromano')
- macOS desktop notification per hit (NOTIFY=0 to disable)
- Bare-Punycode side-channel: even non-brand IDN domains get logged for
weekly review since IDN is almost always a homoglyph attack vector
Lev impl has rowMin early-out so the firehose (~50/sec) stays cheap.
Persistent JSONL log to data/brand-typo-hits.jsonl.
npm run watch:brand
Files touched
M README.mdA brand-typo-watcher.jsM package.json
Diff
commit dc2abc5fa105ffa582ca2e9f7cce4c95a64f4dd7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 12 14:58:54 2026 -0700
step 7: brand-typo-watcher.js — Levenshtein + Punycode brand defense
Dedicated CertStream listener narrower than watch-ct.js, focused purely on
brand defense:
- Substring match (catches 'buy-philipperomano-now.com' style squat)
- Levenshtein distance ≤ 2 (catches 'phillipperomano', 'venturacorridorr',
'designerwalcoverings' typos)
- Punycode/IDN flag (catches Cyrillic-letter homoglyph attacks like
'philippеromano')
- macOS desktop notification per hit (NOTIFY=0 to disable)
- Bare-Punycode side-channel: even non-brand IDN domains get logged for
weekly review since IDN is almost always a homoglyph attack vector
Lev impl has rowMin early-out so the firehose (~50/sec) stays cheap.
Persistent JSONL log to data/brand-typo-hits.jsonl.
npm run watch:brand
---
README.md | 2 +-
brand-typo-watcher.js | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 1 +
3 files changed, 175 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 18f0206..b3ab1d7 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ We can't get the WHOIS query stream (it's gated to aggregator partners), but we
- [x] **Step 4** — `honeypot.js` — controlled leak-attribution hunt. Fires 50 DoH-confirmed-available baits across 5 channels (Verisign WHOIS, Google WHOIS .app, nic.co WHOIS, GoDaddy API, control). 24-48h later, WHOIS-fingerprints every snipe and clusters by registrar+IANA+NS to identify the attacker.
- [x] **Step 5** — `register.js` — atomic registrar API call. Backends: NameSilo / Namecheap / GoDaddy. DRY-RUN default; pass `--confirm` to actually buy.
- [ ] **Step 6** — `watch-drops.js` — daily pull of pending-delete CSVs from public auction houses. Surfaces names dropping in the next 5 days. Free sources: pool.com, dropcatch.com, snapnames.com, ICANN CZDS zone-diff.
-- [ ] **Step 7** — `brand-typo-watcher.js` — promote the brand-keyword logic out of `watch-ct.js` into its own job. Alerts immediately on hit.
+- [x] **Step 7** — `brand-typo-watcher.js` — dedicated CertStream listener with Levenshtein distance ≤ 2 + Punycode/IDN homoglyph detection. macOS desktop notification on hit (NOTIFY=0 to disable). Run with `npm run watch:brand`.
- [ ] **Step 8** — `registrar-fingerprints.json` — known aggregator signatures (OVH ns112.ovh.net, etc.) so the honeypot check phase can name-and-shame quickly.
- [x] **Step 9** — added 16 deny rules to `~/.claude/settings.json` covering:
- MCP tools: `mcp__domain-suite__check_availability`, `mcp__domain-suite__get_whois_contact`, `mcp__godaddy__check_availability`, `mcp__namecheap__check_availability`
diff --git a/brand-typo-watcher.js b/brand-typo-watcher.js
new file mode 100644
index 0000000..49853e6
--- /dev/null
+++ b/brand-typo-watcher.js
@@ -0,0 +1,173 @@
+#!/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 CERTSTREAM_URL = process.env.CERTSTREAM_URL || 'wss://certstream.calidog.io/';
+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';
+
+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 };
+function ts() { return new Date().toISOString(); }
+
+function logHit(rec) {
+ fs.mkdirSync(DATA_DIR, { recursive: true });
+ fs.appendFileSync(HITS_LOG, JSON.stringify(rec) + '\n');
+}
+
+let reconnectDelay = 1000;
+function connect() {
+ console.error(`[${ts()}] 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; }
+ 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++;
+ 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 };
+ 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) {
+ // Bare-puny domains without brand match: log to a separate side channel for spot-review.
+ // These are NOT brand hits but they're worth scanning weekly.
+ logHit({ ts: ts(), domain: d, matchType: 'BARE_PUNYCODE', cert_index: msg.data.cert_index });
+ }
+ }
+ });
+
+ ws.on('close', (code) => {
+ console.error(`[${ts()}] closed (${code}). reconnect in ${reconnectDelay}ms`);
+ setTimeout(connect, reconnectDelay);
+ reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
+ });
+
+ ws.on('error', (e) => console.error(`[${ts()}] error: ${e.message}`));
+}
+
+setInterval(() => {
+ console.error(`[${ts()}] seen=${count.seen} hits=${count.hits} (substring=${count.substring} typo=${count.typo}) puny=${count.puny}`);
+}, 60_000);
+
+process.on('SIGINT', () => process.exit(0));
+process.on('SIGTERM', () => process.exit(0));
+connect();
diff --git a/package.json b/package.json
index bba0304..4a68dba 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"check": "node check.js",
"watch:ct": "node watch-ct.js",
"watch:outbound": "node watch-outbound.js",
+ "watch:brand": "node brand-typo-watcher.js",
"dashboard": "node dashboard.js",
"watch:drops": "node watch-drops.js",
"honeypot:lure": "node honeypot.js lure",
← df203a6 step 9: README marks deny rules shipped
·
back to Domain Sniper
·
step 10: dashboard UX — sort tabs, click-to-copy reg cmd, au 109b3c1 →