batman
   1#!/usr/bin/env tsx
   2import { Command } from "commander";
   3import chalk from "chalk";
   4import ora from "ora";
   5import { readFileSync, existsSync, createWriteStream, statSync } from "fs";
   6import { promises as fs } from "fs";
   7import path from "path";
   8import os from "os";
   9import https from "https";
  10
  11// Types
  12interface JA4Analysis {
  13  format: string;
  14  breakdown: {
  15    part_a: {
  16      raw: string;
  17      protocol?: string;
  18      tls_version?: string;
  19      sni_presence?: string;
  20      cipher_count?: number;
  21      extension_count?: number;
  22      alpn?: string;
  23      method?: string;
  24      version?: string;
  25      has_cookies?: boolean;
  26      has_referer?: boolean;
  27      header_count?: number;
  28      language?: string;
  29      description?: string;
  30      note?: string;
  31      value?: string | number;
  32    };
  33    part_b: {
  34      raw: string;
  35      description: string;
  36      note: string;
  37    };
  38    part_c: {
  39      raw: string;
  40      description: string;
  41      note: string;
  42    };
  43    part_d?: {
  44      raw: string;
  45      description: string;
  46      note: string;
  47    };
  48  };
  49  human_readable?: string;
  50  use_cases?: string[];
  51  analysis?: {
  52    actual_window_size?: number;
  53    network_overhead?: string;
  54    os_indicators?: string[];
  55    tunnel_vpn_indicators?: string[];
  56  };
  57}
  58
  59interface DatabaseResult {
  60  ja4?: string;
  61  ja4s?: string;
  62  ja4h?: string;
  63  ja4x?: string;
  64  ja4t?: string;
  65  application?: string;
  66  os?: string;
  67  verified?: boolean;
  68  observation_count?: number;
  69}
  70
  71interface SearchResult {
  72  count: number;
  73  results: DatabaseResult[];
  74}
  75
  76interface ComparisonResult {
  77  fingerprint_1: string;
  78  fingerprint_2: string;
  79  identical: boolean;
  80  similarities: Array<{ part: string; value: string; note: string }>;
  81  differences: Array<{ part: string; fp1: string; fp2: string; note: string }>;
  82  analysis: string;
  83}
  84
  85interface DatabaseStats {
  86  status: string;
  87  total_records: number;
  88  ja4_count: number;
  89  ja4s_count: number;
  90  ja4h_count: number;
  91  ja4x_count: number;
  92  ja4t_count: number;
  93  verified_count: number;
  94  applications: number;
  95  operating_systems: number;
  96  last_update: string;
  97}
  98
  99// Database URL
 100const DB_URL = "https://ja4db.com/api/download/";
 101
 102// Get XDG cache directory
 103function getXDGCacheDir(): string {
 104  if (process.platform === "win32") {
 105    return process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
 106  }
 107
 108  const homeDir = os.homedir();
 109  return process.env.XDG_CACHE_HOME || path.join(homeDir, ".cache");
 110}
 111
 112const CACHE_DIR = path.join(getXDGCacheDir(), "ja4-cli");
 113const DB_FILE = path.join(CACHE_DIR, "ja4plus_db.json");
 114const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
 115
 116class JA4Database {
 117  private database: any = null;
 118
 119  async ensureCacheDir(): Promise<void> {
 120    try {
 121      await fs.mkdir(CACHE_DIR, { recursive: true });
 122    } catch (error) {
 123      // Directory might already exist
 124    }
 125  }
 126
 127  async downloadDatabase(): Promise<void> {
 128    await this.ensureCacheDir();
 129
 130    return new Promise((resolve, reject) => {
 131      const file = createWriteStream(DB_FILE);
 132
 133      https
 134        .get(DB_URL, (response) => {
 135          if (response.statusCode !== 200) {
 136            reject(new Error(`Failed to download database: ${response.statusCode}`));
 137            return;
 138          }
 139
 140          response.pipe(file);
 141          file.on("finish", () => {
 142            file.close();
 143            resolve();
 144          });
 145        })
 146        .on("error", reject);
 147    });
 148  }
 149
 150  async loadDatabase(): Promise<void> {
 151    try {
 152      const stats = await fs.stat(DB_FILE);
 153      const age = Date.now() - stats.mtime.getTime();
 154
 155      if (age > CACHE_DURATION) {
 156        await this.downloadDatabase();
 157      }
 158
 159      const content = await fs.readFile(DB_FILE, "utf8");
 160      this.database = JSON.parse(content);
 161    } catch (error) {
 162      await this.downloadDatabase();
 163      const content = await fs.readFile(DB_FILE, "utf8");
 164      this.database = JSON.parse(content);
 165    }
 166  }
 167
 168  lookupJA4(fingerprint: string): DatabaseResult[] {
 169    if (!this.database) return [];
 170    return this.database.filter((entry: any) => entry.ja4 === fingerprint);
 171  }
 172
 173  lookupJA4S(fingerprint: string): DatabaseResult[] {
 174    if (!this.database) return [];
 175    return this.database.filter((entry: any) => entry.ja4s === fingerprint);
 176  }
 177
 178  lookupJA4H(fingerprint: string): DatabaseResult[] {
 179    if (!this.database) return [];
 180    return this.database.filter((entry: any) => entry.ja4h === fingerprint);
 181  }
 182
 183  lookupJA4X(fingerprint: string): DatabaseResult[] {
 184    if (!this.database) return [];
 185    return this.database.filter((entry: any) => entry.ja4x === fingerprint);
 186  }
 187
 188  lookupJA4T(fingerprint: string): DatabaseResult[] {
 189    if (!this.database) return [];
 190    return this.database.filter((entry: any) => entry.ja4t === fingerprint);
 191  }
 192
 193  searchByApplication(query: string, limit: number = 50): SearchResult {
 194    if (!this.database) return { count: 0, results: [] };
 195
 196    const searchTerm = query.toLowerCase();
 197    const results = this.database
 198      .filter((entry: any) => entry.application && entry.application.toLowerCase().includes(searchTerm))
 199      .slice(0, limit);
 200
 201    return {
 202      count: results.length,
 203      results,
 204    };
 205  }
 206
 207  searchByOS(query: string, limit: number = 50): SearchResult {
 208    if (!this.database) return { count: 0, results: [] };
 209
 210    const searchTerm = query.toLowerCase();
 211    const results = this.database
 212      .filter((entry: any) => entry.os && entry.os.toLowerCase().includes(searchTerm))
 213      .slice(0, limit);
 214
 215    return {
 216      count: results.length,
 217      results,
 218    };
 219  }
 220
 221  getStatistics(): DatabaseStats {
 222    if (!this.database) {
 223      return {
 224        status: "No database loaded",
 225        total_records: 0,
 226        ja4_count: 0,
 227        ja4s_count: 0,
 228        ja4h_count: 0,
 229        ja4x_count: 0,
 230        ja4t_count: 0,
 231        verified_count: 0,
 232        applications: 0,
 233        operating_systems: 0,
 234        last_update: "Unknown",
 235      };
 236    }
 237
 238    const total_records = this.database.length;
 239    const ja4_count = this.database.filter((entry: any) => entry.ja4).length;
 240    const ja4s_count = this.database.filter((entry: any) => entry.ja4s).length;
 241    const ja4h_count = this.database.filter((entry: any) => entry.ja4h).length;
 242    const ja4x_count = this.database.filter((entry: any) => entry.ja4x).length;
 243    const ja4t_count = this.database.filter((entry: any) => entry.ja4t).length;
 244    const verified_count = this.database.filter((entry: any) => entry.verified).length;
 245    const applications = new Set(this.database.map((entry: any) => entry.application).filter(Boolean)).size;
 246    const operating_systems = new Set(this.database.map((entry: any) => entry.os).filter(Boolean)).size;
 247
 248    let last_update = "Unknown";
 249    try {
 250      const stats = statSync(DB_FILE);
 251      last_update = stats.mtime.toISOString();
 252    } catch (error) {
 253      // Ignore error
 254    }
 255
 256    return {
 257      status: "Database loaded successfully",
 258      total_records,
 259      ja4_count,
 260      ja4s_count,
 261      ja4h_count,
 262      ja4x_count,
 263      ja4t_count,
 264      verified_count,
 265      applications,
 266      operating_systems,
 267      last_update,
 268    };
 269  }
 270}
 271
 272class JA4Analyzer {
 273  static parseJA4(fingerprint: string): JA4Analysis {
 274    const parts = fingerprint.split("_");
 275    if (parts.length !== 3) {
 276      throw new Error("Invalid JA4 fingerprint format");
 277    }
 278
 279    const [partA, partB, partC] = parts;
 280
 281    const protocol = partA.charAt(0);
 282    const version = partA.substring(1, 3);
 283    const sni = partA.charAt(3);
 284    const cipherCount = parseInt(partA.substring(4, 6));
 285    const extensionCount = parseInt(partA.substring(6, 8));
 286    const alpn = partA.substring(8);
 287
 288    return {
 289      format: "JA4",
 290      breakdown: {
 291        part_a: {
 292          raw: partA,
 293          protocol: this.explainProtocol(protocol).description,
 294          tls_version: this.explainTLSVersion(version).description,
 295          sni_presence: this.explainSNI(sni).description,
 296          cipher_count: cipherCount,
 297          extension_count: extensionCount,
 298          alpn: this.explainALPN(alpn).description,
 299        },
 300        part_b: {
 301          raw: partB,
 302          description: "SHA256 hash of cipher suites in order",
 303          note: "First 12 characters of hash",
 304        },
 305        part_c: {
 306          raw: partC,
 307          description: "SHA256 hash of extensions in order",
 308          note: "First 12 characters of hash",
 309        },
 310      },
 311      human_readable: this.generateHumanReadable(protocol, version, sni, alpn),
 312    };
 313  }
 314
 315  static parseJA4S(fingerprint: string): JA4Analysis {
 316    const parts = fingerprint.split("_");
 317    if (parts.length !== 3) {
 318      throw new Error("Invalid JA4S fingerprint format");
 319    }
 320
 321    const [partA, partB, partC] = parts;
 322
 323    const protocol = partA.charAt(0);
 324    const version = partA.substring(1, 3);
 325    const cipherCount = parseInt(partA.substring(3, 5));
 326    const extensionCount = parseInt(partA.substring(5, 7));
 327    const alpn = partA.substring(7);
 328
 329    return {
 330      format: "JA4S",
 331      breakdown: {
 332        part_a: {
 333          raw: partA,
 334          protocol: this.explainProtocol(protocol).description,
 335          tls_version: this.explainTLSVersion(version).description,
 336          cipher_count: cipherCount,
 337          extension_count: extensionCount,
 338          alpn: this.explainALPN(alpn).description,
 339        },
 340        part_b: {
 341          raw: partB,
 342          description: "SHA256 hash of cipher suite",
 343          note: "First 12 characters of hash",
 344        },
 345        part_c: {
 346          raw: partC,
 347          description: "SHA256 hash of extensions",
 348          note: "First 12 characters of hash",
 349        },
 350      },
 351      human_readable: `JA4S Server: ${this.explainProtocol(protocol).description} ${this.explainTLSVersion(version).description}`,
 352    };
 353  }
 354
 355  static parseJA4H(fingerprint: string): JA4Analysis {
 356    const parts = fingerprint.split("_");
 357    if (parts.length !== 4) {
 358      throw new Error("Invalid JA4H fingerprint format");
 359    }
 360
 361    const [partA, partB, partC, partD] = parts;
 362
 363    const method = partA.substring(0, 2);
 364    const version = partA.substring(2, 4);
 365    const cookiePresence = partA.charAt(4);
 366    const refererPresence = partA.charAt(5);
 367    const headerCount = parseInt(partA.substring(6, 8));
 368    const languageHeader = partA.substring(8);
 369
 370    return {
 371      format: "JA4H",
 372      breakdown: {
 373        part_a: {
 374          raw: partA,
 375          method: this.explainHTTPMethod(method).method,
 376          version: this.explainHTTPVersion(version).description,
 377          has_cookies: cookiePresence === "c",
 378          has_referer: refererPresence === "r",
 379          header_count: headerCount,
 380          language: languageHeader,
 381        },
 382        part_b: {
 383          raw: partB,
 384          description: "SHA256 hash of header names",
 385          note: "First 12 characters of hash",
 386        },
 387        part_c: {
 388          raw: partC,
 389          description: "SHA256 hash of header values",
 390          note: "First 12 characters of hash",
 391        },
 392        part_d: {
 393          raw: partD,
 394          description: "SHA256 hash of cookies",
 395          note: "First 12 characters of hash",
 396        },
 397      },
 398      use_cases: ["HTTP client fingerprinting", "Bot detection", "User-Agent validation", "Header analysis"],
 399    };
 400  }
 401
 402  static parseJA4X(fingerprint: string): JA4Analysis {
 403    const parts = fingerprint.split("_");
 404    if (parts.length !== 3) {
 405      throw new Error("Invalid JA4X fingerprint format");
 406    }
 407
 408    return {
 409      format: "JA4X",
 410      breakdown: {
 411        part_a: {
 412          raw: parts[0],
 413          description: "X.509 certificate details",
 414          note: "Certificate chain information",
 415        },
 416        part_b: {
 417          raw: parts[1],
 418          description: "Certificate algorithms",
 419          note: "Signature and key algorithms",
 420        },
 421        part_c: {
 422          raw: parts[2],
 423          description: "Certificate extensions",
 424          note: "Extension fields and values",
 425        },
 426      },
 427      use_cases: ["Certificate fingerprinting", "TLS server identification", "Certificate chain analysis"],
 428    };
 429  }
 430
 431  static parseJA4T(fingerprint: string): JA4Analysis {
 432    const parts = fingerprint.split("_");
 433    if (parts.length !== 4) {
 434      throw new Error("Invalid JA4T fingerprint format");
 435    }
 436
 437    const [windowSize, tcpOptions, mss, windowScale] = parts;
 438
 439    return {
 440      format: "JA4T",
 441      breakdown: {
 442        part_a: {
 443          raw: windowSize,
 444          description: "TCP window size",
 445          note: "Initial window size from SYN packet",
 446        },
 447        part_b: {
 448          raw: tcpOptions,
 449          description: "TCP options",
 450          note: "Options from TCP header",
 451        },
 452        part_c: {
 453          raw: mss,
 454          description: "Maximum Segment Size",
 455          note: "MSS value from TCP options",
 456        },
 457        part_d: {
 458          raw: windowScale,
 459          description: "Window scale factor",
 460          note: "Window scaling option value",
 461        },
 462      },
 463      analysis: {
 464        actual_window_size: parseInt(windowSize) * Math.pow(2, parseInt(windowScale) || 0),
 465        network_overhead: this.analyzeNetworkOverhead(parseInt(mss)),
 466        os_indicators: this.analyzeTCPOSIndicators(tcpOptions),
 467        tunnel_vpn_indicators: this.analyzeTunnelIndicators(parseInt(mss)),
 468      },
 469      use_cases: [
 470        "Operating system fingerprinting",
 471        "Network stack identification",
 472        "VPN/Tunnel detection",
 473        "Network performance analysis",
 474      ],
 475    };
 476  }
 477
 478  static compareFingerprints(fp1: string, fp2: string): ComparisonResult {
 479    const parts1 = fp1.split("_");
 480    const parts2 = fp2.split("_");
 481
 482    if (parts1.length !== parts2.length) {
 483      throw new Error("Fingerprints must be of the same type");
 484    }
 485
 486    const similarities: Array<{ part: string; value: string; note: string }> = [];
 487    const differences: Array<{ part: string; fp1: string; fp2: string; note: string }> = [];
 488
 489    const partNames = ["A", "B", "C", "D"];
 490
 491    for (let i = 0; i < parts1.length; i++) {
 492      const letter = partNames[i];
 493
 494      if (parts1[i] === parts2[i]) {
 495        similarities.push({
 496          part: `Part ${letter}`,
 497          value: parts1[i],
 498          note: "Identical values",
 499        });
 500      } else {
 501        differences.push({
 502          part: `Part ${letter}`,
 503          fp1: parts1[i],
 504          fp2: parts2[i],
 505          note: this.getDifferenceNote(letter, parts1[i], parts2[i]),
 506        });
 507      }
 508    }
 509
 510    const identical = differences.length === 0;
 511    const analysis = this.generateComparisonAnalysis(similarities, differences);
 512
 513    return {
 514      fingerprint_1: fp1,
 515      fingerprint_2: fp2,
 516      identical,
 517      similarities,
 518      differences,
 519      analysis,
 520    };
 521  }
 522
 523  private static explainProtocol(code: string): { description: string; full_name: string } {
 524    const protocols: Record<string, { description: string; full_name: string }> = {
 525      t: { description: "TCP", full_name: "Transmission Control Protocol" },
 526      q: { description: "QUIC", full_name: "Quick UDP Internet Connections" },
 527      d: { description: "DTLS", full_name: "Datagram Transport Layer Security" },
 528    };
 529
 530    return protocols[code] || { description: "Unknown", full_name: "Unknown Protocol" };
 531  }
 532
 533  private static explainTLSVersion(version: string): { description: string; security: string } {
 534    const versions: Record<string, { description: string; security: string }> = {
 535      "10": { description: "TLS 1.0", security: "Deprecated - Not Secure" },
 536      "11": { description: "TLS 1.1", security: "Deprecated - Not Secure" },
 537      "12": { description: "TLS 1.2", security: "Secure" },
 538      "13": { description: "TLS 1.3", security: "Most Secure" },
 539    };
 540
 541    return versions[version] || { description: "Unknown", security: "Unknown" };
 542  }
 543
 544  private static explainSNI(code: string): { description: string; note: string } {
 545    const sni: Record<string, { description: string; note: string }> = {
 546      d: { description: "SNI Present", note: "Server Name Indication extension present" },
 547      i: {
 548        description: "SNI Not Present",
 549        note: "Server Name Indication extension not present or IP address used",
 550      },
 551    };
 552
 553    return sni[code] || { description: "Unknown", note: "Unknown SNI status" };
 554  }
 555
 556  private static explainALPN(code: string): { description: string; protocol: string } {
 557    const alpns: Record<string, { description: string; protocol: string }> = {
 558      h1: { description: "HTTP/1.1", protocol: "HTTP/1.1" },
 559      h2: { description: "HTTP/2", protocol: "HTTP/2" },
 560      h3: { description: "HTTP/3", protocol: "HTTP/3" },
 561      dt: { description: "DoT", protocol: "DNS over TLS" },
 562      dq: { description: "DoQ", protocol: "DNS over QUIC" },
 563    };
 564
 565    return alpns[code] || { description: "None or Other", protocol: "Unknown" };
 566  }
 567
 568  private static explainHTTPMethod(code: string): { method: string; description: string } {
 569    const methods: Record<string, { method: string; description: string }> = {
 570      ge: { method: "GET", description: "Retrieve data from server" },
 571      po: { method: "POST", description: "Send data to server" },
 572      he: { method: "HEAD", description: "Get headers only" },
 573      pu: { method: "PUT", description: "Update/replace resource" },
 574      de: { method: "DELETE", description: "Remove resource" },
 575      op: { method: "OPTIONS", description: "Get allowed methods" },
 576      pa: { method: "PATCH", description: "Partial update" },
 577    };
 578
 579    return methods[code] || { method: "Unknown", description: "Unknown HTTP method" };
 580  }
 581
 582  private static explainHTTPVersion(version: string): { description: string } {
 583    const versions: Record<string, { description: string }> = {
 584      "10": { description: "HTTP/1.0" },
 585      "11": { description: "HTTP/1.1" },
 586      "20": { description: "HTTP/2" },
 587      "30": { description: "HTTP/3" },
 588    };
 589
 590    return versions[version] || { description: "Unknown HTTP version" };
 591  }
 592
 593  private static generateHumanReadable(protocol: string, version: string, sni: string, alpn: string): string {
 594    const proto = this.explainProtocol(protocol);
 595    const ver = this.explainTLSVersion(version);
 596    const sniInfo = this.explainSNI(sni);
 597    const alpnInfo = this.explainALPN(alpn);
 598
 599    return `${proto.description} ${ver.description} connection with ${sniInfo.description.toLowerCase()}, using ${alpnInfo.description}`;
 600  }
 601
 602  private static getDifferenceNote(part: string, value1: string, value2: string): string {
 603    if (part === "A") {
 604      return "Different TLS configuration parameters";
 605    } else {
 606      return "Different cryptographic hash values indicating different cipher/extension sets";
 607    }
 608  }
 609
 610  private static generateComparisonAnalysis(similarities: any[], differences: any[]): string {
 611    const simCount = similarities.length;
 612    const diffCount = differences.length;
 613    const total = simCount + diffCount;
 614
 615    if (diffCount === 0) {
 616      return "Fingerprints are identical - same client configuration";
 617    } else if (simCount > diffCount) {
 618      return `Fingerprints are similar (${Math.round((simCount / total) * 100)}% match) - likely same client type with minor differences`;
 619    } else {
 620      return `Fingerprints are different (${Math.round((simCount / total) * 100)}% match) - likely different client types or major configuration differences`;
 621    }
 622  }
 623
 624  private static analyzeNetworkOverhead(mss: number): string {
 625    if (mss <= 536) return "High overhead (dialup/satellite)";
 626    if (mss <= 1460) return "Standard Ethernet";
 627    if (mss <= 8960) return "Jumbo frames";
 628    return "Custom MTU";
 629  }
 630
 631  private static analyzeTCPOSIndicators(options: string): string[] {
 632    // Simplified OS detection based on TCP options
 633    const indicators = [];
 634    if (options.includes("020405b4")) indicators.push("Linux");
 635    if (options.includes("020405dc")) indicators.push("Windows");
 636    if (options.includes("02040578")) indicators.push("macOS");
 637    return indicators.length ? indicators : ["Unknown"];
 638  }
 639
 640  private static analyzeTunnelIndicators(mss: number): string[] {
 641    const indicators = [];
 642    if (mss < 1460) indicators.push("Possible VPN/Tunnel");
 643    if (mss === 1436) indicators.push("Common VPN MSS");
 644    return indicators;
 645  }
 646}
 647
 648// CLI Implementation
 649const program = new Command();
 650const db = new JA4Database();
 651
 652program.name("ja4-cli").description("JA4 fingerprint analysis CLI tool").version("1.0.0");
 653
 654program
 655  .command("analyze")
 656  .description("Analyze a JA4 fingerprint")
 657  .argument("<fingerprint>", "Fingerprint to analyze")
 658  .option("-t, --type <type>", "Fingerprint type", "ja4")
 659  .option("--no-db", "Skip database lookup")
 660  .option("-j, --json", "Output as JSON")
 661  .option("-v, --verbose", "Verbose output")
 662  .action(async (fingerprint: string, options) => {
 663    const spinner = ora("Analyzing fingerprint...").start();
 664
 665    try {
 666      let analysis: JA4Analysis;
 667      let dbResults: DatabaseResult[] = [];
 668
 669      // Load database if needed
 670      if (options.db) {
 671        spinner.text = "Loading database...";
 672        await db.loadDatabase();
 673      }
 674
 675      // Analyze fingerprint
 676      spinner.text = "Parsing fingerprint...";
 677      const type = options.type.toLowerCase();
 678
 679      switch (type) {
 680        case "ja4":
 681          analysis = JA4Analyzer.parseJA4(fingerprint);
 682          if (options.db) dbResults = db.lookupJA4(fingerprint);
 683          break;
 684        case "ja4s":
 685          analysis = JA4Analyzer.parseJA4S(fingerprint);
 686          if (options.db) dbResults = db.lookupJA4S(fingerprint);
 687          break;
 688        case "ja4h":
 689          analysis = JA4Analyzer.parseJA4H(fingerprint);
 690          if (options.db) dbResults = db.lookupJA4H(fingerprint);
 691          break;
 692        case "ja4x":
 693          analysis = JA4Analyzer.parseJA4X(fingerprint);
 694          if (options.db) dbResults = db.lookupJA4X(fingerprint);
 695          break;
 696        case "ja4t":
 697          analysis = JA4Analyzer.parseJA4T(fingerprint);
 698          if (options.db) dbResults = db.lookupJA4T(fingerprint);
 699          break;
 700        default:
 701          throw new Error(`Unknown fingerprint type: ${type}`);
 702      }
 703
 704      spinner.succeed("Analysis complete!");
 705
 706      if (options.json) {
 707        console.log(
 708          JSON.stringify(
 709            {
 710              fingerprint,
 711              type: type.toUpperCase(),
 712              analysis,
 713              database: {
 714                count: dbResults.length,
 715                results: dbResults,
 716              },
 717            },
 718            null,
 719            2,
 720          ),
 721        );
 722      } else {
 723        // Pretty output
 724        console.log(chalk.cyan(`\nšŸ“Š ${type.toUpperCase()} Analysis`));
 725        console.log(chalk.gray("─".repeat(50)));
 726        console.log(chalk.yellow("Fingerprint:"), fingerprint);
 727        console.log(chalk.yellow("Format:"), analysis.format);
 728
 729        if (analysis.human_readable) {
 730          console.log(chalk.yellow("Summary:"), analysis.human_readable);
 731        }
 732
 733        console.log(chalk.cyan("\nšŸ” Breakdown:"));
 734        Object.entries(analysis.breakdown).forEach(([part, data]) => {
 735          console.log(chalk.magenta(`  ${part.toUpperCase()}:`), data.raw);
 736          if (options.verbose) {
 737            Object.entries(data).forEach(([key, value]) => {
 738              if (key !== "raw" && value !== undefined) {
 739                console.log(chalk.gray(`    ${key}:`), value);
 740              }
 741            });
 742          }
 743        });
 744
 745        if (analysis.use_cases) {
 746          console.log(chalk.cyan("\nšŸ’” Use Cases:"));
 747          analysis.use_cases.forEach((useCase) => {
 748            console.log(chalk.green(`  • ${useCase}`));
 749          });
 750        }
 751
 752        if (dbResults.length > 0) {
 753          console.log(chalk.cyan(`\nšŸ—„ļø  Database Results (${dbResults.length} found):`));
 754          dbResults.slice(0, 5).forEach((result, i) => {
 755            console.log(chalk.green(`  ${i + 1}.`), result.application || "Unknown Application");
 756            if (result.os) console.log(chalk.gray(`     OS: ${result.os}`));
 757            if (result.verified) console.log(chalk.blue("     āœ“ Verified"));
 758          });
 759          if (dbResults.length > 5) {
 760            console.log(chalk.gray(`  ... and ${dbResults.length - 5} more`));
 761          }
 762        } else if (options.db) {
 763          console.log(chalk.yellow("\nšŸ—„ļø  No database matches found"));
 764        }
 765      }
 766    } catch (error) {
 767      spinner.fail("Analysis failed");
 768      console.error(chalk.red("Error:"), (error as Error).message);
 769      process.exit(1);
 770    }
 771  });
 772
 773program
 774  .command("compare")
 775  .description("Compare two JA4 fingerprints")
 776  .argument("<fingerprint1>", "First fingerprint")
 777  .argument("<fingerprint2>", "Second fingerprint")
 778  .option("-j, --json", "Output as JSON")
 779  .action((fp1: string, fp2: string, options) => {
 780    try {
 781      const comparison = JA4Analyzer.compareFingerprints(fp1, fp2);
 782
 783      if (options.json) {
 784        console.log(JSON.stringify(comparison, null, 2));
 785      } else {
 786        console.log(chalk.cyan("\nšŸ” Fingerprint Comparison"));
 787        console.log(chalk.gray("─".repeat(50)));
 788
 789        if (comparison.identical) {
 790          console.log(chalk.green("āœ… Fingerprints are identical!"));
 791        } else {
 792          console.log(chalk.yellow("āš ļø  Fingerprints differ"));
 793        }
 794
 795        console.log(chalk.yellow("\nAnalysis:"), comparison.analysis);
 796
 797        if (comparison.similarities.length > 0) {
 798          console.log(chalk.cyan("\nāœ… Similarities:"));
 799          comparison.similarities.forEach((sim) => {
 800            console.log(chalk.green(`  ${sim.part}: ${sim.value}`));
 801          });
 802        }
 803
 804        if (comparison.differences.length > 0) {
 805          console.log(chalk.cyan("\nāŒ Differences:"));
 806          comparison.differences.forEach((diff) => {
 807            console.log(chalk.red(`  ${diff.part}:`));
 808            console.log(chalk.gray(`    FP1: ${diff.fp1}`));
 809            console.log(chalk.gray(`    FP2: ${diff.fp2}`));
 810            console.log(chalk.gray(`    Note: ${diff.note}`));
 811          });
 812        }
 813      }
 814    } catch (error) {
 815      console.error(chalk.red("Error:"), (error as Error).message);
 816      process.exit(1);
 817    }
 818  });
 819
 820program
 821  .command("search")
 822  .description("Search the JA4 database")
 823  .argument("<query>", "Search term")
 824  .option("-t, --type <type>", "Search type (app or os)", "app")
 825  .option("-l, --limit <number>", "Limit results", "50")
 826  .option("-j, --json", "Output as JSON")
 827  .action(async (query: string, options) => {
 828    const spinner = ora("Searching database...").start();
 829
 830    try {
 831      await db.loadDatabase();
 832
 833      let results: SearchResult;
 834      const limit = parseInt(options.limit);
 835
 836      if (options.type === "os") {
 837        results = db.searchByOS(query, limit);
 838      } else {
 839        results = db.searchByApplication(query, limit);
 840      }
 841
 842      spinner.succeed(`Found ${results.count} results`);
 843
 844      if (options.json) {
 845        console.log(JSON.stringify(results, null, 2));
 846      } else {
 847        console.log(chalk.cyan(`\nšŸ” Search Results for "${query}"`));
 848        console.log(chalk.gray("─".repeat(50)));
 849        console.log(chalk.yellow(`Found ${results.count} matches\n`));
 850
 851        results.results.forEach((result, i) => {
 852          console.log(chalk.green(`${i + 1}.`), result.application || "Unknown Application");
 853          if (result.os) console.log(chalk.gray(`   OS: ${result.os}`));
 854          if (result.ja4) console.log(chalk.blue(`   JA4: ${result.ja4}`));
 855          if (result.verified) console.log(chalk.magenta("   āœ“ Verified"));
 856          console.log();
 857        });
 858      }
 859    } catch (error) {
 860      spinner.fail("Search failed");
 861      console.error(chalk.red("Error:"), (error as Error).message);
 862      process.exit(1);
 863    }
 864  });
 865
 866program
 867  .command("stats")
 868  .description("Show database statistics")
 869  .option("-j, --json", "Output as JSON")
 870  .action(async (options) => {
 871    const spinner = ora("Loading database statistics...").start();
 872
 873    try {
 874      await db.loadDatabase();
 875      const stats = db.getStatistics();
 876
 877      spinner.succeed("Statistics loaded");
 878
 879      if (options.json) {
 880        console.log(JSON.stringify(stats, null, 2));
 881      } else {
 882        console.log(chalk.cyan("\nšŸ“Š Database Statistics"));
 883        console.log(chalk.gray("─".repeat(50)));
 884        console.log(chalk.yellow("Status:"), stats.status);
 885        console.log(chalk.yellow("Total Records:"), stats.total_records.toLocaleString());
 886        console.log(chalk.yellow("Last Update:"), new Date(stats.last_update).toLocaleString());
 887
 888        console.log(chalk.cyan("\nšŸ”¢ Fingerprint Counts:"));
 889        console.log(chalk.green("  JA4:"), stats.ja4_count.toLocaleString());
 890        console.log(chalk.green("  JA4S:"), stats.ja4s_count.toLocaleString());
 891        console.log(chalk.green("  JA4H:"), stats.ja4h_count.toLocaleString());
 892        console.log(chalk.green("  JA4X:"), stats.ja4x_count.toLocaleString());
 893        console.log(chalk.green("  JA4T:"), stats.ja4t_count.toLocaleString());
 894
 895        console.log(chalk.cyan("\nāœ… Quality Metrics:"));
 896        console.log(chalk.blue("  Verified Records:"), stats.verified_count.toLocaleString());
 897        console.log(chalk.blue("  Unique Applications:"), stats.applications.toLocaleString());
 898        console.log(chalk.blue("  Unique Operating Systems:"), stats.operating_systems.toLocaleString());
 899      }
 900    } catch (error) {
 901      spinner.fail("Failed to load statistics");
 902      console.error(chalk.red("Error:"), (error as Error).message);
 903      process.exit(1);
 904    }
 905  });
 906
 907program
 908  .command("batch")
 909  .description("Analyze multiple fingerprints from a file")
 910  .argument("<file>", "File containing fingerprints (one per line)")
 911  .option("-t, --type <type>", "Fingerprint type", "ja4")
 912  .option("-o, --output <file>", "Output file (JSON format)")
 913  .option("--no-db", "Skip database lookups")
 914  .action(async (inputFile: string, options) => {
 915    const spinner = ora("Processing batch file...").start();
 916
 917    try {
 918      if (!existsSync(inputFile)) {
 919        throw new Error(`Input file not found: ${inputFile}`);
 920      }
 921
 922      const content = readFileSync(inputFile, "utf8");
 923      const fingerprints = content.split("\n").filter((line) => line.trim());
 924
 925      if (options.db) {
 926        spinner.text = "Loading database...";
 927        await db.loadDatabase();
 928      }
 929
 930      const results = [];
 931      let processed = 0;
 932
 933      for (const fingerprint of fingerprints) {
 934        try {
 935          processed++;
 936          spinner.text = `Processing ${processed}/${fingerprints.length}...`;
 937
 938          let analysis: JA4Analysis;
 939          let dbResults: DatabaseResult[] = [];
 940
 941          switch (options.type.toLowerCase()) {
 942            case "ja4":
 943              analysis = JA4Analyzer.parseJA4(fingerprint.trim());
 944              if (options.db) dbResults = db.lookupJA4(fingerprint.trim());
 945              break;
 946            case "ja4s":
 947              analysis = JA4Analyzer.parseJA4S(fingerprint.trim());
 948              if (options.db) dbResults = db.lookupJA4S(fingerprint.trim());
 949              break;
 950            case "ja4h":
 951              analysis = JA4Analyzer.parseJA4H(fingerprint.trim());
 952              if (options.db) dbResults = db.lookupJA4H(fingerprint.trim());
 953              break;
 954            case "ja4x":
 955              analysis = JA4Analyzer.parseJA4X(fingerprint.trim());
 956              if (options.db) dbResults = db.lookupJA4X(fingerprint.trim());
 957              break;
 958            case "ja4t":
 959              analysis = JA4Analyzer.parseJA4T(fingerprint.trim());
 960              if (options.db) dbResults = db.lookupJA4T(fingerprint.trim());
 961              break;
 962            default:
 963              throw new Error(`Unknown fingerprint type: ${options.type}`);
 964          }
 965
 966          results.push({
 967            fingerprint: fingerprint.trim(),
 968            type: options.type.toUpperCase(),
 969            analysis,
 970            database: {
 971              count: dbResults.length,
 972              results: dbResults,
 973            },
 974          });
 975        } catch (error) {
 976          results.push({
 977            fingerprint: fingerprint.trim(),
 978            type: options.type.toUpperCase(),
 979            error: (error as Error).message,
 980          });
 981        }
 982      }
 983
 984      spinner.succeed(`Processed ${fingerprints.length} fingerprints`);
 985
 986      const output = {
 987        summary: {
 988          total: fingerprints.length,
 989          successful: results.filter((r) => !r.error).length,
 990          failed: results.filter((r) => r.error).length,
 991          timestamp: new Date().toISOString(),
 992        },
 993        results,
 994      };
 995
 996      if (options.output) {
 997        await fs.writeFile(options.output, JSON.stringify(output, null, 2));
 998        console.log(chalk.green(`Results saved to: ${options.output}`));
 999      } else {
1000        console.log(JSON.stringify(output, null, 2));
1001      }
1002    } catch (error) {
1003      spinner.fail("Batch processing failed");
1004      console.error(chalk.red("Error:"), (error as Error).message);
1005      process.exit(1);
1006    }
1007  });
1008
1009// Global error handler
1010process.on("unhandledRejection", (error) => {
1011  console.error(chalk.red("Unhandled error:"), error);
1012  process.exit(1);
1013});
1014
1015// Parse command line arguments
1016program.parse();