[object Object]

← 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

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 →