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.')

Reply via email to