This is an automated email from the ASF dual-hosted git repository.
jvanderzee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
The following commit(s) were added to refs/heads/master by this push:
new 35462818ef Add JA4 plugin (#11719)
35462818ef is described below
commit 35462818ef9eaf27d6e56fc63038d887bdf5063e
Author: JosiahWI <[email protected]>
AuthorDate: Tue Aug 27 13:53:13 2024 -0500
Add JA4 plugin (#11719)
* Add JA4 plugin
See the README for details.
* Make changes requested by Chris McFarlen
* Add verify test for global ja4_fingerprint
---
cmake/ExperimentalPlugins.cmake | 10 +-
.../experimental/ja4_fingerprint/CMakeLists.txt | 4 +
plugins/experimental/ja4_fingerprint/README.md | 26 ++
plugins/experimental/ja4_fingerprint/ja4.h | 11 +
plugins/experimental/ja4_fingerprint/plugin.cc | 382 +++++++++++++++++++++
.../ja4_fingerprint/tls_client_hello_summary.cc | 13 +-
.../ja4_fingerprint/ja4_fingerprint.replay.yaml | 47 +++
.../ja4_fingerprint/ja4_fingerprint.test.py | 152 ++++++++
8 files changed, 637 insertions(+), 8 deletions(-)
diff --git a/cmake/ExperimentalPlugins.cmake b/cmake/ExperimentalPlugins.cmake
index 2b07f2c563..754dfe926a 100644
--- a/cmake/ExperimentalPlugins.cmake
+++ b/cmake/ExperimentalPlugins.cmake
@@ -40,7 +40,15 @@ auto_option(HOOK_TRACE FEATURE_VAR BUILD_HOOK_TRACE DEFAULT
${_DEFAULT})
auto_option(HTTP_STATS FEATURE_VAR BUILD_HTTP_STATS DEFAULT ${_DEFAULT})
auto_option(ICAP FEATURE_VAR BUILD_ICAP DEFAULT ${_DEFAULT})
auto_option(INLINER FEATURE_VAR BUILD_INLINER DEFAULT ${_DEFAULT})
-auto_option(JA4_FINGERPRINT FEATURE_VAR BUILD_JA4_FINGERPRINT DEFAULT
${_DEFAULT})
+auto_option(
+ JA4_FINGERPRINT
+ FEATURE_VAR
+ BUILD_JA4_FINGERPRINT
+ VAR_DEPENDS
+ HAVE_SSL_CTX_SET_CLIENT_HELLO_CB
+ DEFAULT
+ ${_DEFAULT}
+)
auto_option(
MAGICK
FEATURE_VAR
diff --git a/plugins/experimental/ja4_fingerprint/CMakeLists.txt
b/plugins/experimental/ja4_fingerprint/CMakeLists.txt
index e5a6045c87..3b58eaa3ff 100644
--- a/plugins/experimental/ja4_fingerprint/CMakeLists.txt
+++ b/plugins/experimental/ja4_fingerprint/CMakeLists.txt
@@ -15,6 +15,10 @@
#
#######################
+add_atsplugin(ja4_fingerprint ja4.cc plugin.cc tls_client_hello_summary.cc)
+target_link_libraries(ja4_fingerprint PRIVATE OpenSSL::Crypto OpenSSL::SSL)
+verify_global_plugin(ja4_fingerprint)
+
if(BUILD_TESTING)
add_executable(test_ja4 test_ja4.cc ja4.cc tls_client_hello_summary.cc)
target_link_libraries(test_ja4 PRIVATE catch2::catch2)
diff --git a/plugins/experimental/ja4_fingerprint/README.md
b/plugins/experimental/ja4_fingerprint/README.md
new file mode 100644
index 0000000000..d45ddf0078
--- /dev/null
+++ b/plugins/experimental/ja4_fingerprint/README.md
@@ -0,0 +1,26 @@
+# ATS (Apache Traffic Server) JA4 Fingerprint Plugin
+
+## General Information
+
+The JA4 algorithm designed by John Althouse is the successor to JA3. A JA4
fingerprint has three sections, delimited by underscores, called a, b, and c.
The a section contains basic, un-hashed information about the client hello,
such as the version and most preferred ALPN. The b section is a hash of the
ciphers, and the c section is a hash of the extensions. The algorithm is
licensed under the BSD 3-Clause license.
+
+The technical specification of the algorithm is available
[here](https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md).
+
+### Main Differences from JA3 Algorithm
+
+* The ciphers and extensions are sorted before hashing.
+* Information about the SNI and ALPN is included.
+* The fingerprint can indicate that QUIC is in use.
+
+## Changes from JA3 Plugin
+
+* The behavior is as if the ja3\_fingerprint option `--modify-incoming` were
specified. No other mode is supported.
+* The raw (un-hashed) cipher and extension lists are not logged.
+* There is no way to turn the log off.
+* There is no remap variant of the plugin.
+
+These changes were made to simplify the plugin as much as possible. The
missing features are useful and may be implemented in the future.
+
+## Logging and Debugging
+
+To get debug information in the traffic log, enable the debug tag
`ja4_fingerprint`.
diff --git a/plugins/experimental/ja4_fingerprint/ja4.h
b/plugins/experimental/ja4_fingerprint/ja4.h
index 6b19b47f9e..743b867250 100644
--- a/plugins/experimental/ja4_fingerprint/ja4.h
+++ b/plugins/experimental/ja4_fingerprint/ja4.h
@@ -159,4 +159,15 @@ make_JA4_fingerprint(TLSClientHelloSummary const
&TLS_summary, UnaryOp hasher)
return result;
}
+/**
+ * Check whether @a value is a GREASE value.
+ *
+ * These are reserved extensions randomly advertised to keep implementations
+ * well lubricated. They are ignored in all parts of JA4 because of their
+ * random nature.
+ *
+ * @return Returns true if the value is a GREASE value, fales otherwise.
+ */
+bool is_GREASE(std::uint16_t value);
+
} // end namespace JA4
diff --git a/plugins/experimental/ja4_fingerprint/plugin.cc
b/plugins/experimental/ja4_fingerprint/plugin.cc
new file mode 100644
index 0000000000..7c31dd45ab
--- /dev/null
+++ b/plugins/experimental/ja4_fingerprint/plugin.cc
@@ -0,0 +1,382 @@
+/** @file ja3_fingerprint.cc
+ *
+ Plugin JA4 Fingerprint calculates JA4 signatures for incoming SSL traffic.
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ */
+
+#include "ja4.h"
+
+#include <ts/apidefs.h>
+#include <ts/ts.h>
+
+#include <openssl/sha.h>
+#include <openssl/ssl.h>
+
+#include <arpa/inet.h>
+#include <netinet/in.h>
+
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <memory>
+#include <string>
+
+struct JA4_data {
+ std::string fingerprint;
+ char IP_addr[INET6_ADDRSTRLEN];
+};
+
+// Returns true on success, false otherwise; must succeed before registering
+// hooks.
+[[nodiscard]] static bool register_plugin();
+static void reserve_user_arg();
+static bool create_log_file();
+static void register_hooks();
+static int handle_client_hello(TSCont cont, TSEvent event, void
*edata);
+static std::string get_fingerprint(SSL *ssl);
+char *get_IP(sockaddr const *s_sockaddr, char
res[INET6_ADDRSTRLEN]);
+static void log_fingerprint(JA4_data const *data);
+static std::uint16_t get_version(SSL *ssl);
+static std::string get_first_ALPN(SSL *ssl);
+static void add_ciphers(JA4::TLSClientHelloSummary &summary, SSL
*ssl);
+static void add_extensions(JA4::TLSClientHelloSummary &summary,
SSL *ssl);
+static std::string hash_with_SHA256(std::string_view sv);
+static int handle_read_request_hdr(TSCont cont, TSEvent event,
void *edata);
+static void append_JA4_header(TSCont cont, TSHttpTxn txnp,
std::string const *fingerprint);
+static void append_to_field(TSMBuffer bufp, TSMLoc hdr_loc, char const *field,
int field_len, char const *value, int value_len);
+static int handle_vconn_close(TSCont cont, TSEvent event, void *edata);
+
+namespace
+{
+constexpr char const *PLUGIN_NAME{"ja4_fingerprint"};
+constexpr char const *PLUGIN_VENDOR{"Apache Software Foundation"};
+constexpr char const *PLUGIN_SUPPORT_EMAIL{"[email protected]"};
+
+constexpr unsigned int EXT_ALPN{0x10};
+constexpr unsigned int EXT_SUPPORTED_VERSIONS{0x2b};
+constexpr int SSL_SUCCESS{1};
+
+DbgCtl dbg_ctl{PLUGIN_NAME};
+
+} // end anonymous namespace
+
+static int *
+get_user_arg_index()
+{
+ static int *arg{new int{}};
+ return arg;
+}
+
+static TSTextLogObject *
+get_log_handle()
+{
+ static TSTextLogObject *log_handle{new TSTextLogObject{nullptr}};
+ return log_handle;
+}
+
+static constexpr TSPluginRegistrationInfo
+get_registration_info()
+{
+ TSPluginRegistrationInfo info;
+ info.plugin_name = PLUGIN_NAME;
+ info.vendor_name = PLUGIN_VENDOR;
+ info.support_email = PLUGIN_SUPPORT_EMAIL;
+ return info;
+}
+
+static constexpr std::uint16_t
+make_word(unsigned char lowbyte, unsigned char highbyte)
+{
+ return (static_cast<std::uint16_t>(highbyte) << 8) | lowbyte;
+}
+
+void
+TSPluginInit(int /* argc ATS_UNUSED */, char const ** /* argv ATS_UNUSED */)
+{
+ if (!register_plugin()) {
+ TSError("[%s] Failed to register.", PLUGIN_NAME);
+ return;
+ }
+ reserve_user_arg();
+ if (!create_log_file()) {
+ TSError("[%s] Failed to create log.", PLUGIN_NAME);
+ return;
+ } else {
+ Dbg(dbg_ctl, "Created log file.");
+ }
+ register_hooks();
+}
+
+bool
+register_plugin()
+{
+ constexpr auto info{get_registration_info()};
+ return (TS_SUCCESS == TSPluginRegister(&info));
+}
+
+bool
+create_log_file()
+{
+ return (TS_SUCCESS == TSTextLogObjectCreate(PLUGIN_NAME,
TS_LOG_MODE_ADD_TIMESTAMP, get_log_handle()));
+}
+
+void
+reserve_user_arg()
+{
+ TSUserArgIndexReserve(TS_USER_ARGS_VCONN, PLUGIN_NAME, "used to pass JA4
between hooks", get_user_arg_index());
+}
+
+void
+register_hooks()
+{
+ TSHttpHookAdd(TS_SSL_CLIENT_HELLO_HOOK, TSContCreate(handle_client_hello,
nullptr));
+ TSHttpHookAdd(TS_HTTP_READ_REQUEST_HDR_HOOK,
TSContCreate(handle_read_request_hdr, nullptr));
+ TSHttpHookAdd(TS_VCONN_CLOSE_HOOK, TSContCreate(handle_vconn_close,
nullptr));
+}
+
+int
+handle_client_hello(TSCont /* cont ATS_UNUSED */, TSEvent event, void *edata)
+{
+ if (TS_EVENT_SSL_CLIENT_HELLO != event) {
+ Dbg(dbg_ctl, "Unexpected event %d.", event);
+ // We ignore the event, but we don't want to reject the connection.
+ return TS_SUCCESS;
+ }
+ TSVConn const ssl_vc{static_cast<TSVConn>(edata)};
+ TSSslConnection const ssl{TSVConnSslConnectionGet(ssl_vc)};
+ if (nullptr == ssl) {
+ Dbg(dbg_ctl, "Could not get SSL object.");
+ } else {
+ auto data{std::make_unique<JA4_data>()};
+ data->fingerprint = get_fingerprint(reinterpret_cast<SSL *>(ssl));
+ get_IP(TSNetVConnRemoteAddrGet(ssl_vc), data->IP_addr);
+ log_fingerprint(data.get());
+ // The VCONN_CLOSE handler is now responsible for freeing the resource.
+ TSUserArgSet(ssl_vc, *get_user_arg_index(), static_cast<void
*>(data.release()));
+ }
+ TSVConnReenable(ssl_vc);
+ return TS_SUCCESS;
+}
+
+std::string
+get_fingerprint(SSL *ssl)
+{
+ JA4::TLSClientHelloSummary summary;
+ summary.protocol = JA4::Protocol::TLS;
+ summary.TLS_version = get_version(ssl);
+ summary.ALPN = get_first_ALPN(ssl);
+ add_ciphers(summary, ssl);
+ add_extensions(summary, ssl);
+ std::string result{JA4::make_JA4_fingerprint(summary, hash_with_SHA256)};
+ return result;
+}
+
+// This implementation is copied verbatim from JA3 fingerprint to make the
+// potential for deduplication as obvious as possible.
+char *
+get_IP(sockaddr const *s_sockaddr, char res[INET6_ADDRSTRLEN])
+{
+ res[0] = '\0';
+
+ if (s_sockaddr == nullptr) {
+ return nullptr;
+ }
+
+ switch (s_sockaddr->sa_family) {
+ case AF_INET: {
+ const struct sockaddr_in *s_sockaddr_in = reinterpret_cast<const struct
sockaddr_in *>(s_sockaddr);
+ inet_ntop(AF_INET, &s_sockaddr_in->sin_addr, res, INET_ADDRSTRLEN);
+ } break;
+ case AF_INET6: {
+ const struct sockaddr_in6 *s_sockaddr_in6 = reinterpret_cast<const struct
sockaddr_in6 *>(s_sockaddr);
+ inet_ntop(AF_INET6, &s_sockaddr_in6->sin6_addr, res, INET6_ADDRSTRLEN);
+ } break;
+ default:
+ return nullptr;
+ }
+
+ return res[0] ? res : nullptr;
+}
+
+void
+log_fingerprint(JA4_data const *data)
+{
+ Dbg(dbg_ctl, "JA4 fingerprint: %s", data->fingerprint.c_str());
+ if (TS_ERROR == TSTextLogObjectWrite(*get_log_handle(), "Client IP: %s\tJA4:
%s", data->IP_addr, data->fingerprint.c_str())) {
+ Dbg(dbg_ctl, "Failed to write to log!");
+ }
+}
+
+std::uint16_t
+get_version(SSL *ssl)
+{
+ unsigned char const *buf{};
+ std::size_t buflen{};
+ if (SSL_SUCCESS == SSL_client_hello_get0_ext(ssl, EXT_SUPPORTED_VERSIONS,
&buf, &buflen)) {
+ std::uint16_t max_version{0};
+ for (std::size_t i{1}; i < buflen; i += 2) {
+ std::uint16_t version{make_word(buf[i - 1], buf[i])};
+ if ((!JA4::is_GREASE(version)) && version > max_version) {
+ max_version = version;
+ }
+ }
+ return max_version;
+ } else {
+ Dbg(dbg_ctl, "No supported_versions extension... using legacy version.");
+ return SSL_client_hello_get0_legacy_version(ssl);
+ }
+}
+
+std::string
+get_first_ALPN(SSL *ssl)
+{
+ unsigned char const *buf{};
+ std::size_t buflen{};
+ std::string result{""};
+ if (SSL_SUCCESS == SSL_client_hello_get0_ext(ssl, EXT_ALPN, &buf, &buflen)) {
+ // The first two bytes are a 16bit encoding of the total length.
+ unsigned char first_ALPN_length{buf[2]};
+ TSAssert(buflen > 4);
+ TSAssert(0 != first_ALPN_length);
+ result.assign(&buf[3], (&buf[3]) + first_ALPN_length);
+ }
+ return result;
+}
+
+void
+add_ciphers(JA4::TLSClientHelloSummary &summary, SSL *ssl)
+{
+ unsigned char const *buf{};
+ std::size_t buflen{SSL_client_hello_get0_ciphers(ssl, &buf)};
+ if (buflen > 0) {
+ for (std::size_t i{1}; i < buflen; i += 2) {
+ summary.add_cipher(make_word(buf[i], buf[i - 1]));
+ }
+ } else {
+ Dbg(dbg_ctl, "Failed to get ciphers.");
+ }
+}
+
+void
+add_extensions(JA4::TLSClientHelloSummary &summary, SSL *ssl)
+{
+ int *buf{};
+ std::size_t buflen{};
+ if (SSL_SUCCESS == SSL_client_hello_get1_extensions_present(ssl, &buf,
&buflen)) {
+ for (std::size_t i{1}; i < buflen; i += 2) {
+ summary.add_extension(make_word(buf[i], buf[i - 1]));
+ }
+ }
+ OPENSSL_free(buf);
+}
+
+std::string
+hash_with_SHA256(std::string_view sv)
+{
+ Dbg(dbg_ctl, "Hashing %s", std::string{sv}.c_str());
+ unsigned char hash[SHA256_DIGEST_LENGTH];
+ SHA256(reinterpret_cast<unsigned char const *>(sv.data()), sv.size(), hash);
+ std::string result;
+ result.resize(SHA256_DIGEST_LENGTH * 2 + 1);
+ for (int i{0}; i < SHA256_DIGEST_LENGTH; ++i) {
+ std::snprintf(result.data() + (i * 2), result.size() - (i * 2), "%02x",
hash[i]);
+ }
+ return result;
+}
+
+int
+handle_read_request_hdr(TSCont cont, TSEvent event, void *edata)
+{
+ if (TS_EVENT_HTTP_READ_REQUEST_HDR != event) {
+ TSError("[%s] Unexpected event, got %d, expected %d", PLUGIN_NAME, event,
TS_EVENT_HTTP_READ_REQUEST_HDR);
+ return TS_SUCCESS;
+ }
+
+ TSHttpTxn txnp{};
+ TSHttpSsn ssnp{};
+ TSVConn vconn{};
+ if ((txnp = static_cast<TSHttpTxn>(edata)) == nullptr || (ssnp =
TSHttpTxnSsnGet(txnp)) == nullptr ||
+ (vconn = TSHttpSsnClientVConnGet(ssnp)) == nullptr) {
+ Dbg(dbg_ctl, "Failed to get txn/ssn/vconn object.");
+ TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
+ return TS_SUCCESS;
+ }
+
+ std::string *fingerprint{static_cast<std::string *>(TSUserArgGet(vconn,
*get_user_arg_index()))};
+ if (fingerprint) {
+ append_JA4_header(cont, txnp, fingerprint);
+ } else {
+ Dbg(dbg_ctl, "No JA4 fingerprint attached to vconn!");
+ }
+
+ TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE);
+ return TS_SUCCESS;
+}
+
+void
+append_JA4_header(TSCont /* cont ATS_UNUSED */, TSHttpTxn txnp, std::string
const *fingerprint)
+{
+ TSMBuffer bufp;
+ TSMLoc hdr_loc;
+ if (TS_SUCCESS == TSHttpTxnClientReqGet(txnp, &bufp, &hdr_loc)) {
+ append_to_field(bufp, hdr_loc, "ja4", 3, fingerprint->data(),
fingerprint->size());
+ } else {
+ Dbg(dbg_ctl, "Failed to get headers.");
+ }
+
+ TSHandleMLocRelease(bufp, TS_NULL_MLOC, hdr_loc);
+}
+
+// This function will append value to the last occurrence of field. If none
exists, it will
+// create a field and append to the headers
+void
+append_to_field(TSMBuffer bufp, TSMLoc hdr_loc, const char *field, int
field_len, const char *value, int value_len)
+{
+ TSMLoc target = TSMimeHdrFieldFind(bufp, hdr_loc, field, field_len);
+ if (target == TS_NULL_MLOC) {
+ TSMimeHdrFieldCreateNamed(bufp, hdr_loc, field, field_len, &target);
+ TSMimeHdrFieldAppend(bufp, hdr_loc, target);
+ } else {
+ TSMLoc next = target;
+ while (next) {
+ target = next;
+ next = TSMimeHdrFieldNextDup(bufp, hdr_loc, target);
+ }
+ }
+ TSMimeHdrFieldValueStringInsert(bufp, hdr_loc, target, -1, value, value_len);
+ TSHandleMLocRelease(bufp, hdr_loc, target);
+}
+
+int
+handle_vconn_close(TSCont /* cont ATS_UNUSED */, TSEvent event, void *edata)
+{
+ if (TS_EVENT_VCONN_CLOSE != event) {
+ Dbg(dbg_ctl, "Unexpected event %d.", event);
+ // We ignore the event, but we don't want to reject the connection.
+ return TS_SUCCESS;
+ }
+
+ TSVConn const ssl_vc{static_cast<TSVConn>(edata)};
+ delete static_cast<std::string *>(TSUserArgGet(ssl_vc,
*get_user_arg_index()));
+ TSUserArgSet(ssl_vc, *get_user_arg_index(), nullptr);
+ TSVConnReenable(ssl_vc);
+ return TS_SUCCESS;
+}
diff --git a/plugins/experimental/ja4_fingerprint/tls_client_hello_summary.cc
b/plugins/experimental/ja4_fingerprint/tls_client_hello_summary.cc
index b380a40375..57c64faea0 100644
--- a/plugins/experimental/ja4_fingerprint/tls_client_hello_summary.cc
+++ b/plugins/experimental/ja4_fingerprint/tls_client_hello_summary.cc
@@ -40,7 +40,6 @@ constexpr std::uint16_t extension_ALPN{0x10};
} // end anonymous namespace
-static bool is_GREASE(std::uint16_t value);
static bool is_ignored_non_GREASE_extension(std::uint16_t extension);
std::vector<std::uint16_t> const &
@@ -88,12 +87,6 @@ JA4::TLSClientHelloSummary::get_cipher_count() const
return this->_ciphers.size();
}
-bool
-is_GREASE(std::uint16_t value)
-{
- return std::binary_search(GREASE_values.begin(), GREASE_values.end(), value);
-}
-
JA4::TLSClientHelloSummary::difference_type
JA4::TLSClientHelloSummary::get_extension_count() const
{
@@ -111,3 +104,9 @@ JA4::TLSClientHelloSummary::get_SNI_type() const
{
return this->_SNI_type;
}
+
+bool
+JA4::is_GREASE(std::uint16_t value)
+{
+ return std::binary_search(GREASE_values.begin(), GREASE_values.end(), value);
+}
diff --git
a/tests/gold_tests/pluginTest/ja4_fingerprint/ja4_fingerprint.replay.yaml
b/tests/gold_tests/pluginTest/ja4_fingerprint/ja4_fingerprint.replay.yaml
new file mode 100644
index 0000000000..8e23953c95
--- /dev/null
+++ b/tests/gold_tests/pluginTest/ja4_fingerprint/ja4_fingerprint.replay.yaml
@@ -0,0 +1,47 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+meta:
+ version: "1.0"
+
+sessions:
+- protocol:
+ - name: http
+ version: 1
+ - name: tcp
+ - name: ip
+
+ transactions:
+ - client-request:
+ method: "GET"
+ version: "1.1"
+ url: /resource
+ headers:
+ fields:
+ - [ Connection, keep-alive ]
+ - [ Content-Length, 0 ]
+
+ proxy-request:
+ headers:
+ fields:
+ - [ ja4, { as: contains } ]
+
+ server-response:
+ status: 200
+ reason: OK
+ content:
+ encoding: plain
+ data: Yay!
diff --git
a/tests/gold_tests/pluginTest/ja4_fingerprint/ja4_fingerprint.test.py
b/tests/gold_tests/pluginTest/ja4_fingerprint/ja4_fingerprint.test.py
new file mode 100644
index 0000000000..eba41d4a1b
--- /dev/null
+++ b/tests/gold_tests/pluginTest/ja4_fingerprint/ja4_fingerprint.test.py
@@ -0,0 +1,152 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# 'License'); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an 'AS IS' BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import functools
+import os
+import re
+from typing import Any, Callable, Dict, Optional
+
+from ports import get_port
+
+Test.Summary = 'Tests the ja4_fingerprint plugin.'
+# The plugin is experimental and therefore may not always be built. It also
+# doesn't support QUIC yet.
+Test.SkipUnless(Condition.PluginExists('ja4_fingerprint.so'))
+
+TestParams = Dict[str, Any]
+
+
+class TestJA4Fingerprint:
+ '''Configure a test for ja4_fingerprint.'''
+
+ replay_filepath: str = 'ja4_fingerprint.replay.yaml'
+ client_counter: int = 0
+ server_counter: int = 0
+ ts_counter: int = 0
+
+ def __init__(self, name: str, /, autorun: bool) -> None:
+ '''Initialize the test.
+
+ :param name: The name of the test.
+ '''
+ self.name = name
+ self.autorun = autorun
+
+ def _init_run(self) -> 'TestRun':
+ '''Initialize processes for the test run.'''
+
+ server_one = TestJA4Fingerprint.configure_server('yay.com')
+ self._configure_traffic_server(server_one)
+
+ tr = Test.AddTestRun(self.name)
+ tr.Processes.Default.StartBefore(server_one)
+ tr.Processes.Default.StartBefore(self._ts)
+
+ waiting_tr = self._configure_wait_for_log(server_one)
+
+ return {
+ 'tr': tr,
+ 'waiting_tr': waiting_tr,
+ 'ts': self._ts,
+ 'server_one': server_one,
+ 'port_one': self._port_one,
+ }
+
+ @classmethod
+ def runner(cls, name: str, autorun: bool = True, **kwargs) ->
Optional[Callable]:
+ '''Create a runner for a test case.
+
+ :param autorun: Run the test case once it's set up. Default is True.
+ :return: Returns a runner that can be used as a decorator.
+ '''
+ test = cls(name, autorun=autorun, **kwargs)._prepare_test_case
+ return test
+
+ def _prepare_test_case(self, func: Callable) -> Callable:
+ '''Set up a test case and possibly run it.
+
+ :param func: The test case to set up.
+ :return: Returns a wrapped function that will have its test params
+ passed to it on invocation.
+ '''
+ functools.wraps(func)
+ test_params = self._init_run()
+
+ def wrapper(*args, **kwargs) -> Any:
+ return func(test_params, *args, **kwargs)
+
+ if self.autorun:
+ wrapper()
+ return wrapper
+
+ @staticmethod
+ def configure_server(domain: str):
+ server = Test.MakeVerifierServerProcess(
+ f'server{TestJA4Fingerprint.server_counter + 1}.{domain}',
+ TestJA4Fingerprint.replay_filepath,
+ other_args='--format \'{url}\'')
+ TestJA4Fingerprint.server_counter += 1
+
+ return server
+
+ def _configure_traffic_server(self, server_one: 'Process'):
+ '''Configure Traffic Server.
+
+ :param server_one: The origin server process.
+ '''
+ ts = Test.MakeATSProcess(f'ts-{TestJA4Fingerprint.ts_counter + 1}',
enable_tls=True)
+ TestJA4Fingerprint.ts_counter += 1
+
+ ts.addDefaultSSLFiles()
+ self._port_one = get_port(ts, 'PortOne')
+ ts.Disk.records_config.update(
+ {
+ 'proxy.config.ssl.server.cert.path': f'{ts.Variables.SSLDir}',
+ 'proxy.config.ssl.server.private_key.path':
f'{ts.Variables.SSLDir}',
+ 'proxy.config.http.server_ports': f'{self._port_one}:ssl',
+ 'proxy.config.diags.debug.enabled': 1,
+ 'proxy.config.diags.debug.tags': 'ja4_fingerprint|http',
+ })
+
+ ts.Disk.remap_config.AddLine(f'map /
http://localhost:{server_one.Variables.http_port}')
+
+ ts.Disk.ssl_multicert_config.AddLine(f'dest_ip=*
ssl_cert_name=server.pem ssl_key_name=server.key')
+
+ ts.Disk.plugin_config.AddLine(f'ja4_fingerprint.so')
+
+ log_path = os.path.join(ts.Variables.LOGDIR, "ja4_fingerprint.log")
+ ts.Disk.File(log_path, id='log_file')
+
+ self._ts = ts
+
+ def _configure_wait_for_log(self, server_one: 'Process'):
+ waiting_tr = Test.AddAwaitFileContainsTestRun(self.name,
self._ts.Disk.log_file.Name, 'JA4')
+ return waiting_tr
+
+
+# Tests start.
+
+
[email protected]('When we send a request, ' \
+ 'then a JA4 header should be attached.')
+def test1(params: TestParams) -> None:
+ client = params['tr'].Processes.Default
+ client.Command = 'curl -k -v
"https://localhost:{0}/resource"'.format(params['port_one'])
+
+ client.ReturnCode = 0
+ client.Streams.stdout += Testers.ContainsExpression(r'Yay!', 'We should
receive the expected body.')
+ params['ts'].Disk.traffic_out.Content += Testers.ContainsExpression(
+ r'JA4 fingerprint:', 'We should receive the expected log message.')