← back to Domain Sniper
watch-outbound.js
196 lines
#!/usr/bin/env node
// watch-outbound.js — local HTTPS forward proxy that surfaces every domain-intel
// request leaving THIS machine. Same MITM technology aggregators use to monitor
// incoming WHOIS queries — but inverted so YOU see what YOUR tools send.
//
// Run it:
// node watch-outbound.js
//
// Then point your tools at it:
// export HTTPS_PROXY=http://127.0.0.1:8088
// export HTTP_PROXY=http://127.0.0.1:8088
// export NO_PROXY=localhost,127.0.0.1
//
// ...and re-run whatever you suspect is leaking (Claude with the domain-suite MCP,
// a node script, a `curl` against an availability API, etc.). Hits print to stdout
// and append to data/outbound.jsonl with full timestamps for forensics.
//
// What this catches:
// - Every HTTPS request from any tool that respects HTTPS_PROXY (most Node/Python clients do)
// - SNI hostname is visible even without TLS decryption — enough to ID the leaking tool
//
// What this does NOT catch:
// - Raw `whois` command (port 43). For that, run in another terminal:
// sudo tcpdump -i any -nn -A 'port 43' -l
// - Tools that ignore HTTPS_PROXY (some Go binaries, browsers w/ own proxy settings)
// - DNS queries to OS resolver (use `sudo tcpdump -i any -nn 'udp port 53' -l`)
const http = require('http');
const net = require('net');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');
const PORT = parseInt(process.env.WATCH_PORT || '8088', 10);
const DATA_DIR = path.join(__dirname, 'data');
const LOG_FILE = path.join(DATA_DIR, 'outbound.jsonl');
// Hostname substrings that scream "domain-intel API call". Anything matching → log loud (red).
// Anything else still logs but in dim grey so you can spot unexpected callers.
const DOMAIN_INTEL_PATTERNS = [
'whois',
'rdap',
'godaddy',
'namecheap',
'porkbun',
'cloudflare.com/client/v4/registrar',
'whoisxmlapi',
'whoisxml',
'domaintools',
'nic.google',
'verisign',
'iana.org',
'icann.org',
'pir.org',
'afilias',
'identitydigital',
'donuts.co',
'gandi.net',
'opensrs',
'enom',
'tucows',
'name.com',
'dynadot',
'hover.com',
'domains.google',
'registry.google',
'registrar-servers.com',
'epik.com',
'dropcatch',
'snapnames',
'pool.com',
'expireddomains',
'newdomains',
// RIRs — IP/ASN lookups that often correlate with domain-intel research
'arin.net',
'ripe.net',
'apnic.net',
'lacnic.net',
'afrinic.net',
// ICANN zone data + lookup services (CZDS, etc.) — same family
'czds.icann.org',
'lookup.icann.org',
// Additional 2026-era aggregators / new-TLD registries
'centralnic',
'donuts.email',
'humbleworth',
'sav.com',
'spaceship',
// Cert Spotter API (we use it ourselves but track outbound for budget audit)
'api.certspotter.com',
];
function classify(host) {
const h = (host || '').toLowerCase();
const matched = DOMAIN_INTEL_PATTERNS.find((p) => h.includes(p));
return matched ? { level: 'INTEL', match: matched } : { level: 'OTHER', match: null };
}
function ts() {
return new Date().toISOString();
}
function logHit(rec) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + '\n');
}
function colorPrint(level, host, extra = '') {
const t = new Date().toISOString().replace('T', ' ').replace(/\..*/, '');
if (level === 'INTEL') {
process.stdout.write(`\x1b[31m[INTEL]\x1b[0m ${t} \x1b[1m${host}\x1b[0m ${extra}\n`);
} else {
process.stdout.write(`\x1b[2m[other] ${t} ${host} ${extra}\x1b[0m\n`);
}
}
// ---------- HTTPS CONNECT tunnel (the common case for API calls) ----------
function handleConnect(req, clientSocket, head) {
const [hostRaw, portRaw] = req.url.split(':');
const host = hostRaw;
const port = parseInt(portRaw || '443', 10);
const c = classify(host);
const rec = { ts: ts(), type: 'CONNECT', host, port, via: 'HTTPS_PROXY', ...c };
logHit(rec);
colorPrint(c.level, `${host}:${port}`, 'CONNECT');
const serverSocket = net.connect(port, host, () => {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
serverSocket.write(head);
serverSocket.pipe(clientSocket);
clientSocket.pipe(serverSocket);
});
serverSocket.on('error', (err) => {
clientSocket.end();
if (c.level === 'INTEL') console.error(`[INTEL] upstream error ${host}:${port} ${err.message}`);
});
clientSocket.on('error', () => serverSocket.end());
}
// ---------- Plain HTTP forward (rare for APIs but happens) ----------
function handleRequest(req, res) {
const target = req.headers.host;
if (!target) {
res.writeHead(400);
return res.end('proxy: missing Host');
}
const c = classify(target);
const rec = { ts: ts(), type: 'HTTP', host: target, method: req.method, url: req.url, ...c };
logHit(rec);
colorPrint(c.level, target, `${req.method} ${req.url}`);
let u;
try {
u = new URL(req.url.startsWith('http') ? req.url : `http://${target}${req.url}`);
} catch (e) {
res.writeHead(400);
return res.end('proxy: bad URL');
}
const fwd = http.request(
{
host: u.hostname,
port: u.port || 80,
method: req.method,
path: u.pathname + u.search,
headers: req.headers,
},
(fwdRes) => {
res.writeHead(fwdRes.statusCode, fwdRes.headers);
fwdRes.pipe(res);
},
);
fwd.on('error', (err) => {
res.writeHead(502);
res.end(`proxy upstream error: ${err.message}`);
});
req.pipe(fwd);
}
const server = http.createServer(handleRequest);
server.on('connect', handleConnect);
server.listen(PORT, '127.0.0.1', () => {
console.log(`\n domain-sniper :: outbound watcher`);
console.log(` listening on http://127.0.0.1:${PORT}`);
console.log(` logging to ${LOG_FILE}\n`);
console.log(` In another shell, point your tools at this proxy:`);
console.log(` export HTTPS_PROXY=http://127.0.0.1:${PORT}`);
console.log(` export HTTP_PROXY=http://127.0.0.1:${PORT}`);
console.log(` export NO_PROXY=localhost,127.0.0.1\n`);
console.log(` Then re-run the tool you suspect is leaking. Watch [INTEL] lines below.\n`);
console.log(` For raw 'whois' (port 43) and DNS leaks, run in a separate terminal:`);
console.log(` sudo tcpdump -i any -nn 'port 43 or (udp port 53)' -l\n`);
});
process.on('SIGINT', () => { console.log('\n bye.'); process.exit(0); });