← back to Domain Sniper

ct-source-certspotter-brands.js

185 lines

// ct-source-certspotter-brands.js
//
// STATUS (2026-05-13): STALLED. Not consumed by any current watcher.
//   - Free tier returns HTTP 403 not_allowed_by_plan when domain= is a bare
//     brand stem (callr, butlr). Only fully-qualified domain names are
//     accepted, which defeats typo-squat defense (we don't know the squat's
//     full FQDN in advance).
//   - All shipped watchers moved to ct-source-ctlog (direct RFC-6962 polling).
//
// REACTIVATE WHEN: Steve provisions a paid CERTSPOTTER_API_KEY via the secrets
// skill. Per-FQDN polling becomes useful in paid mode for known-target domains
// (e.g. monitoring exact brand FQDNs after registration).
//
// Original purpose: per-brand polling wrapper around the
// Cert Spotter free tier. Cycles through Steve's brand keywords, hitting
// /v1/issuances?domain=<kw> for each. Emits the same cert_update events as
// ct-source-certspotter.js so existing watchers (brand-typo-watcher, etc.)
// consume without changes.
//
// Why: Cert Spotter free tier requires domain= per request — no firehose.
// 100 req/hr budget. 18 brands × 1 poll/12min = 90 req/hr → safe.
//
// Cursor is per-brand, persisted to data/certspotter-brand-cursors.json.
//
// Usage as library:
//   const src = require('./ct-source-certspotter-brands');
//   const s = src.start({ keywords: [...], cycleMs: 12*60_000, onEvent });
//
// Standalone:
//   node ct-source-certspotter-brands.js

const https = require('https');
const fs = require('fs');
const path = require('path');
const { EventEmitter } = require('events');

const DATA_DIR = path.join(__dirname, 'data');
const CURSOR_FILE = path.join(DATA_DIR, 'certspotter-brand-cursors.json');
const API_HOST = 'api.certspotter.com';
const API_PATH = '/v1/issuances';
const UA = 'domain-sniper/0.1 (defensive brand monitoring)';

const DEFAULT_BRAND_KEYWORDS = [
  'designerwallcoverings', 'philipperomano', 'venturacorridor', 'venturaclaw',
  'starsofdesign', 'bubbesblock', 'wholivedthere', 'agentabrams',
  'nationalpaperhangers', 'novasuede', 'callr', 'butlr',
  'flockedwallpaper', 'grasscloth', 'glassbeaded',
  'lacountyeats', 'venturablvd', 'nosarabeachfrontrentals',
];

function loadCursors() {
  try { return JSON.parse(fs.readFileSync(CURSOR_FILE, 'utf8')); }
  catch { return {}; }
}
function saveCursors(c) {
  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.writeFileSync(CURSOR_FILE, JSON.stringify(c, null, 2));
}

function fetchBrand(domain, after, apiKey) {
  return new Promise((resolve, reject) => {
    const qs = new URLSearchParams();
    qs.set('domain', domain);
    qs.set('include_subdomains', 'true');
    if (after) qs.set('after', after);
    qs.append('expand', 'dns_names');
    qs.append('expand', 'issuer');
    const headers = { 'User-Agent': UA, 'Accept': 'application/json' };
    if (apiKey) headers['Authorization'] = 'Bearer ' + apiKey;
    const req = https.request({
      host: API_HOST, path: API_PATH + '?' + qs.toString(), method: 'GET', headers, timeout: 15_000,
    }, (res) => {
      let buf = '';
      res.on('data', (c) => buf += c);
      res.on('end', () => {
        if (res.statusCode === 200) {
          try { resolve({ status: 200, body: JSON.parse(buf) }); }
          catch (e) { reject(new Error('parse: ' + e.message)); }
        } else {
          resolve({ status: res.statusCode, body: buf, retryAfter: res.headers['retry-after'] });
        }
      });
    });
    req.on('error', reject);
    req.on('timeout', () => { req.destroy(new Error('timeout')); });
    req.end();
  });
}

function start({
  keywords = DEFAULT_BRAND_KEYWORDS,
  cycleMs = 12 * 60 * 1000, // full cycle: each keyword polled once per 12min
  apiKey = process.env.CERTSPOTTER_API_KEY,
  onEvent,
  onStatus,
} = {}) {
  const ee = new EventEmitter();
  if (onEvent) ee.on('certificate_update', onEvent);
  if (onStatus) ee.on('status', onStatus);

  const cursors = loadCursors();
  const slotMs = Math.max(2000, Math.floor(cycleMs / Math.max(1, keywords.length)));
  let stopped = false;
  let idx = 0;
  let consecutiveErrs = 0;

  ee.emit('status', { type: 'started', keywords: keywords.length, cycleMs, slotMs });

  async function tick() {
    if (stopped) return;
    const kw = keywords[idx % keywords.length];
    idx++;
    const after = cursors[kw] || null;
    try {
      const r = await fetchBrand(kw, after, apiKey);
      if (r.status === 429 || r.status === 503) {
        const waitMs = parseInt(r.retryAfter, 10) * 1000 || Math.min(120_000, 5000 * Math.pow(2, consecutiveErrs));
        consecutiveErrs++;
        ee.emit('status', { type: 'throttled', kw, status: r.status, retryInMs: waitMs });
        setTimeout(tick, waitMs);
        return;
      }
      if (r.status !== 200) {
        consecutiveErrs++;
        ee.emit('status', { type: 'http_error', kw, status: r.status, snippet: String(r.body).slice(0, 160) });
        setTimeout(tick, Math.min(120_000, 5000 * Math.pow(2, Math.min(consecutiveErrs, 4))));
        return;
      }
      consecutiveErrs = 0;
      const items = Array.isArray(r.body) ? r.body : [];
      let newCursor = after;
      let emitted = 0;
      for (const it of items) {
        const domains = (it.dns_names || []).filter(Boolean);
        if (!domains.length) continue;
        ee.emit('certificate_update', {
          message_type: 'certificate_update',
          data: {
            cert_index: it.id,
            brand_match: kw,
            leaf_cert: {
              all_domains: domains,
              issuer: it.issuer && (it.issuer.name || it.issuer.friendly_name || null),
              not_before: it.not_before || null,
              not_after: it.not_after || null,
            },
          },
        });
        emitted++;
        if (!newCursor || it.id > newCursor) newCursor = it.id;
      }
      if (newCursor && newCursor !== after) {
        cursors[kw] = newCursor;
        saveCursors(cursors);
      }
      ee.emit('status', { type: 'tick', kw, count: items.length, emitted, cursor: cursors[kw] || null });
    } catch (e) {
      consecutiveErrs++;
      ee.emit('status', { type: 'error', kw, msg: e.message });
    }
    if (!stopped) setTimeout(tick, slotMs);
  }
  tick();

  return {
    on: (...a) => ee.on(...a),
    stop: () => { stopped = true; ee.emit('status', { type: 'stopped' }); },
    cursors: () => ({ ...cursors }),
  };
}

module.exports = { start, DEFAULT_BRAND_KEYWORDS };

if (require.main === module) {
  console.log(`[${new Date().toISOString()}] ct-source-certspotter-brands starting`);
  const s = start({
    onEvent: (e) => {
      const d = e.data.leaf_cert.all_domains;
      console.log(`  [${e.data.brand_match}] ${d.slice(0, 3).join(', ')}${d.length > 3 ? ` …+${d.length - 3}` : ''}`);
    },
    onStatus: (st) => console.log(`  [status] ${JSON.stringify(st)}`),
  });
  process.on('SIGINT', () => { s.stop(); process.exit(0); });
}