= {
+ twitter: /https?:\/\/(www\.)?(twitter|x)\.com\/[a-zA-Z0-9_]+/g,
+ linkedin: /https?:\/\/(www\.)?linkedin\.com\/[a-zA-Z0-9_/.\-]+/g,
+ github: /https?:\/\/(www\.)?github\.com\/[a-zA-Z0-9_\-]+/g,
+ instagram: /https?:\/\/(www\.)?instagram\.com\/[a-zA-Z0-9_.]+/g,
+ youtube: /https?:\/\/(www\.)?youtube\.com\/[a-zA-Z0-9_@/.\-]+/g,
+ facebook: /https?:\/\/(www\.)?facebook\.com\/[a-zA-Z0-9_.]+/g,
+ discord: /https?:\/\/(www\.)?discord\.(gg|com)\/[a-zA-Z0-9]+/g,
+ tiktok: /https?:\/\/(www\.)?tiktok\.com\/@[a-zA-Z0-9_.]+/g,
+};
+
+// ============================================
+// Recon Pipeline
+// ============================================
+
+/**
+ * ReconPipeline — Orchestrates passive domain reconnaissance
+ */
+export class ReconPipeline extends EventEmitter {
+ private httpClient: AxiosInstance;
+
+ constructor() {
+ super();
+ this.httpClient = axios.create({
+ timeout: 15000,
+ maxRedirects: 5,
+ validateStatus: () => true,
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ },
+ });
+ }
+
+ /**
+ * Run full reconnaissance on a domain
+ */
+ async recon(domain: string): Promise
{
+ const startTime = Date.now();
+ const cleanDomain = this.cleanDomain(domain);
+ log.info(`[Recon] Starting reconnaissance on: ${cleanDomain}`);
+
+ // Run independent modules in parallel for speed
+ this.emit('module:start', 'DNS Lookup');
+ this.emit('module:start', 'WHOIS Lookup');
+ this.emit('module:start', 'HTTP Headers');
+ this.emit('module:start', 'SSL/TLS Check');
+
+ const [dnsResult, whoisResult, headersResult, sslResult] = await Promise.allSettled([
+ this.dnsLookup(cleanDomain),
+ this.whoisLookup(cleanDomain),
+ this.analyzeHeaders(cleanDomain),
+ this.checkSSL(cleanDomain),
+ ]);
+
+ const dnsInfo = dnsResult.status === 'fulfilled' ? dnsResult.value : this.emptyDNS();
+ const whoisInfo = whoisResult.status === 'fulfilled' ? whoisResult.value : this.emptyWhois();
+ const headersInfo = headersResult.status === 'fulfilled' ? headersResult.value : this.emptyHeaders();
+ const sslInfo = sslResult.status === 'fulfilled' ? sslResult.value : this.emptySSL();
+
+ this.emit('module:complete', 'DNS Lookup');
+ this.emit('module:complete', 'WHOIS Lookup');
+ this.emit('module:complete', 'HTTP Headers');
+ this.emit('module:complete', 'SSL/TLS Check');
+
+ // Browser-dependent modules (sequential to share browser instance)
+ this.emit('module:start', 'Tech Stack Detection');
+ const techStack = await this.detectTechStack(cleanDomain, headersInfo);
+ this.emit('module:complete', 'Tech Stack Detection');
+
+ this.emit('module:start', 'Content Extraction');
+ const [emailsResult, socialResult, linksResult, screenshotResult] = await Promise.allSettled([
+ this.extractEmails(cleanDomain),
+ this.findSocialLinks(cleanDomain),
+ this.extractLinkInfo(cleanDomain),
+ this.takeScreenshot(cleanDomain),
+ ]);
+ this.emit('module:complete', 'Content Extraction');
+
+ const emails = emailsResult.status === 'fulfilled' ? emailsResult.value : [];
+ const socialLinks = socialResult.status === 'fulfilled' ? socialResult.value : {};
+ const links = linksResult.status === 'fulfilled' ? linksResult.value : { internal: 0, external: 0, totalLinks: 0, externalDomains: [] };
+ const screenshot = screenshotResult.status === 'fulfilled' ? screenshotResult.value : '';
+
+ const partialResult: Omit = {
+ domain: cleanDomain,
+ timestamp: new Date(),
+ dns: dnsInfo,
+ whois: whoisInfo,
+ headers: headersInfo,
+ ssl: sslInfo,
+ techStack,
+ links,
+ emails,
+ socialLinks,
+ screenshot,
+ };
+
+ const securityScore = this.calculateSecurityScore(partialResult);
+
+ const result: ReconResult = { ...partialResult, securityScore };
+
+ const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
+ log.info(`[Recon] Completed in ${totalTime}s — Score: ${securityScore}/100`);
+ this.emit('recon:complete', result);
+
+ return result;
+ }
+
+ // ============================================
+ // DNS Module
+ // ============================================
+
+ async dnsLookup(domain: string): Promise {
+ const resolver = new dns.promises.Resolver();
+ resolver.setServers(['8.8.8.8', '1.1.1.1']);
+
+ const [aResult, aaaaResult, mxResult, txtResult, nsResult, cnameResult, soaResult] = await Promise.allSettled([
+ resolver.resolve4(domain),
+ resolver.resolve6(domain),
+ resolver.resolveMx(domain),
+ resolver.resolveTxt(domain),
+ resolver.resolveNs(domain),
+ resolver.resolveCname(domain),
+ resolver.resolveSoa(domain),
+ ]);
+
+ return {
+ a: aResult.status === 'fulfilled' ? aResult.value : [],
+ aaaa: aaaaResult.status === 'fulfilled' ? aaaaResult.value : [],
+ mx: mxResult.status === 'fulfilled' ? (mxResult.value as any[]).map(r => ({ exchange: r.exchange, priority: r.priority })) : [],
+ txt: txtResult.status === 'fulfilled' ? txtResult.value.map(t => t.join('')) : [],
+ ns: nsResult.status === 'fulfilled' ? nsResult.value : [],
+ cname: cnameResult.status === 'fulfilled' ? cnameResult.value : [],
+ soa: soaResult.status === 'fulfilled' ? soaResult.value as unknown as SOARecord : null,
+ };
+ }
+
+ // ============================================
+ // WHOIS Module
+ // ============================================
+
+ async whoisLookup(domain: string): Promise {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const whoisLookup = require('whois-json') as (domain: string) => Promise;
+ const data = await whoisLookup(domain);
+
+ // whois-json can return an array or object depending on the TLD
+ const record = Array.isArray(data) ? data[0] : data;
+
+ return {
+ registrar: record?.registrar || record?.registrarName || 'Unknown',
+ createdDate: record?.creationDate || record?.createdDate || record?.created || 'Unknown',
+ expiryDate: record?.registrarRegistrationExpirationDate || record?.expirationDate || record?.expires || 'Unknown',
+ nameServers: this.normalizeArray(record?.nameServer || record?.nameServers || []),
+ status: this.normalizeArray(record?.domainStatus || record?.status || []),
+ dnssec: record?.dnssec || 'Unknown',
+ };
+ } catch (error: any) {
+ log.warn(`[Recon] WHOIS lookup failed: ${error.message}`);
+ return this.emptyWhois();
+ }
+ }
+
+ // ============================================
+ // HTTP Headers Module
+ // ============================================
+
+ async analyzeHeaders(domain: string): Promise {
+ const startTime = Date.now();
+ const redirectChain: string[] = [];
+
+ try {
+ const response = await this.httpClient.get(`https://${domain}`, {
+ maxRedirects: 10,
+ beforeRedirect: (options: any) => {
+ redirectChain.push(options.href || '');
+ },
+ });
+
+ const headers = response.headers;
+ const responseTime = Date.now() - startTime;
+
+ return {
+ server: (headers['server'] as string) || 'Not disclosed',
+ poweredBy: (headers['x-powered-by'] as string) || 'Not disclosed',
+ contentType: (headers['content-type'] as string) || 'Unknown',
+ security: {
+ hsts: headers['strict-transport-security'] || false,
+ csp: headers['content-security-policy'] || false,
+ xFrameOptions: (headers['x-frame-options'] as string) || 'MISSING',
+ xContentType: (headers['x-content-type-options'] as string) || 'MISSING',
+ xXssProtection: (headers['x-xss-protection'] as string) || 'MISSING',
+ referrerPolicy: (headers['referrer-policy'] as string) || 'MISSING',
+ permissionsPolicy: (headers['permissions-policy'] as string) || 'MISSING',
+ },
+ statusCode: response.status,
+ responseTime,
+ redirectChain,
+ };
+ } catch (error: any) {
+ log.warn(`[Recon] Header analysis failed: ${error.message}`);
+ return this.emptyHeaders();
+ }
+ }
+
+ // ============================================
+ // SSL/TLS Module
+ // ============================================
+
+ async checkSSL(domain: string): Promise {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const sslCheck = require('ssl-checker') as (domain: string) => Promise;
+ const result = await sslCheck(domain);
+
+ return {
+ valid: result.valid,
+ issuer: result.issuer || 'Unknown',
+ validFrom: result.validFrom || 'Unknown',
+ validTo: result.validTo || 'Unknown',
+ daysRemaining: result.daysRemaining || 0,
+ protocol: result.protocol || 'Unknown',
+ };
+ } catch (error: any) {
+ log.warn(`[Recon] SSL check failed: ${error.message}`);
+ return this.emptySSL();
+ }
+ }
+
+ // ============================================
+ // Tech Stack Detection Module
+ // ============================================
+
+ async detectTechStack(domain: string, headers: HeaderAnalysis): Promise {
+ const detected: TechDetection[] = [];
+ let scripts: string[] = [];
+ let stylesheets: string[] = [];
+ let metaGenerators: string[] = [];
+
+ try {
+ const browser = await browserManager.getBrowser();
+ const page = await browserManager.createPage(browser);
+
+ await page.goto(`https://${domain}`, { waitUntil: 'networkidle2', timeout: 20000 });
+
+ // Extract script sources
+ scripts = await page.$$eval('script[src]', els =>
+ els.map(el => el.getAttribute('src') || '').filter(Boolean)
+ );
+
+ // Extract stylesheet sources
+ stylesheets = await page.$$eval('link[rel="stylesheet"]', els =>
+ els.map(el => el.getAttribute('href') || '').filter(Boolean)
+ );
+
+ // Extract meta generators
+ metaGenerators = await page.$$eval('meta[name="generator"]', els =>
+ els.map(el => el.getAttribute('content') || '').filter(Boolean)
+ );
+
+ // Check global JS objects
+ const globals = await page.evaluate(() => {
+ const w = window as any;
+ return {
+ __REACT_DEVTOOLS_GLOBAL_HOOK__: !!w.__REACT_DEVTOOLS_GLOBAL_HOOK__,
+ __NEXT_DATA__: !!w.__NEXT_DATA__,
+ __NEXT_LOADED_PAGES__: !!w.__NEXT_LOADED_PAGES__,
+ __VUE__: !!w.__VUE__,
+ __VUE_HMR_RUNTIME__: !!w.__VUE_HMR_RUNTIME__,
+ __NUXT__: !!w.__NUXT__,
+ $nuxt: !!w.$nuxt,
+ ng: !!w.ng,
+ jQuery: !!w.jQuery,
+ $: typeof w.$ === 'function' && !!w.$.fn,
+ ___gatsby: !!document.getElementById('___gatsby'),
+ };
+ });
+
+ // Get page HTML for pattern matching
+ const html = await page.content();
+
+ // Match against signatures
+ for (const sig of TECH_SIGNATURES) {
+ let confidence = 0;
+ let matches = 0;
+ let checks = 0;
+
+ // Check globals
+ if (sig.globals) {
+ for (const g of sig.globals) {
+ checks++;
+ if ((globals as any)[g]) {
+ matches++;
+ confidence += 40;
+ }
+ }
+ }
+
+ // Check scripts
+ if (sig.scripts) {
+ for (const pattern of sig.scripts) {
+ checks++;
+ if (scripts.some(s => pattern.test(s))) {
+ matches++;
+ confidence += 30;
+ }
+ }
+ }
+
+ // Check stylesheets
+ if (sig.stylesheets) {
+ for (const pattern of sig.stylesheets) {
+ checks++;
+ if (stylesheets.some(s => pattern.test(s))) {
+ matches++;
+ confidence += 25;
+ }
+ }
+ }
+
+ // Check HTML patterns
+ if (sig.html) {
+ for (const pattern of sig.html) {
+ checks++;
+ if (pattern.test(html)) {
+ matches++;
+ confidence += 30;
+ }
+ }
+ }
+
+ // Check meta tags
+ if (sig.meta) {
+ checks++;
+ for (const gen of metaGenerators) {
+ if (sig.meta.content && sig.meta.content.test(gen)) {
+ matches++;
+ confidence += 35;
+ }
+ }
+ }
+
+ // Check headers
+ if (sig.headers) {
+ for (const [headerKey, pattern] of Object.entries(sig.headers)) {
+ checks++;
+ const headerVal = headerKey === 'server' ? headers.server :
+ headerKey === 'x-powered-by' ? headers.poweredBy : '';
+ if (pattern.test(headerVal)) {
+ matches++;
+ confidence += 35;
+ }
+ }
+ }
+
+ if (matches > 0) {
+ detected.push({
+ name: sig.name,
+ category: sig.category,
+ confidence: Math.min(confidence, 100),
+ });
+ }
+ }
+
+ browserManager.releaseBrowser(browser);
+ } catch (error: any) {
+ log.warn(`[Recon] Tech stack detection failed: ${error.message}`);
+ }
+
+ // Sort by confidence descending
+ detected.sort((a, b) => b.confidence - a.confidence);
+
+ return { detected, scripts, stylesheets, metaGenerators };
+ }
+
+ // ============================================
+ // Content Extraction Modules
+ // ============================================
+
+ async extractEmails(domain: string): Promise {
+ const emails = new Set();
+ const emailRegex = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g;
+
+ const pagesToCheck = [
+ `https://${domain}`,
+ `https://${domain}/contact`,
+ `https://${domain}/about`,
+ `https://${domain}/team`,
+ `https://${domain}/impressum`,
+ ];
+
+ for (const url of pagesToCheck) {
+ try {
+ const response = await this.httpClient.get(url, { timeout: 8000 });
+ if (typeof response.data === 'string') {
+ const found = response.data.match(emailRegex) || [];
+ found.forEach(e => emails.add(e.toLowerCase()));
+ }
+ } catch {
+ // Page doesn't exist or failed, skip
+ }
+ }
+
+ // Filter out common false positives
+ const filtered = [...emails].filter(e =>
+ !e.endsWith('.png') &&
+ !e.endsWith('.jpg') &&
+ !e.endsWith('.svg') &&
+ !e.includes('example.com') &&
+ !e.includes('sentry.io') &&
+ !e.includes('wixpress.com') &&
+ !e.startsWith('noreply@')
+ );
+
+ return filtered;
+ }
+
+ async findSocialLinks(domain: string): Promise {
+ const socialLinks: SocialLinks = {};
+
+ try {
+ const response = await this.httpClient.get(`https://${domain}`, { timeout: 10000 });
+ const html = typeof response.data === 'string' ? response.data : '';
+
+ for (const [platform, regex] of Object.entries(SOCIAL_PATTERNS)) {
+ // Reset global regex lastIndex
+ regex.lastIndex = 0;
+ const matches = [...new Set(html.match(regex) || [])];
+ if (matches.length > 0) {
+ socialLinks[platform] = matches.slice(0, 5); // limit to 5 per platform
+ }
+ }
+ } catch (error: any) {
+ log.warn(`[Recon] Social link extraction failed: ${error.message}`);
+ }
+
+ return socialLinks;
+ }
+
+ async extractLinkInfo(domain: string): Promise {
+ try {
+ const browser = await browserManager.getBrowser();
+ const page = await browserManager.createPage(browser);
+ await page.goto(`https://${domain}`, { waitUntil: 'domcontentloaded', timeout: 15000 });
+
+ const links = await extractLinks(page);
+ browserManager.releaseBrowser(browser);
+
+ const externalDomains = new Set();
+ let internal = 0;
+ let external = 0;
+
+ for (const link of links) {
+ if (link.isExternal) {
+ external++;
+ try {
+ const url = new URL(link.href);
+ externalDomains.add(url.hostname);
+ } catch { /* invalid URL, skip */ }
+ } else {
+ internal++;
+ }
+ }
+
+ return {
+ internal,
+ external,
+ totalLinks: links.length,
+ externalDomains: [...externalDomains].slice(0, 20),
+ };
+ } catch (error: any) {
+ log.warn(`[Recon] Link extraction failed: ${error.message}`);
+ return { internal: 0, external: 0, totalLinks: 0, externalDomains: [] };
+ }
+ }
+
+ async takeScreenshot(domain: string): Promise {
+ try {
+ const browser = await browserManager.getBrowser();
+ const page = await browserManager.createPage(browser);
+ await page.setViewport({ width: 1280, height: 800 });
+ await page.goto(`https://${domain}`, { waitUntil: 'networkidle2', timeout: 20000 });
+
+ // Ensure reports directory exists
+ const reportsDir = path.resolve(process.cwd(), 'reports');
+ if (!fs.existsSync(reportsDir)) {
+ fs.mkdirSync(reportsDir, { recursive: true });
+ }
+
+ const screenshotPath = path.join(reportsDir, `${domain.replace(/[^a-zA-Z0-9.-]/g, '_')}-screenshot.png`);
+ await page.screenshot({ path: screenshotPath, fullPage: false });
+ browserManager.releaseBrowser(browser);
+
+ log.info(`[Recon] Screenshot saved: ${screenshotPath}`);
+ return screenshotPath;
+ } catch (error: any) {
+ log.warn(`[Recon] Screenshot failed: ${error.message}`);
+ return '';
+ }
+ }
+
+ // ============================================
+ // Security Score Calculator
+ // ============================================
+
+ calculateSecurityScore(result: Omit): number {
+ let score = 0;
+ const maxScore = 100;
+
+ // SSL (25 points)
+ if (result.ssl.valid) {
+ score += 15;
+ if (result.ssl.daysRemaining > 30) score += 5;
+ if (result.ssl.daysRemaining > 90) score += 5;
+ }
+
+ // Security Headers (50 points)
+ const sh = result.headers.security;
+ if (sh.hsts) score += 10;
+ if (sh.csp) score += 10;
+ if (sh.xFrameOptions !== 'MISSING') score += 7;
+ if (sh.xContentType !== 'MISSING') score += 7;
+ if (sh.xXssProtection !== 'MISSING') score += 5;
+ if (sh.referrerPolicy !== 'MISSING') score += 6;
+ if (sh.permissionsPolicy !== 'MISSING') score += 5;
+
+ // DNS (15 points)
+ const hasSPF = result.dns.txt.some(t => t.includes('v=spf'));
+ const hasDMARC = result.dns.txt.some(t => t.includes('v=DMARC'));
+ const hasDKIM = result.dns.txt.some(t => t.includes('DKIM'));
+ if (hasSPF) score += 5;
+ if (hasDMARC) score += 5;
+ if (hasDKIM) score += 5;
+
+ // Response (10 points)
+ if (result.headers.statusCode === 200) score += 5;
+ if (result.headers.responseTime < 2000) score += 3;
+ if (result.headers.responseTime < 500) score += 2;
+
+ return Math.min(score, maxScore);
+ }
+
+ // ============================================
+ // Report Generator
+ // ============================================
+
+ generateReport(result: ReconResult): string {
+ const scoreBar = '█'.repeat(Math.round(result.securityScore / 10)) + '░'.repeat(10 - Math.round(result.securityScore / 10));
+ const scoreEmoji = result.securityScore >= 80 ? '🟢' : result.securityScore >= 50 ? '🟡' : '🔴';
+
+ let report = `# 🔍 Recon Report: ${result.domain}
+> Generated by **The Joker 🃏** on ${result.timestamp.toISOString()}
+
+---
+
+## ${scoreEmoji} Security Score: ${result.securityScore}/100
+
+\`\`\`
+${scoreBar} ${result.securityScore}/100
+\`\`\`
+
+---
+
+## 🌐 DNS Records
+
+| Type | Value |
+|------|-------|
+`;
+ result.dns.a.forEach(ip => report += `| A | \`${ip}\` |\n`);
+ result.dns.aaaa.forEach(ip => report += `| AAAA | \`${ip}\` |\n`);
+ result.dns.mx.forEach(mx => report += `| MX | \`${mx.exchange}\` (priority: ${mx.priority}) |\n`);
+ result.dns.ns.forEach(ns => report += `| NS | \`${ns}\` |\n`);
+ result.dns.cname.forEach(cn => report += `| CNAME | \`${cn}\` |\n`);
+ if (result.dns.txt.length > 0) {
+ result.dns.txt.forEach(txt => {
+ const truncated = txt.length > 80 ? txt.substring(0, 80) + '...' : txt;
+ report += `| TXT | \`${truncated}\` |\n`;
+ });
+ }
+ if (result.dns.soa) {
+ report += `| SOA | \`${result.dns.soa.nsname}\` (hostmaster: ${result.dns.soa.hostmaster}) |\n`;
+ }
+
+ report += `
+---
+
+## 🏢 WHOIS Information
+
+| Field | Value |
+|-------|-------|
+| Registrar | ${result.whois.registrar} |
+| Created | ${result.whois.createdDate} |
+| Expires | ${result.whois.expiryDate} |
+| DNSSEC | ${result.whois.dnssec} |
+`;
+ if (result.whois.nameServers.length > 0) {
+ report += `| Name Servers | ${result.whois.nameServers.join(', ')} |\n`;
+ }
+ if (result.whois.status.length > 0) {
+ report += `| Status | ${result.whois.status.slice(0, 3).join(', ')} |\n`;
+ }
+
+ report += `
+---
+
+## 🔒 SSL/TLS Certificate
+
+| Field | Value |
+|-------|-------|
+| Valid | ${result.ssl.valid ? '✅ Yes' : '❌ No'} |
+| Issuer | ${result.ssl.issuer} |
+| Valid From | ${result.ssl.validFrom} |
+| Valid To | ${result.ssl.validTo} |
+| Days Remaining | ${result.ssl.daysRemaining} |
+| Protocol | ${result.ssl.protocol} |
+
+---
+
+## 🛡️ Security Headers
+
+| Header | Status |
+|--------|--------|
+| Strict-Transport-Security (HSTS) | ${result.headers.security.hsts ? '✅' : '❌ Missing'} |
+| Content-Security-Policy (CSP) | ${result.headers.security.csp ? '✅' : '❌ Missing'} |
+| X-Frame-Options | ${result.headers.security.xFrameOptions === 'MISSING' ? '❌ Missing' : '✅ ' + result.headers.security.xFrameOptions} |
+| X-Content-Type-Options | ${result.headers.security.xContentType === 'MISSING' ? '❌ Missing' : '✅ ' + result.headers.security.xContentType} |
+| X-XSS-Protection | ${result.headers.security.xXssProtection === 'MISSING' ? '❌ Missing' : '✅ ' + result.headers.security.xXssProtection} |
+| Referrer-Policy | ${result.headers.security.referrerPolicy === 'MISSING' ? '❌ Missing' : '✅ ' + result.headers.security.referrerPolicy} |
+| Permissions-Policy | ${result.headers.security.permissionsPolicy === 'MISSING' ? '❌ Missing' : '✅ ' + result.headers.security.permissionsPolicy} |
+
+**Server:** ${result.headers.server} | **Powered By:** ${result.headers.poweredBy} | **Response Time:** ${result.headers.responseTime}ms
+
+---
+
+## 💻 Tech Stack
+`;
+
+ if (result.techStack.detected.length > 0) {
+ report += `\n| Technology | Category | Confidence |\n|------------|----------|------------|\n`;
+ result.techStack.detected.forEach(t => {
+ const bar = '█'.repeat(Math.round(t.confidence / 10)) + '░'.repeat(10 - Math.round(t.confidence / 10));
+ report += `| **${t.name}** | ${t.category} | ${bar} ${t.confidence}% |\n`;
+ });
+ } else {
+ report += `\n_No technologies detected._\n`;
+ }
+
+ report += `
+---
+
+## 🔗 Links Analysis
+
+| Metric | Count |
+|--------|-------|
+| Internal Links | ${result.links.internal} |
+| External Links | ${result.links.external} |
+| Total Links | ${result.links.totalLinks} |
+`;
+ if (result.links.externalDomains.length > 0) {
+ report += `\n**External Domains:** ${result.links.externalDomains.slice(0, 10).join(', ')}`;
+ if (result.links.externalDomains.length > 10) {
+ report += ` _(+${result.links.externalDomains.length - 10} more)_`;
+ }
+ report += '\n';
+ }
+
+ report += `
+---
+
+## 📧 Emails Found
+`;
+ if (result.emails.length > 0) {
+ result.emails.forEach(e => report += `- \`${e}\`\n`);
+ } else {
+ report += `_No email addresses found._\n`;
+ }
+
+ report += `
+---
+
+## 🌍 Social Links
+`;
+ const socialEntries = Object.entries(result.socialLinks);
+ if (socialEntries.length > 0) {
+ socialEntries.forEach(([platform, links]) => {
+ const icon = platform === 'twitter' ? '🐦' : platform === 'github' ? '🐙' : platform === 'linkedin' ? '💼' : platform === 'instagram' ? '📸' : platform === 'youtube' ? '🎬' : platform === 'facebook' ? '📘' : platform === 'discord' ? '💬' : '🔗';
+ report += `- ${icon} **${platform}:** ${links.join(', ')}\n`;
+ });
+ } else {
+ report += `_No social media links found._\n`;
+ }
+
+ if (result.screenshot) {
+ report += `
+---
+
+## 📸 Screenshot
+
+
+`;
+ }
+
+ report += `
+---
+
+*Report generated by 🃏 **The Joker** — Agentic Terminal*
+*Scan duration: passive reconnaissance only — no active port/vulnerability scanning*
+`;
+
+ return report;
+ }
+
+ // ============================================
+ // Helpers
+ // ============================================
+
+ private cleanDomain(input: string): string {
+ return input
+ .replace(/^https?:\/\//, '')
+ .replace(/^www\./, '')
+ .replace(/\/.*$/, '')
+ .trim()
+ .toLowerCase();
+ }
+
+ private normalizeArray(val: unknown): string[] {
+ if (Array.isArray(val)) return val.map(v => String(v));
+ if (typeof val === 'string') return val.split(/[\s,;]+/).filter(Boolean);
+ return [];
+ }
+
+ private emptyDNS(): DNSInfo {
+ return { a: [], aaaa: [], mx: [], txt: [], ns: [], cname: [], soa: null };
+ }
+
+ private emptyWhois(): WhoisInfo {
+ return { registrar: 'Unknown', createdDate: 'Unknown', expiryDate: 'Unknown', nameServers: [], status: [], dnssec: 'Unknown' };
+ }
+
+ private emptyHeaders(): HeaderAnalysis {
+ return {
+ server: 'Unknown', poweredBy: 'Unknown', contentType: 'Unknown',
+ security: { hsts: false, csp: false, xFrameOptions: 'MISSING', xContentType: 'MISSING', xXssProtection: 'MISSING', referrerPolicy: 'MISSING', permissionsPolicy: 'MISSING' },
+ statusCode: 0, responseTime: 0, redirectChain: [],
+ };
+ }
+
+ private emptySSL(): SSLInfo {
+ return { valid: false, issuer: 'Unknown', validFrom: 'Unknown', validTo: 'Unknown', daysRemaining: 0, protocol: 'Unknown' };
+ }
+}
+
+// ============================================
+// Tool Definitions
+// ============================================
+
+/**
+ * Execute domain recon as a standalone function
+ */
+export async function domainRecon(params: Record): Promise {
+ const startTime = Date.now();
+ const domain = params.domain;
+
+ if (!domain || typeof domain !== 'string') {
+ return {
+ success: false,
+ error: 'Domain is required. Usage: recon ',
+ metadata: { executionTime: 0, toolName: 'domain_recon', timestamp: new Date() },
+ };
+ }
+
+ try {
+ const pipeline = new ReconPipeline();
+ const result = await pipeline.recon(domain);
+ const report = pipeline.generateReport(result);
+
+ // Save report to file
+ const reportsDir = path.resolve(process.cwd(), 'reports');
+ if (!fs.existsSync(reportsDir)) {
+ fs.mkdirSync(reportsDir, { recursive: true });
+ }
+
+ const cleanDomain = domain.replace(/[^a-zA-Z0-9.-]/g, '_');
+ const reportPath = path.join(reportsDir, `${cleanDomain}-recon.md`);
+ fs.writeFileSync(reportPath, report, 'utf-8');
+
+ return {
+ success: true,
+ data: {
+ result,
+ report,
+ reportPath,
+ summary: `Recon complete for ${domain} — Security Score: ${result.securityScore}/100 — ${result.techStack.detected.length} technologies detected — ${result.emails.length} emails found`,
+ },
+ metadata: {
+ executionTime: Date.now() - startTime,
+ toolName: 'domain_recon',
+ timestamp: new Date(),
+ },
+ };
+ } catch (error: any) {
+ return {
+ success: false,
+ error: `Recon failed: ${error.message}`,
+ metadata: {
+ executionTime: Date.now() - startTime,
+ toolName: 'domain_recon',
+ timestamp: new Date(),
+ },
+ };
+ }
+}
+
+/**
+ * Tool definition for domain_recon
+ */
+export const domainReconTool: Tool = {
+ name: 'domain_recon',
+ description: 'Run passive reconnaissance on a domain — DNS records, WHOIS, SSL/TLS, security headers, tech stack detection, email & social link extraction, and screenshot capture. Generates a comprehensive markdown report.',
+ category: ToolCategory.SCRAPE,
+ parameters: [
+ {
+ name: 'domain',
+ type: 'string',
+ description: 'The domain to perform reconnaissance on (e.g., example.com)',
+ required: true,
+ },
+ ],
+ execute: domainRecon,
+};
+
+/**
+ * Register recon tools in the global registry
+ */
+export function registerReconTools(): void {
+ log.info('[Tools] Registering recon tools...');
+ toolRegistry.register(domainReconTool);
+ log.info('[Tools] ✅ Registered: domain_recon');
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index 402ad04..80b6c66 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -22,6 +22,17 @@ export interface LLMConfig {
timeout?: number;
}
+export type LLMBackend = 'lmstudio' | 'airllm';
+
+export interface AirLLMConfig {
+ enabled: boolean;
+ model: string;
+ port: number;
+ maxLength: number;
+ compression: 'none' | '4bit' | '8bit';
+ pythonPath: string;
+}
+
export interface ChatMessage {
role: 'system' | 'user' | 'assistant' | 'function' | 'tool';
content: string;
@@ -148,7 +159,7 @@ export interface PlanStep {
dependsOn?: string[];
}
-export type Intent =
+export type Intent =
| 'web_search'
| 'web_scrape'
| 'data_extract'
@@ -360,4 +371,5 @@ export interface AppConfig {
scraper: ScraperConfig;
terminal: TerminalConfig;
log: LogConfig;
+ airllm: AirLLMConfig;
}
diff --git a/src/utils/config.ts b/src/utils/config.ts
index 47d1f6c..d4d14c7 100644
--- a/src/utils/config.ts
+++ b/src/utils/config.ts
@@ -5,7 +5,7 @@
import dotenv from 'dotenv';
import path from 'path';
-import { AppConfig, LLMConfig, AgentConfig, ScraperConfig, TerminalConfig, LogConfig } from '../types';
+import { AppConfig, LLMConfig, AgentConfig, ScraperConfig, TerminalConfig, LogConfig, AirLLMConfig } from '../types';
// Load environment variables
dotenv.config();
@@ -38,7 +38,7 @@ function getEnvBool(key: string, fallback: boolean): boolean {
* LLM Configuration
*/
export const llmConfig: LLMConfig = {
- baseUrl: getEnv('LM_STUDIO_BASE_URL', 'http://192.168.56.1:1234'),
+ baseUrl: getEnv('LM_STUDIO_BASE_URL', 'http://localhost:1234'),
model: getEnv('LM_STUDIO_MODEL', 'qwen2.5-coder-14b-instruct-uncensored'),
apiKey: getEnv('LM_STUDIO_API_KEY', 'not-needed'),
temperature: 0.7,
@@ -86,6 +86,24 @@ export const logConfig: LogConfig = {
maxFiles: getEnvNumber('LOG_MAX_FILES', 5),
};
+/**
+ * AirLLM Configuration
+ * Enables 70B-parameter model inference on 4GB RAM via layer-wise loading.
+ *
+ * Citation:
+ * Li, G. (2023). AirLLM: scaling large language models on low-end
+ * commodity computers [Computer software].
+ * https://github.com/lyogavin/airllm/
+ */
+export const airllmConfig: AirLLMConfig = {
+ enabled: getEnvBool('AIRLLM_ENABLED', false),
+ model: getEnv('AIRLLM_MODEL', 'garage-bAInd/Platypus2-70B-instruct'),
+ port: getEnvNumber('AIRLLM_PORT', 8899),
+ maxLength: getEnvNumber('AIRLLM_MAX_LENGTH', 512),
+ compression: getEnv('AIRLLM_COMPRESSION', 'none') as AirLLMConfig['compression'],
+ pythonPath: getEnv('AIRLLM_PYTHON_PATH', 'python'),
+};
+
/**
* Complete Application Configuration
*/
@@ -95,6 +113,7 @@ export const config: AppConfig = {
scraper: scraperConfig,
terminal: terminalConfig,
log: logConfig,
+ airllm: airllmConfig,
};
/**
diff --git a/tests/unit/llm/airllm-bridge.test.ts b/tests/unit/llm/airllm-bridge.test.ts
new file mode 100644
index 0000000..1e4c941
--- /dev/null
+++ b/tests/unit/llm/airllm-bridge.test.ts
@@ -0,0 +1,289 @@
+/**
+ * AirLLM Bridge Unit Tests
+ *
+ * Tests for the AirLLMBridge class that manages the Python
+ * AirLLM sidecar server lifecycle.
+ *
+ * These tests mock child_process.spawn and axios — no actual
+ * Python or GPU required.
+ */
+
+// ============================================
+// Mocks — must be before imports
+// ============================================
+
+const mockSpawn = jest.fn();
+const mockAxiosGet = jest.fn();
+const mockKill = jest.fn();
+
+jest.mock('child_process', () => ({
+ spawn: (...args: unknown[]) => mockSpawn(...args),
+}));
+
+jest.mock('axios', () => ({
+ __esModule: true,
+ default: {
+ get: (...args: unknown[]) => mockAxiosGet(...args),
+ create: jest.fn(() => ({
+ get: mockAxiosGet,
+ post: jest.fn(),
+ })),
+ },
+}));
+
+jest.mock('../../../src/utils/logger', () => ({
+ logger: {
+ info: jest.fn(),
+ debug: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('../../../src/utils/config', () => ({
+ airllmConfig: {
+ enabled: false,
+ model: 'test-model/test-70b',
+ port: 9999,
+ maxLength: 256,
+ compression: 'none' as const,
+ pythonPath: 'python',
+ },
+ llmConfig: {
+ baseUrl: 'http://localhost:1234',
+ model: 'test-model',
+ apiKey: 'not-needed',
+ temperature: 0.7,
+ maxTokens: 4096,
+ timeout: 60000,
+ },
+}));
+
+// ============================================
+// Imports
+// ============================================
+
+import { EventEmitter } from 'events';
+import { AirLLMBridge } from '../../../src/llm/airllm-bridge';
+
+// ============================================
+// Helper
+// ============================================
+
+function createMockProcess() {
+ const proc = new EventEmitter();
+ (proc as any).pid = 12345;
+ (proc as any).stdout = new EventEmitter();
+ (proc as any).stderr = new EventEmitter();
+ (proc as any).kill = mockKill;
+ (proc as any).stdin = null;
+ return proc;
+}
+
+// ============================================
+// Tests
+// ============================================
+
+describe('AirLLMBridge', () => {
+ let bridge: AirLLMBridge;
+ let mockProcess: ReturnType;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockProcess = createMockProcess();
+ mockSpawn.mockReturnValue(mockProcess);
+ });
+
+ afterEach(() => {
+ if (bridge) {
+ try { bridge.destroy(); } catch { /* ignore */ }
+ }
+ });
+
+ // ------------------------------------------
+ // Construction
+ // ------------------------------------------
+
+ describe('constructor', () => {
+ it('should create bridge with default config', () => {
+ bridge = new AirLLMBridge();
+ expect(bridge.isReady()).toBe(false);
+ expect(bridge.getPid()).toBeNull();
+ });
+
+ it('should allow config overrides', () => {
+ bridge = new AirLLMBridge({ port: 7777, model: 'custom/model' });
+ const cfg = bridge.getConfig();
+ expect(cfg.port).toBe(7777);
+ expect(cfg.model).toBe('custom/model');
+ });
+
+ it('should set base URL from port', () => {
+ bridge = new AirLLMBridge({ port: 7777 });
+ expect(bridge.getBaseUrl()).toBe('http://127.0.0.1:7777');
+ });
+ });
+
+ // ------------------------------------------
+ // Start
+ // ------------------------------------------
+
+ describe('start()', () => {
+ it('should spawn the Python sidecar process', async () => {
+ bridge = new AirLLMBridge();
+ mockAxiosGet.mockResolvedValue({ status: 200, data: { data: [] } });
+
+ await bridge.start();
+
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
+ expect(mockSpawn).toHaveBeenCalledWith(
+ 'python',
+ expect.arrayContaining(['--port', '9999']),
+ expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] })
+ );
+ });
+
+ it('should emit sidecar:ready when healthy', async () => {
+ bridge = new AirLLMBridge();
+ mockAxiosGet.mockResolvedValue({ status: 200, data: { data: [] } });
+
+ const readyHandler = jest.fn();
+ bridge.on('sidecar:ready', readyHandler);
+
+ await bridge.start();
+
+ expect(readyHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ pid: 12345, port: 9999 })
+ );
+ });
+
+ it('should set ready state after successful start', async () => {
+ bridge = new AirLLMBridge();
+ mockAxiosGet.mockResolvedValue({ status: 200, data: { data: [] } });
+
+ await bridge.start();
+
+ expect(bridge.isReady()).toBe(true);
+ expect(bridge.getPid()).toBe(12345);
+ });
+
+ it('should not spawn again if already running', async () => {
+ bridge = new AirLLMBridge();
+ mockAxiosGet.mockResolvedValue({ status: 200, data: { data: [] } });
+
+ await bridge.start();
+ await bridge.start();
+
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
+ });
+
+ it('should throw if sidecar process exits during startup', async () => {
+ bridge = new AirLLMBridge();
+ mockAxiosGet.mockRejectedValue(new Error('Connection refused'));
+
+ // Simulate process exit shortly after spawn
+ setTimeout(() => {
+ mockProcess.emit('exit', 1, null);
+ (bridge as any).sidecar = null;
+ }, 50);
+
+ await expect(bridge.start()).rejects.toThrow('sidecar process exited during startup');
+ });
+ });
+
+ // ------------------------------------------
+ // getClient
+ // ------------------------------------------
+
+ describe('getClient()', () => {
+ it('should return a client after start', async () => {
+ bridge = new AirLLMBridge();
+ mockAxiosGet.mockResolvedValue({ status: 200, data: { data: [] } });
+
+ await bridge.start();
+ const client = bridge.getClient();
+
+ expect(client).toBeDefined();
+ });
+
+ it('should throw if not started', () => {
+ bridge = new AirLLMBridge();
+ expect(() => bridge.getClient()).toThrow('not running');
+ });
+ });
+
+ // ------------------------------------------
+ // Stop
+ // ------------------------------------------
+
+ describe('stop()', () => {
+ it('should kill the sidecar process', async () => {
+ bridge = new AirLLMBridge();
+ mockAxiosGet.mockResolvedValue({ status: 200, data: { data: [] } });
+ await bridge.start();
+
+ bridge.stop();
+
+ expect(mockKill).toHaveBeenCalledWith('SIGTERM');
+ expect(bridge.isReady()).toBe(false);
+ });
+
+ it('should handle stop when not running', () => {
+ bridge = new AirLLMBridge();
+ expect(() => bridge.stop()).not.toThrow();
+ });
+ });
+
+ // ------------------------------------------
+ // Events
+ // ------------------------------------------
+
+ describe('events', () => {
+ it('should emit sidecar:output for stdout', async () => {
+ bridge = new AirLLMBridge();
+ mockAxiosGet.mockResolvedValue({ status: 200, data: { data: [] } });
+
+ const outputHandler = jest.fn();
+ bridge.on('sidecar:output', outputHandler);
+
+ await bridge.start();
+
+ (mockProcess as any).stdout.emit('data', Buffer.from('[AirLLM] Loading model...'));
+
+ expect(outputHandler).toHaveBeenCalledWith('[AirLLM] Loading model...');
+ });
+
+ it('should emit sidecar:exit on process exit', async () => {
+ bridge = new AirLLMBridge();
+ mockAxiosGet.mockResolvedValue({ status: 200, data: { data: [] } });
+
+ const exitHandler = jest.fn();
+ bridge.on('sidecar:exit', exitHandler);
+
+ await bridge.start();
+
+ mockProcess.emit('exit', 0, null);
+
+ expect(exitHandler).toHaveBeenCalledWith({ code: 0, signal: null });
+ expect(bridge.isReady()).toBe(false);
+ });
+ });
+
+ // ------------------------------------------
+ // Destroy
+ // ------------------------------------------
+
+ describe('destroy()', () => {
+ it('should stop sidecar and remove all listeners', async () => {
+ bridge = new AirLLMBridge();
+ mockAxiosGet.mockResolvedValue({ status: 200, data: { data: [] } });
+ await bridge.start();
+
+ bridge.destroy();
+
+ expect(mockKill).toHaveBeenCalled();
+ expect(bridge.isReady()).toBe(false);
+ expect(bridge.listenerCount('sidecar:ready')).toBe(0);
+ });
+ });
+});