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