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