domainlooker
Version:
A fast CLI and MCP server for inspecting domains: WHOIS/RDAP, DNS, SSL, ports, subdomains, with CSV/JSON export.
289 lines • 12.9 kB
JavaScript
import chalk from 'chalk';
import Table from 'cli-table3';
import * as fs from 'fs';
import * as path from 'path';
import { CSVExportService } from './services/csv-export.js';
import { JsonExportService } from './services/json-export.js';
import { DomainCollector, hasWhoisData, hasDnsData, collectAdvisories } from './core/collector.js';
import { renderPropagationMap } from './services/propagation-map.js';
import { createSpinner, heading, domainHeading, printError } from './ui/effects.js';
export class DomainInspector {
constructor(options = {}) {
this.options = options;
this.collector = new DomainCollector();
if (this.options.exportCsv) {
this.csvExporter = new CSVExportService();
}
if (this.options.exportJson) {
this.jsonExporter = new JsonExportService(this.options);
}
}
collectOptions() {
return {
quick: this.options.quick,
subdomains: this.options.subdomains,
onError: (_service, error) => this.logVerbose(error),
};
}
// --- Full multi-aspect report ----------------------------------------------
async inspect(domain) {
const info = await this.withSpinner(`Inspecting ${domain}`, () => this.gather(domain));
this.renderReport(info);
if (this.csvExporter && this.options.exportCsv) {
this.csvExporter.addDomain(info);
await this.csvExporter.exportToFile(this.options.exportCsv);
}
if (this.jsonExporter && this.options.exportJson) {
this.jsonExporter.addDomain(info);
await this.jsonExporter.exportToFile(this.options.exportJson);
}
}
async inspectMany(domains, parallelLimit = 3) {
console.log(chalk.bold(`\nInspecting ${domains.length} domains (${parallelLimit} in parallel)`));
for (let i = 0; i < domains.length; i += parallelLimit) {
const batch = domains.slice(i, i + parallelLimit);
const settled = await this.withSpinner(`Inspecting ${batch.join(', ')}`, () => Promise.allSettled(batch.map(domain => this.gather(domain))));
for (let j = 0; j < settled.length; j++) {
const outcome = settled[j];
if (outcome.status === 'rejected') {
// gather() is written never to reject; guard defensively so one bad
// domain can never drop the rest of the batch.
printError(`Failed to inspect ${batch[j]}: ${outcome.reason}`);
continue;
}
const info = outcome.value;
this.renderReport(info);
this.csvExporter?.addDomain(info);
this.jsonExporter?.addDomain(info);
}
}
if (this.csvExporter && this.options.exportCsv) {
await this.csvExporter.exportToFile(this.options.exportCsv);
}
if (this.jsonExporter && this.options.exportJson) {
await this.jsonExporter.exportToFile(this.options.exportJson);
}
}
renderReport(info) {
domainHeading(info.domain);
if (hasWhoisData(info.whois))
this.renderWhois(info.whois);
if (hasDnsData(info.dns))
this.renderDns(info.dns);
if (info.ssl)
this.renderSsl(info.ssl);
if (info.network?.openPorts?.length)
this.renderPorts(info.network);
if (info.subdomains && info.subdomains.totalFound > 0)
this.renderSubdomains(info.subdomains);
this.renderAdvisories(info);
}
// --- Single-aspect commands ------------------------------------------------
//
// Each runs one lookup under a single spinner (only one `ora` is ever live)
// and returns true when data was found and rendered, false otherwise.
async whoisReport(domain) {
const data = await this.withSpinner(`WHOIS ${domain}`, () => this.collector.whois(domain, this.collectOptions()));
if (hasWhoisData(data)) {
this.renderWhois(data);
return true;
}
console.log('No WHOIS data available.');
return false;
}
async dnsReport(domain) {
const data = await this.withSpinner(`DNS ${domain}`, () => this.collector.dns(domain, this.collectOptions()));
if (hasDnsData(data)) {
this.renderDns(data);
return true;
}
console.log('No DNS records found.');
return false;
}
async sslReport(domain) {
const data = await this.withSpinner(`SSL ${domain}`, () => this.collector.ssl(domain, this.collectOptions()));
if (data) {
this.renderSsl(data);
return true;
}
console.log('No SSL certificate found.');
return false;
}
async portsReport(domain) {
const data = await this.withSpinner(`Ports ${domain}`, () => this.collector.ports(domain, this.collectOptions()));
if (data?.openPorts?.length) {
this.renderPorts(data);
return true;
}
console.log('No open ports detected.');
return false;
}
async subdomainReport(domain) {
const data = await this.withSpinner(`Subdomains ${domain}`, () => this.collector.subdomains(domain, this.collectOptions()));
if (data && data.totalFound > 0) {
this.renderSubdomains(data);
return true;
}
console.log('No subdomains found.');
return false;
}
async propagationReport(domain, recordType, mapFile) {
const result = await this.withSpinner(`Propagation ${domain} ${recordType}`, () => this.collector.propagation(domain, recordType, this.collectOptions()));
if (!result) {
console.log('Propagation check failed.');
return false;
}
this.renderPropagation(result);
if (mapFile) {
const out = mapFile.endsWith('.html') ? mapFile : `${mapFile}.html`;
const fullPath = path.resolve(out);
await fs.promises.writeFile(fullPath, renderPropagationMap(result), 'utf8');
console.log(chalk.dim(`\nWorld map written to ${fullPath} — open it in a browser.`));
}
// Exit non-zero when the domain did not resolve on any resolver.
return result.expected !== null;
}
// --- Data gathering --------------------------------------------------------
gather(domain) {
return this.collector.collect(domain, this.collectOptions());
}
/** Run `work` under a single spinner so concurrent lookups never fight for the TTY line. */
async withSpinner(label, work) {
const spinner = createSpinner(label).start();
try {
const result = await work();
spinner.succeed(label);
return result;
}
catch (error) {
spinner.fail(label);
throw error;
}
}
logVerbose(error) {
if (this.options.verbose) {
printError(String(error));
}
}
// --- Section renderers -----------------------------------------------------
renderWhois(whois) {
heading('Registration (WHOIS)');
const table = new Table({ colWidths: [20, 50], wordWrap: true });
if (whois.registrar)
table.push(['Registrar', whois.registrar]);
if (whois.registrationDate)
table.push(['Registered', whois.registrationDate]);
if (whois.expirationDate)
table.push(['Expires', whois.expirationDate]);
if (whois.registrantCountry)
table.push(['Country', whois.registrantCountry]);
if (whois.nameServers?.length)
table.push(['Name servers', whois.nameServers.join('\n')]);
if (whois.status?.length)
table.push(['Status', whois.status.join('\n')]);
console.log(table.toString());
}
renderDns(dns) {
heading('DNS records');
const table = new Table({ colWidths: [15, 55], wordWrap: true });
if (dns.a?.length)
table.push(['A', dns.a.join('\n')]);
if (dns.aaaa?.length)
table.push(['AAAA', dns.aaaa.join('\n')]);
// A "null MX" (RFC 7505) has an empty exchange — skip it rather than print a bare priority.
const mx = dns.mx?.filter(m => m.exchange && m.exchange !== '.') ?? [];
if (mx.length)
table.push(['MX', mx.map(m => `${m.priority} ${m.exchange}`).join('\n')]);
if (dns.ns?.length)
table.push(['NS', dns.ns.join('\n')]);
if (dns.txt?.length)
table.push(['TXT', dns.txt.slice(0, 5).join('\n')]);
if (dns.soa)
table.push(['SOA', `${dns.soa.primary} (serial ${dns.soa.serial})`]);
console.log(table.toString());
}
renderSsl(ssl) {
heading('SSL certificate');
const table = new Table({ colWidths: [20, 50], wordWrap: true });
if (ssl.subject)
table.push(['Subject', ssl.subject]);
if (ssl.issuer)
table.push(['Issuer', ssl.issuer]);
if (ssl.validFrom)
table.push(['Valid from', ssl.validFrom]);
if (ssl.validTo)
table.push(['Valid to', ssl.validTo]);
if (ssl.daysUntilExpiry !== undefined) {
const days = ssl.daysUntilExpiry;
const color = days < 30 ? chalk.red : days < 90 ? chalk.yellow : chalk.green;
table.push(['Days until expiry', color(`${days}`)]);
}
if (ssl.signatureAlgorithm)
table.push(['Signature', ssl.signatureAlgorithm]);
if (ssl.fingerprint)
table.push(['Fingerprint', ssl.fingerprint]);
console.log(table.toString());
}
renderPorts(network) {
if (!network.openPorts?.length)
return;
heading('Open ports');
const table = new Table({ head: ['Port', 'Protocol', 'Service'], colWidths: [10, 12, 18] });
network.services?.forEach(service => {
table.push([service.port.toString(), service.protocol, service.service]);
});
console.log(table.toString());
}
renderSubdomains(sub) {
heading(`Subdomains (${sub.totalFound} found)`);
const summary = new Table({ colWidths: [28, 12] });
summary.push(['Certificate transparency', sub.sources.certificateTransparency.length.toString()], ['Common names', sub.sources.commonNames.length.toString()]);
console.log(summary.toString());
if (sub.subdomains.length === 0)
return;
const limit = Math.min(20, sub.subdomains.length);
const list = new Table({ head: ['Subdomain', 'Source'], colWidths: [45, 24], wordWrap: true });
for (let i = 0; i < limit; i++) {
const name = sub.subdomains[i];
const found = [];
if (sub.sources.certificateTransparency.includes(name))
found.push('Cert transparency');
if (sub.sources.commonNames.includes(name))
found.push('Common name');
list.push([name, found.join(', ') || 'Unknown']);
}
console.log(list.toString());
if (sub.subdomains.length > limit) {
console.log(chalk.dim(`... and ${sub.subdomains.length - limit} more`));
}
}
renderPropagation(result) {
heading(`DNS propagation — ${result.domain} (${result.recordType})`);
if (result.expected) {
console.log('Expected (majority): ' + chalk.cyan(result.expected.join(', ')));
}
const table = new Table({ head: ['Resolver', 'Location', 'Answer', 'Status'], colWidths: [16, 20, 30, 13], wordWrap: true });
for (const r of result.results) {
const answer = r.answer ? r.answer.join(', ') : chalk.dim('(none)');
const status = r.status === 'in-sync' ? chalk.green('✓ in sync') :
r.status === 'differs' ? chalk.yellow('✗ differs') :
chalk.dim('– no answer');
table.push([r.resolver.provider, `${r.resolver.location}, ${r.resolver.country}`, answer, status]);
}
console.log(table.toString());
const color = result.percent === 100 ? chalk.green : result.percent >= 60 ? chalk.yellow : chalk.red;
console.log('\n' + color(`${result.percent}% propagated`) + chalk.dim(` (${result.inSync}/${result.total} resolvers in sync)`));
}
renderAdvisories(info) {
heading('Advisories');
const advisories = collectAdvisories(info);
if (advisories.length === 0) {
console.log(chalk.green('No issues found.'));
return;
}
for (const advisory of advisories) {
console.log(chalk.yellow(`- ${advisory}`));
}
}
}
//# sourceMappingURL=domain-inspector.js.map