ja4plus.__all__ names the interface the project promises. Version 1.0.0 promises that
a name in that list stays until version 2.0.0. A name absent from the list is not
promised, and the project may change it in any release.
import ja4plus
ja4plus.__all__ # the 25 promised names| Group | Names |
|---|---|
| Result and processor | FingerprintResult, Processor |
| Fingerprinter classes | JA4Fingerprinter, JA4SFingerprinter, JA4HFingerprinter, JA4LFingerprinter, JA4XFingerprinter, JA4SSHFingerprinter, JA4TFingerprinter, JA4TSFingerprinter, JA4DFingerprinter, JA4D6Fingerprinter |
| One-shot functions | generate_ja4, generate_ja4s, generate_ja4h, generate_ja4l, generate_ja4x, generate_ja4ssh, generate_ja4t, generate_ja4ts, generate_ja4d, generate_ja4d6 |
| Certificate helpers | compute_ja4x_from_der, compute_ja4x_from_pem |
| Version | __version__ |
A module states its own public names in its own __all__. ja4plus.types names
FingerprintResult, and ja4plus.processor names Processor and ProcessorStats.
Processor.stats returns dict[str, ProcessorStats], so a caller who annotates the
report imports the class from ja4plus.processor.
Four names of the top-level namespace are not promised. bind_loopback_ipv6 and
register_tunnel_dissectors are the two calls the package makes at import time, so a
caller needs neither name. __author__ and __license__ describe the project and not
the interface, and the distribution metadata carries the license.
The package ships a py.typed marker, and the wheel carries it. A caller who runs
mypy --strict against their own code therefore resolves the annotations of ja4plus.
from ja4plus import FingerprintResult
result = FingerprintResult(type="ja4", fingerprint="t13d1516h2_8daaf6152771_b0da82dd1658")
name: str = result.fingerprint # mypy reads `str`Verified against: https://peps.python.org/pep-0561/ (retrieved 2026-08-08).
All fingerprinters inherit from BaseFingerprinter and share a common interface.
| Method | Returns | Description |
|---|---|---|
process_packet(packet) |
str or None |
Process a scapy packet. Returns fingerprint string if one is generated. |
get_fingerprints() |
list[dict] |
Returns all collected fingerprints as {"fingerprint": str, ...} dicts. |
reset() |
None |
Clears all collected fingerprints and internal state. |
close_open_windows() |
list[dict] |
Emits the window every connection holds open, and returns the new entries. |
Call this method when the packet source ends. A file reader reaches the last packet, and a live capture stops. JA4SSH is the only method that holds a window, so every other fingerprinter returns an empty list.
A connection that sends no FIN+ACK packet holds its last window open, and no other rule
emits it. ssh2.pcapng carries 452 TCP packets on port 22 and no FIN+ACK packet, so this
method produces its second value, c36s52_c42s76_c51s2. #214 holds the ruling.
A window that holds no SSH packet emits nothing. A fingerprint of an empty window describes no traffic, and #97 declines the same value in the FoxIO Python reference.
The method emits the window and evicts no entry, so the state table holds the same keys after the call. A second call emits nothing, because the first call cleared the counters.
from ja4plus.fingerprinters.ja4ssh import JA4SSHFingerprinter
from scapy.all import PcapReader
fp = JA4SSHFingerprinter()
with PcapReader("ssh2.pcapng") as reader:
for packet in reader:
fp.process_packet(packet)
trailing = fp.close_open_windows() # [{"fingerprint": "c36s52_c42s76_c51s2", ...}]Processor.close_open_windows() runs the same method on every fingerprinter it holds,
and it returns one result dict for each window. Each dict holds the keys type,
fingerprint and connection. It holds no packet endpoint, because no packet produces
the value.
Every entry holds the key fingerprint. An entry that one packet produced also holds
these four fields. A method adds its own keys beside them, such as raw on JA4.
| Field | Type | Description |
|---|---|---|
src |
str |
The source address of the packet. |
dst |
str |
The destination address of the packet. |
srcport |
int |
The source port of the packet. |
dstport |
int |
The destination port of the packet. |
A tunnelled packet reports the outer address layer and the innermost port layer, because the reference reports one tunnelled connection that way.
No entry holds the packet object. A monitor runs for weeks, and a stored packet holds
every packet the monitor ever fingerprinted. A caller that needs the packet reads it
inside its own process_packet call.
JA4SSH reports a window of many packets rather than one packet, so its entry holds the
key connection instead of these four fields. JA4L holds both.
A JA4SSH fingerprint names a client packet size and a server packet size, so which
endpoint is the server changes the value. Every JA4SSH entry therefore carries
server_decided_by, and a consumer reads a measured endpoint and a guessed endpoint
differently.
| Value | What decided the server |
|---|---|
port |
One endpoint uses port 22. |
handshake |
The TCP handshake names the endpoints. The SYN sender is the client, and the SYN+ACK sender is the server. |
guess |
The capture holds no handshake and no endpoint on port 22, so the lower port decided. Two ephemeral ports carry no meaning, so treat the two endpoints as unproven. |
The first SSH banner decides nothing. RFC 4253 section 4.2 has both endpoints send an identification string, and a client that does not wait sends first.
TLS Client Hello fingerprinting.
from ja4plus import JA4Fingerprinter
fp = JA4Fingerprinter()
result = fp.process_packet(packet) # Returns JA4 string or None
raw = fp.get_raw_fingerprint(packet) # Returns unhashed fingerprintTLS Server Hello fingerprinting.
from ja4plus import JA4SFingerprinter
fp = JA4SFingerprinter()
result = fp.process_packet(packet)HTTP request fingerprinting.
from ja4plus import JA4HFingerprinter
fp = JA4HFingerprinter()
result = fp.process_packet(packet)TCP client fingerprinting from SYN packets.
One connection produces one value, from its first SYN. The fingerprinter holds a
connection table that carries 10000 entries at most and evicts an entry after 600
seconds. Call cleanup_connection when a connection ends, or reset to drop every
entry. #215 records the ruling.
from ja4plus import JA4TFingerprinter
fp = JA4TFingerprinter()
result = fp.process_packet(packet)TCP server fingerprinting from SYN-ACK packets.
from ja4plus import JA4TSFingerprinter
fp = JA4TSFingerprinter()
result = fp.process_packet(packet)The fingerprinter holds the SYN-ACK times of each connection, because part e reads the
delay between two SYN-ACK packets. It counts ten retransmissions for one connection, and
it drops a connection two minutes after the last SYN-ACK. Call cleanup_connection when
a connection ends, or reset to drop every entry.
The fingerprinter also reads the RST packet that the server sends. A RST on a connection
that already holds a delay appends R and its own delay to part e, and the value reads
part a through part d from the first SYN-ACK of the connection. A RST on a connection
with no delay produces no value, and a client RST produces no value.
Network latency estimation from TCP handshake timing.
from ja4plus import JA4LFingerprinter
fp = JA4LFingerprinter()
result = fp.process_packet(packet)X.509 certificate structure fingerprinting.
from ja4plus import JA4XFingerprinter
fp = JA4XFingerprinter()
result = fp.fingerprint_certificate(der_bytes) # From DER-encoded cert
value, raw = fp.read_certificate(der_bytes) # The value and the JA4X_r raw form
result = fp.process_packet(packet) # From TLS packet
details = fp.get_cert_details(x509_cert) # Extract OID detailsA JA4X entry holds the raw key, which carries the JA4X_r value. It holds the three
unhashed lists of the fingerprint, joined with _. JA4X sorts no list, so
raw_original_order holds the same value. #267 decided the form, and
docs/implementation_notes.md holds the reading.
SSH session classification.
from ja4plus import JA4SSHFingerprinter
fp = JA4SSHFingerprinter(packet_count=200)
result = fp.process_packet(packet)
info = fp.interpret_fingerprint(result) # Session type analysis
hassh = fp.get_hassh_fingerprints() # HASSH fingerprints
lookup = fp.lookup_hassh(hassh_value) # Known HASSH lookupCall this method when the caller evicts one connection, which is the moment the reference
publishes the final window. rust/ja4/src/ssh.rs:45-55 and zeek/ja4ssh/main.zeek:160-164
both emit at teardown. close_open_windows reaches every connection at once, and it
serves no single connection that just ended.
| Method | Returns | Description |
|---|---|---|
close_connection_window(src_ip, src_port, dst_ip, dst_port, proto) |
list[dict] |
Emits the window one connection holds open, and then removes that connection. |
The method names the connection by the same key cleanup_connection accepts, so the
caller names the two endpoints in either order. The list is empty for a connection the
state table does not hold. The list is empty for a window that holds no SSH packet. The
method removes the connection in both cases, so a second call returns an empty list.
The method is opt-in. cleanup_connection still emits nothing, so a caller that only
reclaims memory receives no fingerprint it did not ask for. The maintainer ruled the
method on Crank-Git/ja4plus-go issue #216, on 2026-08-12, and Crank-Git/ja4plus-go
pull request #263 names the interface.
from ja4plus.fingerprinters.ja4ssh import JA4SSHFingerprinter
fp = JA4SSHFingerprinter()
fp.process_packet(packet)
final = fp.close_connection_window("10.0.0.1", 50000, "10.0.0.2", 22, "tcp")FingerprintResult is the typed result of the public interface. It is a frozen
dataclass, because a result describes something that already happened.
| Field | Type | Constraint |
|---|---|---|
type |
str |
The method name, lowercase. One of ten values, and ten values carry eleven methods. --types accepts these ten tokens and the token ja4ls. |
fingerprint |
str |
The fingerprint string. Never empty. |
raw |
str | None |
The raw form, when the method defines one. |
raw_original_order |
str | None |
The original-order raw form, when the method defines one. |
src_ip |
str |
The source address. Empty when the packet carries no address. |
src_port |
int |
The source port. Zero when the packet carries no port. |
dst_ip |
str |
The destination address. |
dst_port |
int |
The destination port. |
timestamp |
datetime | None |
The packet timestamp, or None when the packet carries none. |
The field names are the snake-case form of the FingerprintResult struct of the Go
port, under parity rule 2. The field that names the method is type, not method.
from ja4plus import FingerprintResult
result = FingerprintResult(type="ja4", fingerprint="t13d1516h2_8daaf6152771_b0da82dd1658")
result.type # "ja4"
result.fingerprint # "t13d1516h2_8daaf6152771_b0da82dd1658"
result.timestamp # NoneVersion 0.6.0 returned a dictionary. A result reads by field name too, so that code written against the dictionary keeps working for one major version.
Warning: item access emits a DeprecationWarning. Read the attribute instead.
result["fingerprint"] # the same value, and one DeprecationWarning
result["method"] # KeyError. The field is `type`.The item access covers reading only. result["fingerprint"] = "x" raises TypeError,
and result.fingerprint = "x" raises dataclasses.FrozenInstanceError.
Processor.process_packet returns a list of FingerprintResult, and #45 changed it.
Processor.close_open_windows still returns a list of dictionaries, because a window
carries a connection key and no FingerprintResult field holds one.
| Class/Function | Description |
|---|---|
Processor(thread_safe=True) |
Build one processor and the ten fingerprinters it drives |
.process_packet(packet) |
Run every fingerprinter on one packet, and return a list of FingerprintResult |
.process_packet_with_errors(packet) |
Return the same list, and the errors the fingerprinters raised |
.process_packet_with_method_errors(packet) |
Return the same list, and each error with the name of the method that raised it |
.close_open_windows() |
Emit every window the fingerprinters hold open |
.get_shard_key(packet) |
Return one stable key for the connection of a packet |
.cleanup_connection(src_ip, src_port, dst_ip, dst_port, proto) |
Drop the state of one connection across every fingerprinter |
.reset() |
Reset every fingerprinter, and return every count to zero |
.stats() |
Return one ProcessorStats for each of the ten fingerprinters |
.thread_safe |
The value the constructor read |
ProcessorStats |
The counts of one method, which Processor.stats reports |
process_packet returns the results alone. A fingerprinter that raises produces no
result, and the processor logs the error at DEBUG. One method that raises poisons no
other method.
process_packet_with_errors returns the results and the errors together. Call it to
tell a packet that produces no fingerprint from a packet that failed a parse.
FR-typed-api-4 states the requirement.
results, errors = processor.process_packet_with_errors(packet)
for error in errors:
print(f"one method failed to read the packet: {error!r}")The results follow the fixed method order ja4, ja4s, ja4h, ja4t, ja4ts,
ja4l, ja4x, ja4ssh, ja4d, ja4d6. The order is part of the interface. The
errors follow the same order.
An exception names no method, so a caller that reports an error to a person calls
process_packet_with_method_errors instead. It returns the same results, and one pair
of the method name and the exception for each method that raised. #51 added it, and the
command-line program reads it.
results, errors = processor.process_packet_with_method_errors(packet)
for method, error in errors:
print(f"{method} could not read the packet: {error}")Warning: every returned exception carries no traceback. A traceback holds the frame of
every call it passed, and those frames hold the packet. A monitor that keeps the errors
of every packet would therefore hold every packet it read. CLAUDE.md states that no
code holds a reference to a packet object after process_packet returns. The type, the
message and the error chain stay, so repr(error) reads the same. If the stack matters,
log the error inside the loop that reads it.
process_packet_with_errors sets no timestamp on a result, because the processor
reads no packet timestamp. The field holds None.
stats() reports what the state tables hold, and #41 built it. One processor holds
seventeen state tables across the ten fingerprinters: the fifteen BoundedStateTable
instances and the two TCPStreamReassembler instances of JA4H and JA4X. A method that
holds no state reports an empty tables list.
Field of ProcessorStats |
Description |
|---|---|
method |
The method name, such as ja4l |
packets |
The count of packets the processor gave this method |
entries |
The count of entries every state table of this method holds |
evictions |
The count of entries those tables evicted |
returned_connections |
The count of returned connections those tables saw |
tables |
The name of each state table this method holds |
A returned connection is one connection that a state table evicted and then saw
again. Its new entry holds none of the packets that came before the eviction, so its
fingerprint may be incomplete. A key the caller removed through cleanup_connection,
del, pop or clear leaves no memory, so a connection that returns after that counts
as a first sighting.
stats() holds the lock of one fingerprinter at a time and never two, so it deadlocks
against neither the locks of the fingerprinters nor the module lock of ja4plus.ja4db.
Several threads may call process_packet on one Processor(). Each fingerprinter holds
one threading.RLock of its own, so ten threads work at once on ten fingerprinters rather than
waiting on one lock. The lock is reentrant, because Processor.process_packet holds the
lock of a fingerprinter and then calls a method that holds it again.
The contract is narrower than "thread safe", and it has three clauses.
- Give each thread whole connections.
get_shard_keysorts the 5-tuple, so both directions of one connection return one key, and a caller routes on that key. Eight threads arranged this way read the value set one thread reads. - A caller that splits the packets of one connection across threads gets undefined results. The state of that connection then advances out of capture order, and no lock restores the order.
- Feed one processor the packets of one timeline. Every state table evicts an entry that receives no packet for its maximum age, and the pass reads the timestamp of the most recent packet. Two packet sources whose clocks sit far apart therefore age out state that the later source still needs.
Warning: thread_safe=False is a promise the caller makes, not a mode the library
checks. Processor(thread_safe=False) gives every fingerprinter the shared NULL_LOCK
and acquires nothing. A caller that runs one processor for each shard pays no lock, and a
caller that breaks the promise gets undefined results and no error.
Clause 1 holds whether the lock is present or absent, because a thread that owns whole
connections touches keys no other thread touches. The lock guards the caller who shares
one processor without that arrangement, and it guards a reset that runs beside a packet.
One processor holds seventeen state tables: fifteen BoundedStateTable instances and
two TCPStreamReassembler instances. features/03-concurrency-safety.md states the maximum
entry count and the maximum age of each one. A table that reaches its maximum entry count
evicts the least recently used entry. A long capture can therefore evict a connection
that later returns, and the fingerprint of a returned connection may be incomplete.
Eviction runs on packet arrival, and the library starts no thread.
BaseFingerprinter.fingerprints holds one result for each fingerprint rather than
per-connection data, and it holds no bound. A caller that runs for a long time reads
get_fingerprints() and calls reset(). A caller can instead read the return value of
process_packet and never let the list grow.
JA4SSHFingerprinter.hassh_fingerprints holds no bound either.
This package states a memory ceiling of 512 MiB. One Processor() at the shipped
defaults reads 1000000 packets across 100000 distinct connections and holds resident
memory below that number, and #279 measured 394.94 MiB at the highest of four runs.
features/03-concurrency-safety.md states the defaults the ceiling holds at and the
traffic the case feeds. The ceiling covers that packet run and no longer run, because
of the two unbounded lists the paragraph above names. The same traffic passes 512 MiB at
1500000 packets, where it reads 513.06 MiB.
One-shot fingerprinting without maintaining state:
from ja4plus import (
generate_ja4,
generate_ja4s,
generate_ja4h,
generate_ja4t,
generate_ja4ts,
generate_ja4l,
generate_ja4x,
generate_ja4ssh,
)
from ja4plus.utils.tls_utils import extract_tls_info
# Each of the eight takes a scapy packet, except `generate_ja4` and `generate_ja4x`.
result = generate_ja4s(packet)
# `generate_ja4` reads the TLS info that `extract_tls_info` returns.
result = generate_ja4(extract_tls_info(packet))| Function | Input | Description |
|---|---|---|
generate_ja4(tls_info) |
dict | JA4 TLS client fingerprint. It reads the dict that extract_tls_info returns, and not a packet |
generate_ja4s(packet) |
scapy packet | JA4S TLS server fingerprint |
generate_ja4h(packet) |
scapy packet | JA4H HTTP fingerprint |
generate_ja4t(packet) |
scapy packet | JA4T TCP client fingerprint |
generate_ja4ts(packet, tracker=None) |
scapy packet | JA4TS TCP server fingerprint. One packet names no retransmission, so a call with no tracker writes four parts. JA4TSFingerprinter passes its own tracker and writes part e. A RST that the server sends on a connection that already holds a delay appends R and its own delay to part e, and that value reads part a through part d from the tracker. A call with no tracker reads no RST. |
generate_ja4l(packet) |
scapy packet | JA4L latency fingerprint |
generate_ja4x(cert_info) |
dict | JA4X certificate fingerprint (takes cert_info dict) |
generate_ja4ssh(packet) |
scapy packet | JA4SSH session fingerprint |
Processor.stats() returns a dict that maps each of the ten fingerprinter names to one
ProcessorStats. FR-concurrency-safety-11 and FR-concurrency-safety-12 state the
requirement, and #41 built it.
from ja4plus.processor import Processor
from scapy.all import PcapReader
processor = Processor()
with PcapReader("latest.pcapng") as reader:
for packet in reader:
processor.process_packet(packet)
report = processor.stats()
print(report["ja4l"].packets) # 209
print(report["ja4l"].entries) # the entries of both JA4L tables
print(report["ja4l"].tables["connections"].evictions)Field of ProcessorStats |
Description |
|---|---|
.method |
The method name, such as ja4h |
.packets |
The count of packets the processor gave this method. A packet the method ignores counts too |
.tables |
A dict that maps the state table name to its TableStats |
.entries |
The sum of the entry counts of the tables |
.evictions |
The sum of the eviction counts of the tables |
.returned_connections |
The sum of the counts of connections that returned after an eviction |
Field of TableStats |
Description |
|---|---|
.entries |
The count of entries the table holds now |
.max_entries |
The maximum entry count of the table |
.inserts |
The count of keys the table ever added |
.evictions |
The count of entries the table itself removed, on either bound |
.removals |
The count of entries the caller removed |
.returned_connections |
The count of connections the table evicted and then saw again |
The six counts hold the invariant inserts == entries + evictions + removals. A reader
who sees it broken read the table while another thread wrote it.
A table that a caller built with track_evictions=False reports 0 returned connections,
because it remembers no evicted key. The other five counts stand, and the invariant
holds. Every state table of a Processor tracks its evictions, so a ProcessorStats
reports the count for every method. The lookup cache of JA4DBClient is the one table
that opts out, and #359 measured the 16.06 MiB it saves.
A returned connection matters to an operator. Its new entry holds none of the packets
that came before the eviction, so its fingerprint may be incomplete. A count above zero
states that the bounds of the table are too small for the traffic. A connection the
caller removed with cleanup_connection counts as a first sighting when it returns,
because the caller asked for that removal.
A table remembers the keys it evicted, so that it can recognize a return. The memory holds the entry bound of its own table. The seventeen tables of one processor hold 57400 remembered keys between them, at 187 bytes for one key, so the memory costs 10.2 MiB when every table is full and every entry of every table has been replaced.
Ten fingerprinters hold seventeen state tables between them. JA4DFingerprinter and
JA4D6Fingerprinter hold none, and each reports an empty tables dict.
stats() holds the lock of one fingerprinter across the read of that fingerprinter, so
the counts of one method describe one instant. The report describes ten instants and not
one. If you need one instant across the ten fingerprinters, stop the packet source first.
Processor.reset() returns every packet count to zero, because a reset drops the state
tables that the counts describe.
ProcessorStats is a plain object. Epic 4 makes it a typed dataclass.
| Function | Description |
|---|---|
extract_tls_info(packet) |
Extract TLS handshake details from a packet |
is_grease_value(value) |
Check if a value is a GREASE value |
parse_client_hello(data) |
Parse raw ClientHello bytes |
parse_server_hello(data) |
Parse raw ServerHello bytes |
| Function | Description |
|---|---|
extract_http_info(packet) |
Extract HTTP request details from a packet |
is_http_request(data) |
Check if data is an HTTP request |
parse_http_request(data) |
Parse raw HTTP request bytes |
| Function | Description |
|---|---|
is_ssh_packet(data) |
Check if data is SSH traffic |
parse_ssh_packet(data) |
Parse SSH packet structure |
extract_hassh(data) |
Extract HASSH fingerprint from KEXINIT |
| Function | Description |
|---|---|
oid_to_hex(oid_string) |
Convert OID dotted string to ASN.1 hex encoding |
get_cert_details(cert) |
Extract issuer/subject RDNs and extensions from an x509 certificate |
extract_certificate_from_bytes(data) |
Find DER certificates in raw TLS record bytes |
| Function | Description |
|---|---|
parse_quic_initial(udp_payload) |
Parse QUIC Initial, return tls_info with is_quic=True or None |
derive_initial_secrets(dcid, version) |
Derive secrets from DCID (version 1 or 2) |
extract_crypto_frames(plaintext) |
Reassemble CRYPTO frames from decrypted payload |
| Class/Function | Description |
|---|---|
TCPStreamReassembler(max_streams, max_stream_bytes, max_stream_segments, max_stream_age) |
Sequence-aware TCP stream reassembly |
.add_segment(key, seq, data, timestamp) |
Add a TCP segment. timestamp is the packet time in seconds, and it ages the stream |
.get_stream(key) |
Get reassembled contiguous bytes |
.remove_stream(key) |
Remove a tracked stream. The removal belongs to the caller, so it evicts nothing |
.stats() |
Return the TableStats of the reassembler |
The reassembler holds per-connection data across packets, so it is a state table. It
inherits StateTable and reports the six counts every state table reports.
| Class/Function | Description |
|---|---|
BoundedStateTable(max_connections, max_connection_age, eviction_interval, track_evictions, on_eviction) |
A mapping that evicts on the entry count and on the entry age |
.on_packet(timestamp) |
Announce one packet. The calling thread reads timestamp for every later operation of that thread, and the table runs one age eviction pass for every eviction_interval packets |
.evict_aged(now) |
Run one age eviction pass over the entries of the calling thread, and return the count of entries it removed. The pass holds every entry of another thread |
.evict_key(key) |
Remove one entry, count it as an eviction, and call on_eviction. Return False when the table holds no such key |
.on_eviction |
A callable the table calls with the key of every entry it evicts. A caller removal calls nothing. #285 added it, so that a second table holding the same keys stays in lockstep |
.evictions |
The count of entries the table itself removed. pop, del and clear raise none |
.track_evictions |
True remembers the key of every entry the table evicts, and it reports returned_connections. False remembers no key and reports 0. The default is True |
.stats() |
Return the TableStats of the table |
StateTable |
The base class every state table inherits. It holds the six counts and the memory of the evicted keys |
TableStats |
The counts one state table reports: entries, max_entries, inserts, evictions, removals and returned_connections |
The table answers the dictionary operations a fingerprinter uses: [], [] =, del,
in, get, pop, setdefault, len, keys, values, items and iteration.
A read of one key holds that entry against both bounds. A pass over the whole table
holds no entry, so keys, values, items and iteration change no eviction order.
Two operations read differently from the dictionary this table replaces. dict(table)
reads each key through __getitem__, so it holds every entry; call items for a pass
that holds none. popitem removes the least recently used entry, and a dictionary
removes the entry it received last.
Warning: state the packet timestamp on every packet of one capture, or on none of them.
One on_packet() call that states no timestamp moves the table to the wall clock, and a
replay of a capture recorded in the past then ages out whole.
The clock belongs to the thread that announces the packet. The entry belongs to the thread that read it last. Eight sharded threads stand at eight points of one timeline. One clock for the whole table then lets the thread that stands furthest ahead evict a connection a slower thread still reads. #461 measured that eviction, and the JA4TS value of the evicted connection lost part e.
One wall clock serves every thread, so the pass evicts an entry that the wall clock dated, whichever thread stored it. The entry of a thread that ends stays until the entry count bound removes it. A thread that announces no packet reads the timestamp of the most recent packet of any thread.
The defaults are 10000 entries, 600 seconds and 1000 packets. ssh-r.pcap sets the age.
It holds the longest gap between two segments of one connection across
tests/foxio_vectors/, at 320.714503 seconds.
| Function | Description |
|---|---|
get_ip_layer(packet) |
Return IP or IPv6 layer, or None |
get_ttl(packet) |
Return TTL (IPv4) or Hop Limit (IPv6), or None |
packet_seconds(packet) |
Return the capture timestamp in seconds, or None |
Command-line interface for JA4+ fingerprinting. Installed as the ja4plus command.
ja4plus analyze <pcap_file> # Fingerprint a PCAP file
ja4plus watch <interface> # Read an interface (needs the capture privilege)
ja4plus live <interface> # An alias of watch
ja4plus cert <cert_file> # Fingerprint an X.509 certificate
ja4plus db update # Download the mapping file to the cache directory
ja4plus db info # Report the mapping file the client reads| Option | Description |
|---|---|
--format table|json|csv |
Output format (default: table) |
--types ja4,ja4s,... |
Filter to specific fingerprint types |
--lookup |
Identify fingerprints from the bundled database. It makes no network request |
--lookup-remote |
Identify fingerprints, and send each one the bundled database holds no entry for to https://ja4db.com |
--output FILE |
Write the results to FILE instead of standard output |
--force |
Overwrite the file that --output names when it exists |
--version |
Print version |
The watch command carries four more options. The first two bound the connection table
it owns.
| Option | Description |
|---|---|
--max-connections COUNT |
Maximum number of tracked connections (default: 10000) |
--connection-timeout SECONDS |
Maximum age of a connection that sends no packet (default: 300) |
--stats-interval SECONDS |
Write a statistics line every SECONDS seconds (default: no schedule) |
--bpf FILTER |
Capture filter, in Berkeley Packet Filter syntax (default: no filter) |
The command reads no user identity. It attempts the capture and reads the failure, so a
Linux host that grants CAP_NET_RAW without the user identity zero runs the monitor.
The command names the privilege, lists the interfaces of the host, or reports the filter
error, and it ends the run with the status 1.
The monitor loop and the connection table of ja4plus watch.
The command owns the connection table. It records the connection of every packet it reads, and it evicts a connection on two bounds.
- The count bound removes the least recently used connection as soon as the table
reaches
--max-connections. - The age bound removes every connection that sends no packet for
--connection-timeoutseconds of capture time.
Each eviction calls Processor.cleanup_connection, so it drops the entry of the
connection table and the per-connection state of all ten fingerprinters together. Version 0.6.0
called cleanup_connection never, and its monitor grew until the host stopped it.
Eviction runs on packet arrival. The statistics thread is the only thread the module
starts, and report_statistics starts it only when the caller states an interval.
| Class/Function | Description |
|---|---|
Monitor(processor, report, ...) |
The monitor loop, without the packet source |
Monitor.stats |
The counts the statistics line reports |
Monitor.handle_packet(packet) |
Record the connection of one packet, evict, and report the packet |
Monitor.tracked_connections() |
Return the key of every connection the table holds |
Monitor.evictions |
The count of connections the monitor evicted |
connection_key(packet) |
Return the key of the connection the packet belongs to, or None |
read_interface(interface, handle_packet, stop_filter, capture_filter, stop_requested, poll_interval, open_socket, drop_count) |
Read packets from one interface until the capture stops |
open_capture_socket(interface, capture_filter) |
Return an open capture socket for one interface |
DEFAULT_POLL_INTERVAL |
The count of seconds one sniff call reads before the loop reads the stop request |
DEFAULT_MAX_CONNECTIONS |
The count bound of the connection table, which --max-connections changes |
DEFAULT_CONNECTION_TIMEOUT |
The age bound of one connection in seconds, which --connection-timeout changes |
STATISTICS_THREAD_NAME |
The name the statistics thread carries |
CAPTURE_FAILURES |
The exception classes the capture layer raises when it refuses an interface |
available_interfaces() |
Return the name of every interface the host holds |
describe_capture_failure(error, ...) |
Return the message the operator reads for one capture failure |
unsupported_platform_message(platform, command) |
Return the reason the platform runs no monitor, or None |
StopRequest |
The flag a termination signal sets and the capture reads |
StopRequest.requested() |
Return True after a termination signal arrived |
StopRequest.stop_after(packet) |
Return True when the capture stops after this packet |
stop_on_signal(signal_numbers) |
Yield the stop request, with a handler installed for each signal |
capture_drop_count(capture_socket) |
Return the drop count of one capture socket, or None |
packet_statistics_drops(capture_socket) |
Return the drop count one Linux packet socket reported since the last read, or None |
SOL_PACKET |
The socket level of a Linux packet socket, which the Python socket module omits |
PACKET_STATISTICS |
The socket option that reads struct tpacket_stats of a Linux packet socket |
CaptureDropCount |
The drop count of the capture socket the monitor reads |
CaptureDropCount.attach(capture_socket) |
Hold the capture socket the monitor reads |
CaptureDropCount.refresh() |
Read the drop count of the capture socket, and hold what it reported |
CaptureDropCount.release() |
Read the drop count one last time, and drop the capture socket |
MonitorStats(clock, dropped_source) |
The counts of one monitor, and the lock that guards them |
MonitorStats.count_fingerprints(count) |
Add the fingerprints of one packet to the fingerprint count |
MonitorStats.record_packet(connections, evicted) |
Count one packet, and publish the two table counts |
MonitorStats.snapshot() |
Return the counts of one instant |
StatisticsSnapshot |
The counts one statistics line reports |
format_statistics(snapshot) |
Return the statistics line of one snapshot |
write_statistics(stats, stream) |
Write one statistics line, and flush the stream |
StatisticsReporter(stats, interval, stream, wait) |
The thread that writes a statistics line on a schedule |
report_statistics(stats, interval, stream, wait) |
Yield the statistics thread, and stop it when the body returns |
The capture thread writes the counts and the statistics thread reads them. Every write
and every read holds one lock, so a reader reads the counts of one instant. The capture
thread publishes the two table counts through record_packet, so the statistics thread
reads MonitorStats and never the connection table.
The dropped field reports the count a dropped_source returns, and null where the
caller passes none. ja4plus watch passes a CaptureDropCount, and read_interface
attaches the capture socket to it. On macOS the field holds a whole number, which
_L2bpfSocket.get_stats reads through the BIOCGSTATS ioctl. On Linux it holds a whole
number too, which packet_statistics_drops reads through the PACKET_STATISTICS socket
option. The Linux kernel resets its counters as the read returns them, so
CaptureDropCount adds each Linux reading to a running total.
A CaptureDropCount holds the last count it read. The exit summary runs after the
capture closed the socket, and the kernel gives the file descriptor of a closed socket to
the next file the process opens. read_interface therefore calls release, which reads
the count once more and drops the socket, before it closes the socket. One lock guards
every read, so no read of the statistics thread is in flight at the close. Issue #423
records the macOS measurement and issue #326 records the Linux reading.
The wait parameter of StatisticsReporter is a test seam. It receives the interval and
returns True when the stop arrives, which matches threading.Event.wait. The default
waits on the stop event, and ja4plus watch passes no other call. A test passes its own
call and reads an exact line count, rather than count the lines a loaded host delivered.
Issue #369 added the parameter.
report_statistics carries the same parameter, and it forwards the call to the statistics
thread. A test that reaches the statistics thread through cmd_watch builds none of its
own. The seam of StatisticsReporter alone therefore left that test on the wall clock.
Issue #371 added the parameter.
SIGINT and SIGTERM both stop the monitor, and both end the run with the status zero.
The handler sets the stop request and returns. It calls sys.exit never, because a
signal arrives at any point, including the point where the output holds half a line.
scapy reads the stop request through the stop_filter argument of sniff, and it
applies that filter after it reports a packet. The monitor therefore finishes the line it
writes, and the command flushes the output before it exits.
scapy applies stop_filter to a packet and to nothing else, so an interface that
carries no traffic reaches that filter never. read_interface therefore opens the
capture socket itself and calls sniff with opened_socket and a timeout of
DEFAULT_POLL_INTERVAL seconds, in a loop. It reads stop_requested after each call, so
a monitor on a quiet interface stops within one second of the signal. The socket stays
open across the calls, because AsyncSniffer._run closes the sockets it opened itself
and no other socket; a loop that reopened the socket would lose every packet the host
buffered between two calls. Issue #320 records the whole reading.
describe_capture_failure reads the failure the capture layer reported and returns one
message. It calls no capture function, so it classifies the failure of any capture layer
that raises one of CAPTURE_FAILURES. It reads the privilege first, because a host that
refuses the privilege refuses it before it reads the interface name or the filter.
The writers that turn a FingerprintResult into an output line. --format selects one
of them, and the command-line program owns the two fields a writer adds:
schema_version and identified_as.
The JSON Lines format and the CSV format each carry a stability promise, so both write the same field set whatever flags the user passed. The table format is for a person reading a terminal, and it carries no promise.
| Class/Function | Description |
|---|---|
OutputWriter(stream) |
The interface the three formats share |
OutputWriter.write_header() |
Write the header of the format. The base class writes nothing |
OutputWriter.write(result, identified_as) |
Write one output line. The base class raises NotImplementedError |
JsonLinesWriter(stream) |
The writer that puts one JSON object on each line |
CsvWriter(stream) |
The writer that puts one CSV row on each line, with all eleven columns |
TableWriter(stream) |
The writer that aligns the output lines for a person reading a terminal |
build_writer(fmt, stream) |
Return the writer that the format name selects |
CSV_COLUMNS |
The column names of the CSV format, in the order FR-structured-output-4 fixes |
SCHEMA_VERSION |
The version number every JSON object and every CSV row carries |
A new CSV column appends to the end of CSV_COLUMNS, because a downstream reader reads a
column by position and an insertion breaks it.
Fingerprint identification using FoxIO's ja4plus-mapping.csv database.
from ja4plus.ja4db import JA4DBClient, lookup
# Module-level convenience function
result = lookup("t13d1516h2_8daaf6152771_02713d6af862")
# LookupResult(application="Chromium Browser", type="ja4", notes="", source="embedded")
# Or use the client for caching across multiple lookups
client = JA4DBClient()
result = client.lookup(fingerprint_string)
# One call for many fingerprints
results = client.lookup_many([fingerprint_string, another_fingerprint])| Class/Function | Description |
|---|---|
JA4DBClient(allow_remote=False, cache_size=100000) |
Client with a bounded lookup cache and the bundled database |
JA4DBClient.lookup(fingerprint) |
Look up a fingerprint, returns a LookupResult or None |
JA4DBClient.lookup_many(fingerprints) |
Look up a sequence, returns one entry per fingerprint |
lookup(fingerprint) |
Module-level convenience using a shared client |
LookupResult |
The frozen result: application, type, notes and source |
LookupResult is a frozen dataclass. It carries the three fields that LookupResult of
lookup.go:23 carries, plus source, FR-db-enrichment-8. An analyst needs to know where
a name came from to judge how much to trust it.
| Field | What it holds |
|---|---|
application |
The name the mapping file or the lookup service gives |
type |
The fingerprint method the entry names, such as ja4 |
notes |
The note the entry carries, or an empty string |
source |
embedded, cache or remote |
The value embedded names the mapping file that ships inside the package, cache names
the file that ja4plus db update wrote to the cache directory, and remote names the
lookup service. The port publishes the first two at lookup.go:31, and CLAUDE.md
parity rule 2 adopts them.
Version 0.6.0 returned a dict from lookup. Version 1.0.0 returns the frozen result, so
a caller reads result.application where it read result["application"] before.
FoxIO builds ja4plus-mapping.csv from its own implementations, and the Rust one writes
000000000000 for an empty list. ja4plus hashes an empty list instead, so it writes
e3b0c44298fc where a row of that file holds the zero sentinel. The lookup reads the two
forms as one value, and #639 holds the ruling of 2026-08-15.
client = JA4DBClient()
# The mapping file holds `000000000000_4f24da86fad6_bf0f0589fc03`, and `ja4plus`
# produces the hashed form for the same certificate. Both reach the row.
client.lookup("e3b0c44298fc_4f24da86fad6_bf0f0589fc03").application # Sliver/Havoc C2 Server
client.lookup("000000000000_4f24da86fad6_bf0f0589fc03").application # Sliver/Havoc C2 ServerWarning: 000000000000 does not name one thing across JA4+. The table states the parts
where the lookup reads both forms.
| Part | What the sentinel names there | Does the lookup read both forms |
|---|---|---|
| JA4X part a, part b and part c | an empty object identifier list | Yes |
| JA4H part b | an empty header list | Yes |
| JA4H part c and part d | no cookie |
No |
Part c and part d of JA4H hold no cookie, which is a value in its own right. A JA4H
value whose part c or part d carries the sentinel therefore gains no match, and
client.lookup("ge11nn08enus_050dd5cfb971_e3b0c44298fc_000000000000") returns None.
db info reports the entry count of the mapping file. The lookup holds the alias values
apart from that file, so no alias moves the count the command prints.
A result reads by field name too, so that code written against the dict of version 0.6.0
keeps working for one major version, FR-db-enrichment-16. Version 0.6.0 published the
three keys application, type and notes, and each one names a field, so no key of
version 0.6.0 returns the value of another field.
Warning: item access emits a DeprecationWarning, FR-db-enrichment-17. Read the
attribute instead.
result["application"] # the same value, and one DeprecationWarning
result["method"] # KeyError. `LookupResult` holds no field of that name.The item access covers reading only. result["application"] = "x" raises TypeError,
and result.application = "x" raises dataclasses.FrozenInstanceError.
lookup_many accepts a sequence of fingerprints and returns one entry per fingerprint,
FR-db-enrichment-7. A miss holds None, so a caller reads one entry for every fingerprint
it passed. The returned mapping keys the fingerprint, so a sequence that repeats a
fingerprint holds one entry for it.
results = client.lookup_many(["t13d1516h2_8daaf6152771_02713d6af862", "t99z9999h0_0_0"])
# {"t13d1516h2_8daaf6152771_02713d6af862": LookupResult(...), "t99z9999h0_0_0": None}lookup_many reaches the lookup service under the rule that lookup holds, and under no
other rule. A client that the operator built with allow_remote=False sends nothing,
whatever count of fingerprints the call carries. A client that the operator built with
allow_remote=True sends one request for each fingerprint the mapping file holds no entry
for. The lookup cache holds a miss as well as a hit, so a repeated fingerprint costs one
request and no more.
A fingerprint describes traffic the operator observed. A request to the lookup service
ja4db.com discloses that traffic to a third party. The client therefore reads the
bundled mapping file and performs no network request by default, FR-db-enrichment-1. The
module-level lookup function holds the same default.
JA4DBClient(allow_remote=True) permits one request for each fingerprint the mapping
file holds no entry for, FR-db-enrichment-2. The request goes to
https://ja4db.com/api/read/<fingerprint>, and it waits 5 seconds at most,
FR-db-enrichment-14. _REMOTE_TIMEOUT in ja4plus/ja4db.py holds that interval, and no
parameter of the client changes it.
allow_remote takes True or False, and the constructor raises TypeError for every
other value. cache_size was the first parameter before #57, so JA4DBClient(100) asked
for a lookup cache of 100 entries. That call now reads as a request for the remote
lookup, and the client refuses it. Write JA4DBClient(cache_size=100) instead.
The request needs the requests package, which the lookup extra installs. A client
that reaches no service returns None for the miss, and it raises nothing,
FR-db-enrichment-15. The same holds for a request that times out, for a status other
than 200, and for a package that is absent.
The lookup service publishes no versioned API document. The client therefore accepts one
shape: an object that carries a non-empty application string. It reads type and
notes as strings, and it substitutes an empty string for a field of another type. It
returns None for every other shape, so no unchecked value reaches a caller.
--lookup identifies each fingerprint from the bundled mapping file, and it makes no
network request, FR-db-enrichment-3.
--lookup-remote identifies each fingerprint, and it sends every fingerprint the mapping
file holds no entry for to the lookup service, FR-db-enrichment-4. It asks for the lookup
as well as for the disclosure, so an operator who passes it needs no --lookup.
JA4PLUS_DB_LOOKUP=1 permits the same disclosure, FR-db-enrichment-5. It serves an
operator who runs a command line another program builds. The variable permits the
disclosure and asks for no lookup, so JA4PLUS_DB_LOOKUP=1 ja4plus analyze capture.pcap
looks nothing up. JA4PLUS_DB_LOOKUP=1 ja4plus analyze capture.pcap --lookup performs
the remote lookup.
The option and the variable each permit the disclosure, and neither one refuses it.
JA4PLUS_DB_LOOKUP=0 therefore cancels no option, and an operator who wants the local
lookup passes --lookup. The variable permits the disclosure on the value 1 and on no
other value.
The command writes one notice to standard error for each run that permits the remote lookup, FR-db-enrichment-6. The notice names the lookup service and the two ways to stop the request. It appears once whatever count of fingerprints the run looks up, and it goes to standard error, because a notice on standard output would enter the pipe that carries the results.
Notice: the remote lookup is on. Each fingerprint the bundled mapping file holds no entry for goes to the lookup service at https://ja4db.com. To stop it, pass no --lookup-remote option and unset JA4PLUS_DB_LOOKUP.
The command needs the requests package for the remote lookup. Where the operator asks
for the remote lookup and the package is absent, the command reports the extra to install
and ends the run with the status 1.
The first caller of lookup builds the module-level client. Two threads that call
lookup at the same time receive results from one client. Several threads may share
one JA4DBClient, and the client holds a lock over the lookup cache read and over the
lookup cache write.
The lookup cache holds a hit and it holds a miss, so a repeated miss costs one read.
It holds no more than cache_size entries, and it evicts the least recently used
entry at that count. It also evicts an entry that receives no read for 600 seconds.
An age pass reads every entry, so the pass runs once for every cache_size lookups,
and once for every 100000 lookups at most.
A caller that shares one client between threads therefore receives a result the client looked up before, or a result the client looks up now. An entry that leaves the lookup cache costs the next caller one more lookup, and it changes no result.
A full lookup cache holds about 47 MiB. Two methods agree on that total: tracemalloc
reads 47.06 MiB and sys.getsizeof reads 46.95 MiB. The figure covers two structures,
because the client also remembers the 100000 keys it evicted. A run of 200000 distinct
lookups fills both, and a run of 100000 fills the entries alone at 25.34 MiB. #279 holds
the memory ceiling of this package, and it states no decided number yet.
The client reads the mapping file once, at construction. A caller that replaces the mapping file builds a new client, and the new client holds an empty lookup cache.
ja4plus db update downloads ja4plus-mapping.csv from FoxIO and writes it to the cache
directory, FR-db-enrichment-12. It writes no file inside the installed package. A package
directory may be read-only, several users may share it, and the next pip install
discards a file written there. The cache directory follows the platform convention.
| Platform | Cache directory |
|---|---|
| Linux | $XDG_CACHE_HOME/ja4plus, or ~/.cache/ja4plus where the variable holds no value |
| macOS | ~/Library/Caches/ja4plus |
The command writes a temporary file and renames it, so a reader of the cache file reads the whole new file or the file the last run wrote. Where the command creates no cache directory, it names the directory and ends the run with the status 1. Where the download fails, it leaves the cache file as it was and ends the run with the status 1.
JA4DBClient prefers the cached mapping file over the bundled one, FR-db-enrichment-13.
A cache file that is empty, corrupt or unreadable falls back to the bundled file, and the
client writes one WARNING record that names the path.
ja4plus db info reports the source, the path, the entry count and the modification time
of the mapping file the client reads, FR-db-enrichment-11. The source is embedded or
cache. The port publishes the value embedded for the file that ships inside the
package, at lookup.go:31, and CLAUDE.md parity rule 2 adopts it. The prose of this
project still calls that file the bundled mapping file. Where the source is embedded,
the command names the cache file as well. It
reports that the cache file holds no entry, or that no cache file exists, and it names
ja4plus db update in each case.