← back to Domain Sniper

ct-source-certspotter.js

180 lines

// ct-source-certspotter.js
//
// STATUS (2026-05-13): STALLED. Not consumed by any current watcher.
//   - Free anonymous tier returns HTTP 400 (requires domain= per request).
//   - Free per-brand mode lives in ct-source-certspotter-brands.js (also stalled).
//   - All shipped watchers (watch-ct, brand-typo-watcher, dashboard) moved to
//     ct-source-ctlog (direct RFC-6962 CT-log polling, no middleman). See README
//     section "Known issues — public CT-log infrastructure is fragile".
//
// REACTIVATE WHEN: Steve provisions a paid CERTSPOTTER_API_KEY via the secrets
// skill. The adapter is intentionally preserved (not deleted) because its
// emit-payload shape matches what the watchers already consume — so the only
// changes needed on reactivation are: set the env var, swap CT_SOURCE=certspotter
// in the consuming watcher, and remove this STALLED note. No code refactor.
//
// Original purpose: Cert Spotter REST fallback for when certstream.calidog.io
// is dead. Polls https://api.certspotter.com/v1/issuances
// and emits 'certificate_update' events with the same payload shape consumers
// of CertStream already expect:
//   { message_type:'certificate_update',
//     data: { cert_index, leaf_cert: { all_domains: [...] } } }
//
// IMPORTANT: Cert Spotter's free tier REQUIRES domain= per request — there is
// no anonymous firehose. This module supports two modes:
//   1. domain=<keyword>  → per-brand polling. 18 brands × 12-min cycle = 90 req/hr
//                          (within 100/hr free limit). Use for BRAND defense.
//   2. with CERTSPOTTER_API_KEY env → can omit domain (paid-tier firehose).
//
// Free tier: 100 req/hr without API key.
// Cursor is the `id` of the newest issuance seen, persisted to
// data/certspotter-cursor.json so restarts don't re-emit history.
//
// Usage:
//   const src = require('./ct-source-certspotter');
//   const s = src.start({ pollMs: 40_000, onEvent: e => ... });
//   s.stop();
//
// Or as a standalone process:
//   node ct-source-certspotter.js   # logs each batch to stdout

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-cursor.json');
const API_HOST = 'api.certspotter.com';
const API_PATH = '/v1/issuances';
const UA = 'domain-sniper/0.1 (defensive brand monitoring; contact via project README)';

function loadCursor() {
  try { return JSON.parse(fs.readFileSync(CURSOR_FILE, 'utf8')).after || null; }
  catch { return null; }
}
function saveCursor(after) {
  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.writeFileSync(CURSOR_FILE, JSON.stringify({ after, savedAt: new Date().toISOString() }));
}

function fetchBatch(after, apiKey) {
  return new Promise((resolve, reject) => {
    const qs = new URLSearchParams();
    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({ pollMs = 40_000, 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);

  let after = loadCursor();
  let stopped = false;
  let backoff = 0;

  ee.emit('status', { type: 'started', after, pollMs });

  async function tick() {
    if (stopped) return;
    try {
      const r = await fetchBatch(after, apiKey);
      if (r.status === 429 || r.status === 503) {
        const wait = parseInt(r.retryAfter, 10) * 1000 || Math.min(60_000, Math.max(5000, backoff * 2 || 5000));
        backoff = wait;
        ee.emit('status', { type: 'throttled', status: r.status, retryInMs: wait });
        setTimeout(tick, wait);
        return;
      }
      if (r.status !== 200) {
        ee.emit('status', { type: 'http_error', status: r.status, snippet: String(r.body).slice(0, 200) });
        const wait = Math.min(60_000, Math.max(5000, backoff * 2 || 5000));
        backoff = wait;
        setTimeout(tick, wait);
        return;
      }
      backoff = 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,
            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) {
        after = newCursor;
        saveCursor(after);
      }
      ee.emit('status', { type: 'tick', count: items.length, emitted, cursor: after });
    } catch (e) {
      ee.emit('status', { type: 'error', msg: e.message });
    }
    if (!stopped) setTimeout(tick, pollMs);
  }
  tick();

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

module.exports = { start, loadCursor, saveCursor };

// Standalone mode: run directly to smoke-test
if (require.main === module) {
  const pollMs = parseInt(process.env.POLL_MS || '40000', 10);
  console.log(`[${new Date().toISOString()}] ct-source-certspotter starting (poll=${pollMs}ms)`);
  const s = start({
    pollMs,
    onEvent: (e) => {
      const domains = e.data.leaf_cert.all_domains;
      // Print only first 3 per cert to keep output sane
      console.log(`  CERT id=${e.data.cert_index}  ${domains.slice(0, 3).join(', ')}${domains.length > 3 ? ` …+${domains.length - 3}` : ''}`);
    },
    onStatus: (s) => console.log(`  [status] ${JSON.stringify(s)}`),
  });
  process.on('SIGINT', () => { s.stop(); process.exit(0); });
}