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