ja4plus 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ja4plus/__init__.py +30 -0
- ja4plus/collector.py +194 -0
- ja4plus/fingerprinters/__init__.py +23 -0
- ja4plus/fingerprinters/base.py +44 -0
- ja4plus/fingerprinters/ja4.py +284 -0
- ja4plus/fingerprinters/ja4h.py +131 -0
- ja4plus/fingerprinters/ja4l.py +265 -0
- ja4plus/fingerprinters/ja4s.py +144 -0
- ja4plus/fingerprinters/ja4ssh.py +402 -0
- ja4plus/fingerprinters/ja4t.py +88 -0
- ja4plus/fingerprinters/ja4ts.py +90 -0
- ja4plus/fingerprinters/ja4x.py +300 -0
- ja4plus/utils/__init__.py +3 -0
- ja4plus/utils/http_utils.py +181 -0
- ja4plus/utils/ssh_utils.py +221 -0
- ja4plus/utils/tls_utils.py +387 -0
- ja4plus/utils/x509_utils.py +352 -0
- ja4plus-0.2.0.dist-info/METADATA +178 -0
- ja4plus-0.2.0.dist-info/RECORD +22 -0
- ja4plus-0.2.0.dist-info/WHEEL +5 -0
- ja4plus-0.2.0.dist-info/licenses/LICENSE +28 -0
- ja4plus-0.2.0.dist-info/top_level.txt +1 -0
ja4plus/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JA4+ - Network Fingerprinting Library
|
|
3
|
+
|
|
4
|
+
A Python implementation of the JA4+ network fingerprinting methods
|
|
5
|
+
created by FoxIO LLC. Supports TLS, TCP, HTTP, SSH, and X.509
|
|
6
|
+
fingerprinting for network security monitoring and traffic analysis.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ja4plus.fingerprinters.ja4 import JA4Fingerprinter
|
|
10
|
+
from ja4plus.fingerprinters.ja4s import JA4SFingerprinter
|
|
11
|
+
from ja4plus.fingerprinters.ja4h import JA4HFingerprinter
|
|
12
|
+
from ja4plus.fingerprinters.ja4l import JA4LFingerprinter
|
|
13
|
+
from ja4plus.fingerprinters.ja4x import JA4XFingerprinter
|
|
14
|
+
from ja4plus.fingerprinters.ja4ssh import JA4SSHFingerprinter
|
|
15
|
+
from ja4plus.fingerprinters.ja4t import JA4TFingerprinter
|
|
16
|
+
from ja4plus.fingerprinters.ja4ts import JA4TSFingerprinter
|
|
17
|
+
|
|
18
|
+
# Function-based API
|
|
19
|
+
from ja4plus.fingerprinters.ja4 import generate_ja4
|
|
20
|
+
from ja4plus.fingerprinters.ja4s import generate_ja4s
|
|
21
|
+
from ja4plus.fingerprinters.ja4h import generate_ja4h
|
|
22
|
+
from ja4plus.fingerprinters.ja4l import generate_ja4l
|
|
23
|
+
from ja4plus.fingerprinters.ja4x import generate_ja4x
|
|
24
|
+
from ja4plus.fingerprinters.ja4ssh import generate_ja4ssh
|
|
25
|
+
from ja4plus.fingerprinters.ja4t import generate_ja4t
|
|
26
|
+
from ja4plus.fingerprinters.ja4ts import generate_ja4ts
|
|
27
|
+
|
|
28
|
+
__version__ = "0.2.0"
|
|
29
|
+
__author__ = "ja4plus contributors"
|
|
30
|
+
__license__ = "BSD-3-Clause"
|
ja4plus/collector.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
JA4+ collector for Elastic Agent integration.
|
|
4
|
+
Captures network traffic and outputs JA4+ fingerprints as JSON.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import signal
|
|
12
|
+
from scapy.all import sniff
|
|
13
|
+
|
|
14
|
+
from ja4plus.fingerprinters.ja4 import JA4Fingerprinter
|
|
15
|
+
from ja4plus.fingerprinters.ja4s import JA4SFingerprinter
|
|
16
|
+
from ja4plus.fingerprinters.ja4h import JA4HFingerprinter
|
|
17
|
+
from ja4plus.fingerprinters.ja4t import JA4TFingerprinter
|
|
18
|
+
from ja4plus.fingerprinters.ja4ts import JA4TSFingerprinter
|
|
19
|
+
from ja4plus.fingerprinters.ja4x import JA4XFingerprinter
|
|
20
|
+
from ja4plus.fingerprinters.ja4ssh import JA4SSHFingerprinter
|
|
21
|
+
|
|
22
|
+
# Global variables for signal handling
|
|
23
|
+
packet_count = 0
|
|
24
|
+
max_packets = 0
|
|
25
|
+
start_time = 0
|
|
26
|
+
timeout_seconds = 0
|
|
27
|
+
|
|
28
|
+
def signal_handler(signum, frame):
|
|
29
|
+
"""Handle termination signals gracefully"""
|
|
30
|
+
sys.stderr.write("Received signal, shutting down gracefully...\n")
|
|
31
|
+
sys.exit(0)
|
|
32
|
+
|
|
33
|
+
def main():
|
|
34
|
+
"""Main collector function"""
|
|
35
|
+
global packet_count, max_packets, start_time, timeout_seconds
|
|
36
|
+
|
|
37
|
+
parser = argparse.ArgumentParser(description="JA4+ Network Fingerprinting Collector")
|
|
38
|
+
parser.add_argument("--interface", "-i", default="any", help="Network interface to capture on")
|
|
39
|
+
parser.add_argument("--filter", "-f", default="tcp or udp", help="BPF filter expression")
|
|
40
|
+
parser.add_argument("--fingerprinters", default="ja4,ja4s,ja4h,ja4t,ja4ts,ja4x,ja4ssh",
|
|
41
|
+
help="Comma-separated list of fingerprinters to enable")
|
|
42
|
+
parser.add_argument("--max-packets", type=int, default=0,
|
|
43
|
+
help="Maximum packets to process (0 = unlimited)")
|
|
44
|
+
parser.add_argument("--timeout", type=float, default=0,
|
|
45
|
+
help="Timeout in seconds (0 = no timeout)")
|
|
46
|
+
parser.add_argument("--output", choices=["json", "text"], default="json",
|
|
47
|
+
help="Output format")
|
|
48
|
+
|
|
49
|
+
args = parser.parse_args()
|
|
50
|
+
|
|
51
|
+
# Set up signal handlers
|
|
52
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
53
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
54
|
+
|
|
55
|
+
# Initialize global variables
|
|
56
|
+
max_packets = args.max_packets
|
|
57
|
+
timeout_seconds = args.timeout
|
|
58
|
+
start_time = time.time()
|
|
59
|
+
|
|
60
|
+
# Initialize enabled fingerprinters
|
|
61
|
+
available_fingerprinters = {
|
|
62
|
+
"ja4": JA4Fingerprinter(),
|
|
63
|
+
"ja4s": JA4SFingerprinter(),
|
|
64
|
+
"ja4h": JA4HFingerprinter(),
|
|
65
|
+
"ja4t": JA4TFingerprinter(),
|
|
66
|
+
"ja4ts": JA4TSFingerprinter(),
|
|
67
|
+
"ja4x": JA4XFingerprinter(),
|
|
68
|
+
"ja4ssh": JA4SSHFingerprinter()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
enabled_fps = args.fingerprinters.split(",")
|
|
72
|
+
fingerprinters = {}
|
|
73
|
+
for name in enabled_fps:
|
|
74
|
+
name = name.strip()
|
|
75
|
+
if name in available_fingerprinters:
|
|
76
|
+
fingerprinters[name] = available_fingerprinters[name]
|
|
77
|
+
else:
|
|
78
|
+
sys.stderr.write(f"Warning: Unknown fingerprinter '{name}'\n")
|
|
79
|
+
|
|
80
|
+
if not fingerprinters:
|
|
81
|
+
sys.stderr.write("Error: No valid fingerprinters enabled\n")
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
def process_packet(packet):
|
|
85
|
+
"""Process packet with enabled fingerprinters and output results"""
|
|
86
|
+
global packet_count
|
|
87
|
+
|
|
88
|
+
# Check packet limit
|
|
89
|
+
if max_packets > 0 and packet_count >= max_packets:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# Check timeout
|
|
93
|
+
if timeout_seconds > 0 and (time.time() - start_time) >= timeout_seconds:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
packet_count += 1
|
|
97
|
+
|
|
98
|
+
results = {
|
|
99
|
+
"@timestamp": int(time.time() * 1000), # Elasticsearch expects milliseconds
|
|
100
|
+
"ja4plus": {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Apply each enabled fingerprinter
|
|
104
|
+
for name, fp in fingerprinters.items():
|
|
105
|
+
try:
|
|
106
|
+
fingerprint = fp.process_packet(packet)
|
|
107
|
+
if fingerprint:
|
|
108
|
+
results["ja4plus"][name] = fingerprint
|
|
109
|
+
except Exception as e:
|
|
110
|
+
sys.stderr.write(f"Error in {name} fingerprinter: {e}\n")
|
|
111
|
+
|
|
112
|
+
# Only output if we have fingerprints
|
|
113
|
+
if results["ja4plus"]:
|
|
114
|
+
# Add network metadata - properly extract IP addresses
|
|
115
|
+
try:
|
|
116
|
+
from scapy.all import IP, IPv6
|
|
117
|
+
|
|
118
|
+
# Look for IP layer (IPv4 or IPv6)
|
|
119
|
+
if packet.haslayer(IP):
|
|
120
|
+
ip_layer = packet[IP]
|
|
121
|
+
results["source"] = {"ip": ip_layer.src}
|
|
122
|
+
results["destination"] = {"ip": ip_layer.dst}
|
|
123
|
+
elif packet.haslayer(IPv6):
|
|
124
|
+
ip_layer = packet[IPv6]
|
|
125
|
+
results["source"] = {"ip": ip_layer.src}
|
|
126
|
+
results["destination"] = {"ip": ip_layer.dst}
|
|
127
|
+
elif hasattr(packet, "src") and hasattr(packet, "dst"):
|
|
128
|
+
# Fallback to packet-level src/dst (might be MAC addresses)
|
|
129
|
+
# Only use if they look like IP addresses
|
|
130
|
+
src_str = str(packet.src)
|
|
131
|
+
dst_str = str(packet.dst)
|
|
132
|
+
if ":" not in src_str or len(src_str.split(":")) <= 4: # Not a MAC address
|
|
133
|
+
results["source"] = {"ip": src_str}
|
|
134
|
+
results["destination"] = {"ip": dst_str}
|
|
135
|
+
|
|
136
|
+
# Add port information if available
|
|
137
|
+
from scapy.all import TCP, UDP
|
|
138
|
+
if packet.haslayer(TCP):
|
|
139
|
+
tcp_layer = packet[TCP]
|
|
140
|
+
if "source" in results:
|
|
141
|
+
results["source"]["port"] = tcp_layer.sport
|
|
142
|
+
if "destination" in results:
|
|
143
|
+
results["destination"]["port"] = tcp_layer.dport
|
|
144
|
+
elif packet.haslayer(UDP):
|
|
145
|
+
udp_layer = packet[UDP]
|
|
146
|
+
if "source" in results:
|
|
147
|
+
results["source"]["port"] = udp_layer.sport
|
|
148
|
+
if "destination" in results:
|
|
149
|
+
results["destination"]["port"] = udp_layer.dport
|
|
150
|
+
|
|
151
|
+
except Exception as e:
|
|
152
|
+
sys.stderr.write(f"Error extracting network metadata: {e}\n")
|
|
153
|
+
|
|
154
|
+
# Add collection metadata
|
|
155
|
+
results["ja4plus"]["interface"] = args.interface
|
|
156
|
+
results["ja4plus"]["filter"] = args.filter
|
|
157
|
+
results["ja4plus"]["collection_timestamp"] = int(time.time() * 1000)
|
|
158
|
+
|
|
159
|
+
# Output based on format
|
|
160
|
+
if args.output == "json":
|
|
161
|
+
print(json.dumps(results))
|
|
162
|
+
else:
|
|
163
|
+
print(f"Packet {packet_count}: {results}")
|
|
164
|
+
|
|
165
|
+
sys.stdout.flush()
|
|
166
|
+
|
|
167
|
+
# Stop if we've reached the packet limit
|
|
168
|
+
if max_packets > 0 and packet_count >= max_packets:
|
|
169
|
+
sys.stderr.write(f"Reached packet limit of {max_packets}, stopping...\n")
|
|
170
|
+
sys.exit(0)
|
|
171
|
+
|
|
172
|
+
# Stop if we've reached the timeout
|
|
173
|
+
if timeout_seconds > 0 and (time.time() - start_time) >= timeout_seconds:
|
|
174
|
+
sys.stderr.write(f"Reached timeout of {timeout_seconds} seconds, stopping...\n")
|
|
175
|
+
sys.exit(0)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
sys.stderr.write(f"Starting JA4+ collection on interface '{args.interface}' with filter '{args.filter}'\n")
|
|
179
|
+
sys.stderr.write(f"Enabled fingerprinters: {', '.join(fingerprinters.keys())}\n")
|
|
180
|
+
|
|
181
|
+
# Start sniffing
|
|
182
|
+
sniff(prn=process_packet,
|
|
183
|
+
filter=args.filter,
|
|
184
|
+
iface=args.interface if args.interface != "any" else None,
|
|
185
|
+
store=0)
|
|
186
|
+
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
sys.stderr.write("Interrupted by user\n")
|
|
189
|
+
except Exception as e:
|
|
190
|
+
sys.stderr.write(f"Error during packet capture: {e}\n")
|
|
191
|
+
sys.exit(1)
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
main()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JA4+ fingerprinters for various protocols and traffic types.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .ja4 import JA4Fingerprinter
|
|
6
|
+
from .ja4s import JA4SFingerprinter
|
|
7
|
+
from .ja4h import JA4HFingerprinter
|
|
8
|
+
from .ja4l import JA4LFingerprinter
|
|
9
|
+
from .ja4x import JA4XFingerprinter
|
|
10
|
+
from .ja4ssh import JA4SSHFingerprinter
|
|
11
|
+
from .ja4t import JA4TFingerprinter
|
|
12
|
+
from .ja4ts import JA4TSFingerprinter
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
'JA4Fingerprinter',
|
|
16
|
+
'JA4SFingerprinter',
|
|
17
|
+
'JA4HFingerprinter',
|
|
18
|
+
'JA4LFingerprinter',
|
|
19
|
+
'JA4XFingerprinter',
|
|
20
|
+
'JA4SSHFingerprinter',
|
|
21
|
+
'JA4TFingerprinter',
|
|
22
|
+
'JA4TSFingerprinter'
|
|
23
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base fingerprinter class for JA4+ fingerprinters.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
class BaseFingerprinter:
|
|
6
|
+
"""Base class for all JA4+ fingerprinters."""
|
|
7
|
+
|
|
8
|
+
def __init__(self):
|
|
9
|
+
"""Initialize the fingerprinter."""
|
|
10
|
+
self.fingerprints = []
|
|
11
|
+
|
|
12
|
+
def process_packet(self, packet):
|
|
13
|
+
"""
|
|
14
|
+
Process a packet and extract fingerprint if applicable.
|
|
15
|
+
This method should be overridden by subclasses.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
packet: A network packet to analyze
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
The extracted fingerprint if successful, None otherwise
|
|
22
|
+
"""
|
|
23
|
+
raise NotImplementedError("Subclasses must implement this method.")
|
|
24
|
+
|
|
25
|
+
def add_fingerprint(self, fingerprint, packet):
|
|
26
|
+
"""
|
|
27
|
+
Add a fingerprint to the collection.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
fingerprint: The extracted fingerprint
|
|
31
|
+
packet: The packet that generated this fingerprint
|
|
32
|
+
"""
|
|
33
|
+
self.fingerprints.append({
|
|
34
|
+
'fingerprint': fingerprint,
|
|
35
|
+
'packet': packet
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
def get_fingerprints(self):
|
|
39
|
+
"""Return all collected fingerprints."""
|
|
40
|
+
return self.fingerprints
|
|
41
|
+
|
|
42
|
+
def reset(self):
|
|
43
|
+
"""Reset the fingerprinter state."""
|
|
44
|
+
self.fingerprints = []
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JA4 TLS Client Hello Fingerprinting implementation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from scapy.all import TCP, UDP, Raw, IP
|
|
7
|
+
from ja4plus.utils.tls_utils import extract_tls_info, is_grease_value
|
|
8
|
+
from ja4plus.fingerprinters.base import BaseFingerprinter
|
|
9
|
+
|
|
10
|
+
def generate_ja4(tls_info):
|
|
11
|
+
"""
|
|
12
|
+
Generate a JA4 fingerprint from TLS Client Hello info.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
tls_info: A dictionary with TLS handshake information
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
A JA4 fingerprint string or None if not applicable
|
|
19
|
+
"""
|
|
20
|
+
if not tls_info or tls_info.get('type') != 'client_hello':
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
# Determine protocol type (q=QUIC, d=DTLS, t=TLS over TCP)
|
|
25
|
+
proto = 'q' if tls_info.get('is_quic') else 'd' if tls_info.get('is_dtls') else 't'
|
|
26
|
+
|
|
27
|
+
# Get TLS version - prioritize supported_versions extension (0x002b)
|
|
28
|
+
version = tls_info.get('version')
|
|
29
|
+
supported_versions = tls_info.get('supported_versions', [])
|
|
30
|
+
|
|
31
|
+
# Filter out GREASE values
|
|
32
|
+
supported_versions = [v for v in supported_versions if not is_grease_value(v)]
|
|
33
|
+
|
|
34
|
+
if supported_versions:
|
|
35
|
+
# Use highest supported version
|
|
36
|
+
version = max(supported_versions)
|
|
37
|
+
|
|
38
|
+
# Convert version to string format
|
|
39
|
+
if version == 0x0304: # TLS 1.3
|
|
40
|
+
version_str = '13'
|
|
41
|
+
elif version == 0x0303: # TLS 1.2
|
|
42
|
+
version_str = '12'
|
|
43
|
+
elif version == 0x0302: # TLS 1.1
|
|
44
|
+
version_str = '11'
|
|
45
|
+
elif version == 0x0301: # TLS 1.0
|
|
46
|
+
version_str = '10'
|
|
47
|
+
elif version == 0x0300: # SSL 3.0
|
|
48
|
+
version_str = 's3'
|
|
49
|
+
elif version == 0x0200: # SSL 2.0
|
|
50
|
+
version_str = 's2'
|
|
51
|
+
elif version == 0xfeff: # DTLS 1.0
|
|
52
|
+
version_str = 'd1'
|
|
53
|
+
elif version == 0xfefd: # DTLS 1.2
|
|
54
|
+
version_str = 'd2'
|
|
55
|
+
elif version == 0xfefc: # DTLS 1.3
|
|
56
|
+
version_str = 'd3'
|
|
57
|
+
else:
|
|
58
|
+
version_str = '00'
|
|
59
|
+
|
|
60
|
+
# SNI type - 'd' if SNI exists, 'i' if not
|
|
61
|
+
sni = tls_info.get('sni')
|
|
62
|
+
sni_type = 'd' if sni else 'i'
|
|
63
|
+
|
|
64
|
+
# Get cipher suites - filter out GREASE values
|
|
65
|
+
ciphers = [c for c in tls_info.get('ciphers', []) if not is_grease_value(c)]
|
|
66
|
+
cipher_count = min(len(ciphers), 99) # Cap at 99
|
|
67
|
+
cipher_count_str = f"{cipher_count:02d}"
|
|
68
|
+
|
|
69
|
+
# Get extensions - filter out GREASE values
|
|
70
|
+
extensions = [e for e in tls_info.get('extensions', []) if not is_grease_value(e)]
|
|
71
|
+
ext_count = min(len(extensions), 99) # Cap at 99
|
|
72
|
+
ext_count_str = f"{ext_count:02d}"
|
|
73
|
+
|
|
74
|
+
# Get ALPN value - extract first and last character
|
|
75
|
+
# Per FoxIO spec: first+last alphanumeric char of first ALPN protocol
|
|
76
|
+
# Non-ASCII (ord > 127) -> '99'
|
|
77
|
+
alpn_protocols = tls_info.get('alpn_protocols', [])
|
|
78
|
+
if not alpn_protocols:
|
|
79
|
+
alpn_value = '00'
|
|
80
|
+
else:
|
|
81
|
+
first_alpn = alpn_protocols[0]
|
|
82
|
+
|
|
83
|
+
if not first_alpn:
|
|
84
|
+
alpn_value = '00'
|
|
85
|
+
else:
|
|
86
|
+
# FoxIO spec: if first char is non-ASCII, use '99'
|
|
87
|
+
if ord(first_alpn[0]) > 127:
|
|
88
|
+
alpn_value = '99'
|
|
89
|
+
elif len(first_alpn) == 1:
|
|
90
|
+
alpn_value = first_alpn[0] + first_alpn[0]
|
|
91
|
+
else:
|
|
92
|
+
alpn_value = f"{first_alpn[0]}{first_alpn[-1]}"
|
|
93
|
+
|
|
94
|
+
# Form part_a of the fingerprint
|
|
95
|
+
part_a = f"{proto}{version_str}{sni_type}{cipher_count_str}{ext_count_str}{alpn_value}"
|
|
96
|
+
|
|
97
|
+
# Generate cipher hash - sort ciphers first
|
|
98
|
+
if ciphers:
|
|
99
|
+
sorted_ciphers = sorted(ciphers)
|
|
100
|
+
cipher_str = ','.join([f"{c:04x}" for c in sorted_ciphers])
|
|
101
|
+
cipher_hash = hashlib.sha256(cipher_str.encode()).hexdigest()[:12]
|
|
102
|
+
else:
|
|
103
|
+
cipher_hash = '000000000000'
|
|
104
|
+
|
|
105
|
+
# Generate extension hash
|
|
106
|
+
# 1. Remove SNI (0x0000) and ALPN (0x0010) from extensions for hashing
|
|
107
|
+
filtered_extensions = [e for e in extensions if e != 0x0000 and e != 0x0010]
|
|
108
|
+
|
|
109
|
+
# 2. Sort filtered extensions
|
|
110
|
+
sorted_extensions = sorted(filtered_extensions)
|
|
111
|
+
|
|
112
|
+
# 3. Get signature algorithms in original order
|
|
113
|
+
sig_algs = tls_info.get('signature_algorithms', [])
|
|
114
|
+
|
|
115
|
+
# 4. Form extension string - sorted extensions + underscore + sig algorithms if present
|
|
116
|
+
ext_str = ','.join([f"{e:04x}" for e in sorted_extensions])
|
|
117
|
+
if sig_algs:
|
|
118
|
+
sig_alg_str = ','.join([f"{s:04x}" for s in sig_algs])
|
|
119
|
+
ext_str = f"{ext_str}_{sig_alg_str}"
|
|
120
|
+
|
|
121
|
+
# 5. Generate extension hash
|
|
122
|
+
if ext_str:
|
|
123
|
+
ext_hash = hashlib.sha256(ext_str.encode()).hexdigest()[:12]
|
|
124
|
+
else:
|
|
125
|
+
ext_hash = '000000000000'
|
|
126
|
+
|
|
127
|
+
# Form the complete JA4 fingerprint
|
|
128
|
+
ja4 = f"{part_a}_{cipher_hash}_{ext_hash}"
|
|
129
|
+
|
|
130
|
+
return ja4
|
|
131
|
+
|
|
132
|
+
except Exception:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def get_raw_fingerprint(tls_info, original_order=False):
|
|
136
|
+
"""
|
|
137
|
+
Generate a raw JA4 fingerprint with all values visible.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
tls_info: A dictionary with TLS handshake information
|
|
141
|
+
original_order: Whether to maintain original ordering (True) or sort (False)
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
A raw JA4 fingerprint string or None if not applicable
|
|
145
|
+
"""
|
|
146
|
+
if not tls_info or tls_info.get('type') != 'client_hello':
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
# Get the same components as in generate_ja4
|
|
151
|
+
proto = 'q' if tls_info.get('is_quic') else 'd' if tls_info.get('is_dtls') else 't'
|
|
152
|
+
|
|
153
|
+
# Version
|
|
154
|
+
version = tls_info.get('version')
|
|
155
|
+
supported_versions = tls_info.get('supported_versions', [])
|
|
156
|
+
supported_versions = [v for v in supported_versions if not is_grease_value(v)]
|
|
157
|
+
|
|
158
|
+
if supported_versions:
|
|
159
|
+
version = max(supported_versions)
|
|
160
|
+
|
|
161
|
+
# Map version to string format (same as in generate_ja4)
|
|
162
|
+
if version == 0x0304: # TLS 1.3
|
|
163
|
+
version_str = '13'
|
|
164
|
+
elif version == 0x0303: # TLS 1.2
|
|
165
|
+
version_str = '12'
|
|
166
|
+
elif version == 0x0302: # TLS 1.1
|
|
167
|
+
version_str = '11'
|
|
168
|
+
elif version == 0x0301: # TLS 1.0
|
|
169
|
+
version_str = '10'
|
|
170
|
+
elif version == 0x0300: # SSL 3.0
|
|
171
|
+
version_str = 's3'
|
|
172
|
+
elif version == 0x0200: # SSL 2.0
|
|
173
|
+
version_str = 's2'
|
|
174
|
+
elif version == 0xfeff: # DTLS 1.0
|
|
175
|
+
version_str = 'd1'
|
|
176
|
+
elif version == 0xfefd: # DTLS 1.2
|
|
177
|
+
version_str = 'd2'
|
|
178
|
+
elif version == 0xfefc: # DTLS 1.3
|
|
179
|
+
version_str = 'd3'
|
|
180
|
+
else:
|
|
181
|
+
version_str = '00'
|
|
182
|
+
|
|
183
|
+
# SNI
|
|
184
|
+
sni = tls_info.get('sni')
|
|
185
|
+
sni_type = 'd' if sni else 'i'
|
|
186
|
+
|
|
187
|
+
# Ciphers - filter GREASE
|
|
188
|
+
ciphers = [c for c in tls_info.get('ciphers', []) if not is_grease_value(c)]
|
|
189
|
+
cipher_count = min(len(ciphers), 99)
|
|
190
|
+
cipher_count_str = f"{cipher_count:02d}"
|
|
191
|
+
|
|
192
|
+
# Extensions - filter GREASE
|
|
193
|
+
extensions = [e for e in tls_info.get('extensions', []) if not is_grease_value(e)]
|
|
194
|
+
ext_count = min(len(extensions), 99)
|
|
195
|
+
ext_count_str = f"{ext_count:02d}"
|
|
196
|
+
|
|
197
|
+
# ALPN - same as in generate_ja4
|
|
198
|
+
alpn_protocols = tls_info.get('alpn_protocols', [])
|
|
199
|
+
if not alpn_protocols:
|
|
200
|
+
alpn_value = '00'
|
|
201
|
+
else:
|
|
202
|
+
first_alpn = alpn_protocols[0]
|
|
203
|
+
|
|
204
|
+
if not first_alpn:
|
|
205
|
+
alpn_value = '00'
|
|
206
|
+
elif ord(first_alpn[0]) > 127:
|
|
207
|
+
alpn_value = '99'
|
|
208
|
+
elif len(first_alpn) == 1:
|
|
209
|
+
alpn_value = first_alpn[0] + first_alpn[0]
|
|
210
|
+
else:
|
|
211
|
+
alpn_value = f"{first_alpn[0]}{first_alpn[-1]}"
|
|
212
|
+
|
|
213
|
+
# First part of fingerprint
|
|
214
|
+
part_a = f"{proto}{version_str}{sni_type}{cipher_count_str}{ext_count_str}{alpn_value}"
|
|
215
|
+
|
|
216
|
+
# Cipher list - either sorted or original
|
|
217
|
+
if original_order:
|
|
218
|
+
cipher_list = ','.join([f"{c:04x}" for c in tls_info.get('ciphers', []) if not is_grease_value(c)])
|
|
219
|
+
else:
|
|
220
|
+
cipher_list = ','.join([f"{c:04x}" for c in sorted(ciphers)])
|
|
221
|
+
|
|
222
|
+
# Extension list - either with or without SNI/ALPN based on original_order
|
|
223
|
+
if original_order:
|
|
224
|
+
ext_list = ','.join([f"{e:04x}" for e in tls_info.get('extensions', []) if not is_grease_value(e)])
|
|
225
|
+
else:
|
|
226
|
+
ext_list = ','.join([f"{e:04x}" for e in sorted([e for e in extensions if e != 0x0000 and e != 0x0010])])
|
|
227
|
+
|
|
228
|
+
# Signature algorithms
|
|
229
|
+
sig_algs = tls_info.get('signature_algorithms', [])
|
|
230
|
+
sig_alg_list = ','.join([f"{s:04x}" for s in sig_algs])
|
|
231
|
+
|
|
232
|
+
# Final format
|
|
233
|
+
if sig_algs:
|
|
234
|
+
if original_order:
|
|
235
|
+
raw_ja4 = f"{part_a}_{cipher_list}_{ext_list}_{sig_alg_list}"
|
|
236
|
+
else:
|
|
237
|
+
raw_ja4 = f"{part_a}_{cipher_list}_{ext_list}_{sig_alg_list}"
|
|
238
|
+
else:
|
|
239
|
+
raw_ja4 = f"{part_a}_{cipher_list}_{ext_list}"
|
|
240
|
+
|
|
241
|
+
# Add suffix to indicate format
|
|
242
|
+
if original_order:
|
|
243
|
+
return f"JA4_ro = {raw_ja4}"
|
|
244
|
+
else:
|
|
245
|
+
return f"JA4_r = {raw_ja4}"
|
|
246
|
+
|
|
247
|
+
except Exception:
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
class JA4Fingerprinter(BaseFingerprinter):
|
|
251
|
+
"""Fingerprinter for JA4 (TLS Client Hello)."""
|
|
252
|
+
|
|
253
|
+
def process_packet(self, packet):
|
|
254
|
+
"""Process a packet and extract JA4 fingerprint if applicable."""
|
|
255
|
+
# First extract TLS info from the packet
|
|
256
|
+
tls_info = extract_tls_info(packet)
|
|
257
|
+
|
|
258
|
+
if not tls_info:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
# Then generate JA4 from the extracted TLS info
|
|
262
|
+
fingerprint = generate_ja4(tls_info)
|
|
263
|
+
|
|
264
|
+
if fingerprint:
|
|
265
|
+
self.add_fingerprint(fingerprint, packet)
|
|
266
|
+
|
|
267
|
+
return fingerprint
|
|
268
|
+
|
|
269
|
+
def get_raw_fingerprint(self, packet, original_order=False):
|
|
270
|
+
"""
|
|
271
|
+
Get raw JA4 fingerprint with visible components.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
packet: A packet containing a TLS Client Hello
|
|
275
|
+
original_order: Whether to maintain original ordering
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Raw JA4 fingerprint string or None
|
|
279
|
+
"""
|
|
280
|
+
tls_info = extract_tls_info(packet)
|
|
281
|
+
if not tls_info:
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
return get_raw_fingerprint(tls_info, original_order)
|