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();