From da7c5b409f166438749f8379d7bd960937ec45ad Mon Sep 17 00:00:00 2001 From: TripleA150 <46062533+TripleA150@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:48:05 +0700 Subject: [PATCH 01/11] ndpi: build against nDPI 4.12, 5.0 and 6.0 nDPI 5.0 removed the protocol bitmask, ndpi_extra_dissection_possible() and the third argument of ndpi_detection_giveup(), and introduced a classification state; nDPI 6.0 added a license argument to ndpi_init_detection_module(). The plugin only built against nDPI 4.x. Wrap these differences in a small header of static inline helpers selected on NDPI_MAJOR so that ndpi.c stays version agnostic. On 5.0 and later a flow is final once classified with no extra dissection pending, as in ndpiReader, or in monitoring state, where the classification can no longer change. The packet cap now also applies to classified flows still under extra dissection. Ticket: 8077 --- plugins/ndpi/Makefile.am | 2 +- plugins/ndpi/ndpi-compat.h | 113 +++++++++++++++++++++++++++++++++++++ plugins/ndpi/ndpi.c | 56 +++++++++--------- 3 files changed, 142 insertions(+), 29 deletions(-) create mode 100644 plugins/ndpi/ndpi-compat.h diff --git a/plugins/ndpi/Makefile.am b/plugins/ndpi/Makefile.am index 4d5dcbe4ed..b28c48323c 100644 --- a/plugins/ndpi/Makefile.am +++ b/plugins/ndpi/Makefile.am @@ -7,7 +7,7 @@ ndpi_la_LIBADD = @NDPI_LIB@ # source directory. ndpi_la_CFLAGS = -I../../rust/gen -I../../rust/dist -ndpi_la_SOURCES = ndpi.c +ndpi_la_SOURCES = ndpi.c ndpi-compat.h install-exec-hook: cd $(DESTDIR)$(pkglibdir) && $(RM) $(pkglib_LTLIBRARIES) diff --git a/plugins/ndpi/ndpi-compat.h b/plugins/ndpi/ndpi-compat.h new file mode 100644 index 0000000000..689944f000 --- /dev/null +++ b/plugins/ndpi/ndpi-compat.h @@ -0,0 +1,113 @@ +/* Copyright (C) 2025 Open Information Security Foundation + * + * You can copy, redistribute or modify this Program under the terms of + * the GNU General Public License version 2 as published by the Free + * Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * version 2 along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +/** + * \file + * + * Compatibility layer over the parts of the nDPI API that differ between + * nDPI 4.12, 5.0 and 6.0, selected at compile time from NDPI_MAJOR so + * that ndpi.c stays version agnostic. + */ + +#ifndef SURICATA_NDPI_COMPAT_H +#define SURICATA_NDPI_COMPAT_H + +#include +#include + +#include "ndpi_api.h" + +#if NDPI_MAJOR < 4 || (NDPI_MAJOR == 4 && NDPI_MINOR < 12) +#error "the nDPI plugin requires nDPI 4.12 or later" +#endif + +/** + * \brief Allocate a detection module. + * + * nDPI 6.0 requires the embedding application to declare under which + * license it uses the library. Not-for-profit use enables every dissector. + */ +static inline struct ndpi_detection_module_struct *NdpiCompatInitModule( + struct ndpi_global_context *g_ctx) +{ +#if NDPI_MAJOR >= 6 + return ndpi_init_detection_module(g_ctx, NDPI_LICENSE_NOT_FOR_PROFIT_LGPL); +#else + return ndpi_init_detection_module(g_ctx); +#endif +} + +/* nDPI 4.x needs every protocol enabled explicitly before finalization, + * later releases enable them by default and removed the bitmask. */ +static inline void NdpiCompatEnableAllProtocols(struct ndpi_detection_module_struct *ndpi) +{ +#if NDPI_MAJOR < 5 + NDPI_PROTOCOL_BITMASK all; + NDPI_BITMASK_SET_ALL(all); + ndpi_set_protocol_detection_bitmask2(ndpi, &all); +#else + (void)ndpi; +#endif +} + +/* Whether nDPI reached a final classification for the flow. Once it has, + * the plugin stops feeding it packets and the keywords start matching. + * + * On nDPI 5.0 and later NDPI_STATE_CLASSIFIED with no extra dissection + * pending is final, as in nDPI's ndpiReader. NDPI_STATE_MONITORING is + * treated as final too: the classification will not change and nDPI + * would only extract more metadata, so stopping there bounds the per + * packet cost at the expense of that metadata. */ +static inline bool NdpiCompatClassificationFinal(struct ndpi_detection_module_struct *ndpi, + struct ndpi_flow_struct *flow, const ndpi_protocol *proto) +{ +#if NDPI_MAJOR >= 5 + (void)ndpi; + if (proto->state == NDPI_STATE_MONITORING) + return true; + return proto->state == NDPI_STATE_CLASSIFIED && flow->extra_packets_func == NULL; +#else + return ndpi_is_protocol_detected(*proto) != 0 && !ndpi_is_proto_unknown(proto->proto) && + !ndpi_extra_dissection_possible(ndpi, flow); +#endif +} + +/* Whether nDPI has no classification for the flow at all, in which case + * giving up on it goes through ndpi_detection_giveup() for a guess. */ +static inline bool NdpiCompatIsUnclassified(const ndpi_protocol *proto) +{ +#if NDPI_MAJOR >= 5 + return proto->state != NDPI_STATE_CLASSIFIED && proto->state != NDPI_STATE_MONITORING; +#else + return ndpi_is_protocol_detected(*proto) == 0; +#endif +} + +/* nDPI 5.0 dropped the output argument telling whether the protocol was + * guessed, the flow now carries it in protocol_was_guessed. */ +static inline ndpi_protocol NdpiCompatGiveup( + struct ndpi_detection_module_struct *ndpi, struct ndpi_flow_struct *flow) +{ +#if NDPI_MAJOR >= 5 + return ndpi_detection_giveup(ndpi, flow); +#else + uint8_t protocol_was_guessed; + return ndpi_detection_giveup(ndpi, flow, &protocol_was_guessed); +#endif +} + +#endif /* SURICATA_NDPI_COMPAT_H */ diff --git a/plugins/ndpi/ndpi.c b/plugins/ndpi/ndpi.c index c8e5caed1c..f3e1ff2246 100644 --- a/plugins/ndpi/ndpi.c +++ b/plugins/ndpi/ndpi.c @@ -32,7 +32,7 @@ #include "thread-storage.h" #include "util-debug.h" -#include "ndpi_api.h" +#include "ndpi-compat.h" static SCThreadStorageId thread_storage_id = { .id = -1 }; static SCFlowStorageId flow_storage_id = { .id = -1 }; @@ -81,6 +81,20 @@ static inline struct NdpiFlowContext *NdpiGetFlowContext(const Flow *f) return SCFlowGetStorageById(f, flow_storage_id); } +/** + * Allocate and finalize a detection module with every protocol enabled. + */ +static struct ndpi_detection_module_struct *NdpiModuleNew(void) +{ + struct ndpi_detection_module_struct *ndpi = NdpiCompatInitModule(NULL); + if (ndpi == NULL) + return NULL; + + NdpiCompatEnableAllProtocols(ndpi); + ndpi_finalize_initialization(ndpi); + return ndpi; +} + static void ThreadStorageFree(void *ptr) { SCLogDebug("Free'ing nDPI thread storage"); @@ -166,20 +180,20 @@ static void OnFlowUpdate(ThreadVars *tv, Flow *f, Packet *p, void *_data) flowctx->detected_l7_protocol = ndpi_detection_process_packet( threadctx->ndpi, flowctx->ndpi_flow, ip_ptr, ip_len, time_ms, NULL); - if (ndpi_is_protocol_detected(flowctx->detected_l7_protocol) != 0) { - if (!ndpi_is_proto_unknown(flowctx->detected_l7_protocol.proto)) { - if (!ndpi_extra_dissection_possible(threadctx->ndpi, flowctx->ndpi_flow)) - flowctx->detection_completed = true; - } + if (NdpiCompatClassificationFinal( + threadctx->ndpi, flowctx->ndpi_flow, &flowctx->detected_l7_protocol)) { + flowctx->detection_completed = true; } else { - uint16_t max_num_pkts = (flow_proto == IPPROTO_UDP) ? 8 : 24; + /* stop feeding nDPI after a few packets, taking its best guess + * for flows it could not classify */ + const uint16_t max_num_pkts = (flow_proto == IPPROTO_UDP) ? 8 : 24; if ((SCFlowGetToServerPacketCount(f) + SCFlowGetToClientPacketCount(f)) > max_num_pkts) { - uint8_t proto_guessed; - - flowctx->detected_l7_protocol = - ndpi_detection_giveup(threadctx->ndpi, flowctx->ndpi_flow, &proto_guessed); + if (NdpiCompatIsUnclassified(&flowctx->detected_l7_protocol)) { + flowctx->detected_l7_protocol = + NdpiCompatGiveup(threadctx->ndpi, flowctx->ndpi_flow); + } flowctx->detection_completed = true; } } @@ -209,14 +223,10 @@ static void OnThreadInit(ThreadVars *tv, void *_data) if (context == NULL) { FatalError("Failed to allocate nDPI thread context"); } - context->ndpi = ndpi_init_detection_module(NULL); + context->ndpi = NdpiModuleNew(); if (context->ndpi == NULL) { FatalError("Failed to initialize nDPI detection module"); } - NDPI_PROTOCOL_BITMASK protos; - NDPI_BITMASK_SET_ALL(protos); - ndpi_set_protocol_detection_bitmask2(context->ndpi, &protos); - ndpi_finalize_initialization(context->ndpi); SCThreadSetStorageById(tv, thread_storage_id, context); } @@ -272,17 +282,12 @@ static DetectnDPIProtocolData *DetectnDPIProtocolParse(const char *arg, bool neg struct ndpi_detection_module_struct *ndpi_struct; ndpi_master_app_protocol l7_protocol; char *l7_protocol_name = (char *)arg; - NDPI_PROTOCOL_BITMASK all; /* convert protocol name (string) to ID */ - ndpi_struct = ndpi_init_detection_module(NULL); + ndpi_struct = NdpiModuleNew(); if (unlikely(ndpi_struct == NULL)) return NULL; - NDPI_BITMASK_SET_ALL(all); - ndpi_set_protocol_detection_bitmask2(ndpi_struct, &all); - ndpi_finalize_initialization(ndpi_struct); - l7_protocol = ndpi_get_protocol_by_name(ndpi_struct, l7_protocol_name); ndpi_exit_detection_module(ndpi_struct); @@ -401,16 +406,11 @@ static DetectnDPIRiskData *DetectnDPIRiskParse(const char *arg, bool negate) DetectnDPIRiskData *data; struct ndpi_detection_module_struct *ndpi_struct; ndpi_risk risk_mask; - NDPI_PROTOCOL_BITMASK all; /* convert list of risk names (string) to mask */ - ndpi_struct = ndpi_init_detection_module(NULL); + ndpi_struct = NdpiModuleNew(); if (unlikely(ndpi_struct == NULL)) return NULL; - - NDPI_BITMASK_SET_ALL(all); - ndpi_set_protocol_detection_bitmask2(ndpi_struct, &all); - ndpi_finalize_initialization(ndpi_struct); ndpi_exit_detection_module(ndpi_struct); if (isdigit(arg[0])) From 8089777894cab414bf1c81b4253e166ad3a3c32f Mon Sep 17 00:00:00 2001 From: TripleA150 <46062533+TripleA150@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:00:18 +0700 Subject: [PATCH 02/11] ndpi: no detection module needed to parse risks ndpi_code2risk() does not need a detection module, yet one was created, finalized and destroyed for every ndpi-risk keyword. Ticket: 8077 --- plugins/ndpi/ndpi.c | 6 ------ 1 file changed, 6 deletions(-) diff --git a/plugins/ndpi/ndpi.c b/plugins/ndpi/ndpi.c index f3e1ff2246..0ecaea3d60 100644 --- a/plugins/ndpi/ndpi.c +++ b/plugins/ndpi/ndpi.c @@ -404,15 +404,9 @@ static int DetectnDPIRiskPacketMatch( static DetectnDPIRiskData *DetectnDPIRiskParse(const char *arg, bool negate) { DetectnDPIRiskData *data; - struct ndpi_detection_module_struct *ndpi_struct; ndpi_risk risk_mask; /* convert list of risk names (string) to mask */ - ndpi_struct = NdpiModuleNew(); - if (unlikely(ndpi_struct == NULL)) - return NULL; - ndpi_exit_detection_module(ndpi_struct); - if (isdigit(arg[0])) risk_mask = atoll(arg); else { From 79d403437673ef271b4e164b050fb47c10eead67 Mon Sep 17 00:00:00 2001 From: TripleA150 <46062533+TripleA150@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:00:59 +0700 Subject: [PATCH 03/11] ndpi: share LRU caches across worker threads nDPI correlates flows through LRU caches (DNS to TLS, STUN to RTP, TLS certificates, ...) that are private to each worker's detection module. As Suricata spreads a host's flows over all workers, these correlations mostly missed. Create a global context at plugin init, attach the worker modules to it and make every cache global. Failures are logged and non-fatal. nDPI creates a shared cache on first finalization without locking, which is fine as thread init callbacks run sequentially from TmThreadCreate(). Ticket: 8077 --- plugins/ndpi/ndpi.c | 51 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/plugins/ndpi/ndpi.c b/plugins/ndpi/ndpi.c index 0ecaea3d60..98ab155add 100644 --- a/plugins/ndpi/ndpi.c +++ b/plugins/ndpi/ndpi.c @@ -38,6 +38,7 @@ static SCThreadStorageId thread_storage_id = { .id = -1 }; static SCFlowStorageId flow_storage_id = { .id = -1 }; static int ndpi_protocol_keyword_id = -1; static int ndpi_risk_keyword_id = -1; +static struct ndpi_global_context *ndpi_g_ctx = NULL; struct NdpiThreadContext { struct ndpi_detection_module_struct *ndpi; @@ -81,16 +82,49 @@ static inline struct NdpiFlowContext *NdpiGetFlowContext(const Flow *f) return SCFlowGetStorageById(f, flow_storage_id); } +/* nDPI correlates flows through LRU caches (DNS to TLS, STUN to RTP, ...) + * that are private to a detection module unless made global through a + * shared context. Suricata spreads a host's flows over all its workers, + * so without sharing a correlation never leaves the worker that saw the + * first flow. */ +static const char *ndpi_shared_lru_caches[] = { + "lru.ookla.scope", + "lru.bittorrent.scope", + "lru.stun.scope", + "lru.tls_cert.scope", + "lru.mining.scope", + "lru.msteams.scope", + "lru.fpc_dns.scope", + "lru.signal.scope", +}; + /** * Allocate and finalize a detection module with every protocol enabled. + * Worker modules share their LRU caches through the global context, the + * throwaway modules used while parsing rules don't need to. + * + * nDPI creates a shared cache on the first finalization without locking, + * which is fine as thread init callbacks run sequentially from + * TmThreadCreate(). */ -static struct ndpi_detection_module_struct *NdpiModuleNew(void) +static struct ndpi_detection_module_struct *NdpiModuleNew(bool shared_caches) { - struct ndpi_detection_module_struct *ndpi = NdpiCompatInitModule(NULL); + struct ndpi_global_context *g_ctx = shared_caches ? ndpi_g_ctx : NULL; + struct ndpi_detection_module_struct *ndpi = NdpiCompatInitModule(g_ctx); if (ndpi == NULL) return NULL; NdpiCompatEnableAllProtocols(ndpi); + + if (g_ctx != NULL) { + for (size_t i = 0; i < ARRAY_SIZE(ndpi_shared_lru_caches); i++) { + if (ndpi_set_config(ndpi, NULL, ndpi_shared_lru_caches[i], "1") != NDPI_CFG_OK) { + SCLogWarning("Failed to share the nDPI \"%s\" cache between threads", + ndpi_shared_lru_caches[i]); + } + } + } + ndpi_finalize_initialization(ndpi); return ndpi; } @@ -223,7 +257,7 @@ static void OnThreadInit(ThreadVars *tv, void *_data) if (context == NULL) { FatalError("Failed to allocate nDPI thread context"); } - context->ndpi = NdpiModuleNew(); + context->ndpi = NdpiModuleNew(true); if (context->ndpi == NULL) { FatalError("Failed to initialize nDPI detection module"); } @@ -284,7 +318,7 @@ static DetectnDPIProtocolData *DetectnDPIProtocolParse(const char *arg, bool neg char *l7_protocol_name = (char *)arg; /* convert protocol name (string) to ID */ - ndpi_struct = NdpiModuleNew(); + ndpi_struct = NdpiModuleNew(false); if (unlikely(ndpi_struct == NULL)) return NULL; @@ -566,6 +600,15 @@ static void NdpiInit(void) { SCLogDebug("Initializing nDPI plugin"); + /* Global context shared by the worker modules, see NdpiModuleNew(). + * NULL if nDPI was built without global context support. There is + * no plugin deinit hook, so it is never released. */ + ndpi_g_ctx = ndpi_global_init(); + if (ndpi_g_ctx == NULL) { + SCLogWarning("Failed to initialize the nDPI global context: " + "per-thread nDPI caches will not be shared"); + } + /* Register thread storage. */ thread_storage_id = SCThreadStorageRegister("ndpi", ThreadStorageFree); if (thread_storage_id.id < 0) { From bb3b0335066c97648ab59a63552cd61e3baf2916 Mon Sep 17 00:00:00 2001 From: TripleA150 <46062533+TripleA150@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:01:18 +0700 Subject: [PATCH 04/11] ndpi: pass the packet direction to nDPI Suricata knows the direction of every packet, so tell nDPI instead of letting it guess. Whether the flow beginning was seen is left unknown as the flow API exposes no reliable flag for it. Ticket: 8077 --- plugins/ndpi/ndpi.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/plugins/ndpi/ndpi.c b/plugins/ndpi/ndpi.c index 98ab155add..d3c717b645 100644 --- a/plugins/ndpi/ndpi.c +++ b/plugins/ndpi/ndpi.c @@ -208,11 +208,20 @@ static void OnFlowUpdate(ThreadVars *tv, Flow *f, Packet *p, void *_data) if (!flowctx->detection_completed && ip_ptr != NULL && ip_len > 0) { uint64_t time_ms = ((uint64_t)p->ts.secs) * 1000 + p->ts.usecs / 1000; + struct ndpi_flow_input_info input_info; SCLogDebug("Performing nDPI detection..."); + /* Suricata knows the packet direction, spare nDPI from guessing it. + * Whether the flow beginning was seen is left unknown as the flow + * API exposes no reliable flag for it. */ + memset(&input_info, 0, sizeof(input_info)); + input_info.seen_flow_beginning = NDPI_FLOW_BEGINNING_UNKNOWN; + input_info.in_pkt_dir = + PKT_IS_TOSERVER(p) ? NDPI_IN_PKT_DIR_C_TO_S : NDPI_IN_PKT_DIR_S_TO_C; + flowctx->detected_l7_protocol = ndpi_detection_process_packet( - threadctx->ndpi, flowctx->ndpi_flow, ip_ptr, ip_len, time_ms, NULL); + threadctx->ndpi, flowctx->ndpi_flow, ip_ptr, ip_len, time_ms, &input_info); if (NdpiCompatClassificationFinal( threadctx->ndpi, flowctx->ndpi_flow, &flowctx->detected_l7_protocol)) { From d1e9db7bb8d74ecbc494d548e2a598cc64445373 Mon Sep 17 00:00:00 2001 From: TripleA150 <46062533+TripleA150@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:02:50 +0700 Subject: [PATCH 05/11] ndpi: add ndpi.license configuration option nDPI 6.0 requires the application to declare under which license the library is used and, for for-profit use without an agreement with ntop, does not load its dual-licensed dissectors (DHCP, DNS, QUIC and TLS as of 6.0), so rules on those protocols never fire. Expose this as "ndpi.license" with the values not-for-profit (the default), for-profit and for-profit-dual. An invalid value is fatal. Older nDPI releases accept and ignore the option with a warning. Ticket: 8077 --- plugins/ndpi/ndpi-compat.h | 30 ++++++++++++++++++-------- plugins/ndpi/ndpi.c | 43 +++++++++++++++++++++++++++++++++++++- suricata.yaml.in | 17 +++++++++++++++ 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/plugins/ndpi/ndpi-compat.h b/plugins/ndpi/ndpi-compat.h index 689944f000..37eb5a6801 100644 --- a/plugins/ndpi/ndpi-compat.h +++ b/plugins/ndpi/ndpi-compat.h @@ -35,18 +35,30 @@ #error "the nDPI plugin requires nDPI 4.12 or later" #endif -/** - * \brief Allocate a detection module. - * - * nDPI 6.0 requires the embedding application to declare under which - * license it uses the library. Not-for-profit use enables every dissector. - */ +/* nDPI 6.0 requires the embedding application to declare under which + * license it uses the library; earlier releases have no notion of it. */ +#if NDPI_MAJOR >= 6 +#define NDPI_COMPAT_HAS_LICENSE 1 +typedef enum ndpi_license_type NdpiCompatLicense; +#define NDPI_COMPAT_LICENSE_NOT_FOR_PROFIT NDPI_LICENSE_NOT_FOR_PROFIT_LGPL +#define NDPI_COMPAT_LICENSE_FOR_PROFIT NDPI_LICENSE_FOR_PROFIT_LGPL +#define NDPI_COMPAT_LICENSE_FOR_PROFIT_DUAL NDPI_LICENSE_FOR_PROFIT_DUAL_LICENSE +#else +#define NDPI_COMPAT_HAS_LICENSE 0 +/* placeholder so the license plumbing builds, the value is ignored */ +typedef int NdpiCompatLicense; +#define NDPI_COMPAT_LICENSE_NOT_FOR_PROFIT 0 +#endif + +/* Allocate a detection module. The license is ignored on nDPI releases + * predating the declaration. */ static inline struct ndpi_detection_module_struct *NdpiCompatInitModule( - struct ndpi_global_context *g_ctx) + struct ndpi_global_context *g_ctx, NdpiCompatLicense license) { -#if NDPI_MAJOR >= 6 - return ndpi_init_detection_module(g_ctx, NDPI_LICENSE_NOT_FOR_PROFIT_LGPL); +#if NDPI_COMPAT_HAS_LICENSE + return ndpi_init_detection_module(g_ctx, license); #else + (void)license; return ndpi_init_detection_module(g_ctx); #endif } diff --git a/plugins/ndpi/ndpi.c b/plugins/ndpi/ndpi.c index d3c717b645..af5e63f5f5 100644 --- a/plugins/ndpi/ndpi.c +++ b/plugins/ndpi/ndpi.c @@ -22,6 +22,7 @@ #include "suricata-common.h" #include "suricata-plugin.h" +#include "conf.h" #include "detect-engine-helper.h" #include "detect-parse.h" #include "flow.h" @@ -39,6 +40,7 @@ static SCFlowStorageId flow_storage_id = { .id = -1 }; static int ndpi_protocol_keyword_id = -1; static int ndpi_risk_keyword_id = -1; static struct ndpi_global_context *ndpi_g_ctx = NULL; +static NdpiCompatLicense ndpi_license = NDPI_COMPAT_LICENSE_NOT_FOR_PROFIT; struct NdpiThreadContext { struct ndpi_detection_module_struct *ndpi; @@ -82,6 +84,43 @@ static inline struct NdpiFlowContext *NdpiGetFlowContext(const Flow *f) return SCFlowGetStorageById(f, flow_storage_id); } +/** + * Resolve "ndpi.license". nDPI 6.0 does not load its dual-licensed + * dissectors (DHCP, DNS, QUIC and TLS as of 6.0) for for-profit use + * without an agreement with ntop. Not-for-profit is the default so the + * plugin works out of the box; an invalid value is fatal. + */ +static NdpiCompatLicense NdpiResolveLicense(void) +{ + const char *license = NULL; + + if (SCConfGet("ndpi.license", &license) != 1 || license == NULL) + return NDPI_COMPAT_LICENSE_NOT_FOR_PROFIT; + +#if NDPI_COMPAT_HAS_LICENSE + if (strcmp(license, "not-for-profit") == 0) + return NDPI_COMPAT_LICENSE_NOT_FOR_PROFIT; + + if (strcmp(license, "for-profit") == 0) { + SCLogWarning("ndpi.license is \"for-profit\": nDPI will not load its dual-licensed " + "dissectors (DHCP, DNS, QUIC and TLS as of nDPI 6.0)"); + return NDPI_COMPAT_LICENSE_FOR_PROFIT; + } + + if (strcmp(license, "for-profit-dual") == 0) + return NDPI_COMPAT_LICENSE_FOR_PROFIT_DUAL; + + FatalError("invalid ndpi.license value \"%s\": expected \"not-for-profit\", " + "\"for-profit\" or \"for-profit-dual\"", + license); +#else + SCLogWarning("ndpi.license has no effect with nDPI %d.%d, the license declaration was " + "introduced in nDPI 6.0", + NDPI_MAJOR, NDPI_MINOR); +#endif + return NDPI_COMPAT_LICENSE_NOT_FOR_PROFIT; +} + /* nDPI correlates flows through LRU caches (DNS to TLS, STUN to RTP, ...) * that are private to a detection module unless made global through a * shared context. Suricata spreads a host's flows over all its workers, @@ -110,7 +149,7 @@ static const char *ndpi_shared_lru_caches[] = { static struct ndpi_detection_module_struct *NdpiModuleNew(bool shared_caches) { struct ndpi_global_context *g_ctx = shared_caches ? ndpi_g_ctx : NULL; - struct ndpi_detection_module_struct *ndpi = NdpiCompatInitModule(g_ctx); + struct ndpi_detection_module_struct *ndpi = NdpiCompatInitModule(g_ctx, ndpi_license); if (ndpi == NULL) return NULL; @@ -609,6 +648,8 @@ static void NdpiInit(void) { SCLogDebug("Initializing nDPI plugin"); + ndpi_license = NdpiResolveLicense(); + /* Global context shared by the worker modules, see NdpiModuleNew(). * NULL if nDPI was built without global context support. There is * no plugin deinit hook, so it is never released. */ diff --git a/suricata.yaml.in b/suricata.yaml.in index bb96a4fa27..34e25fa040 100644 --- a/suricata.yaml.in +++ b/suricata.yaml.in @@ -85,6 +85,23 @@ plugins: @ndpi_comment@- @prefix@/lib/@PACKAGE_NAME@/ndpi.so # - /path/to/plugin.so +# nDPI plugin settings, only used when the nDPI plugin is loaded above. +ndpi: + # nDPI 6.0 and later require the application to declare under which + # license the library is used. Has no effect with older nDPI releases. + # + # not-for-profit : not-for-profit use, every dissector is enabled. + # This is the default. + # for-profit : for-profit use without a license agreement with + # ntop. nDPI does not load its dual-licensed + # dissectors (DHCP, DNS, QUIC and TLS as of nDPI 6.0), + # so rules matching on those protocols never fire. + # for-profit-dual : for-profit use with a license agreement signed + # with ntop for the dual-licensed components. + # + # See https://www.ntop.org/announcing-ndpi-dual-license-change/ + #license: not-for-profit + # Configure the type of alert (and other) logging you would like. outputs: # a line based alerts log similar to Snort's fast.log From b5dd5fad2705c9cf95399e21b6cb52d606cbd0d7 Mon Sep 17 00:00:00 2001 From: TripleA150 <46062533+TripleA150@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:02:50 +0700 Subject: [PATCH 06/11] configure: fix enable_dpi typo in the nDPI checks The --disable-shared check tested the undefined $enable_dpi and never fired. Ticket: 8077 --- configure.ac | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configure.ac b/configure.ac index b51ab4d47d..9553ad5902 100644 --- a/configure.ac +++ b/configure.ac @@ -2271,7 +2271,7 @@ fi exit 1 ]) - AS_IF([test "x$enable_dpi" = "xyes"], [ + AS_IF([test "x$enable_ndpi" = "xyes"], [ if test "x$enable_shared" = "xno"; then echo echo " ERROR! ndpi cannot be enabled with --disable-shared" From c0506887da5446726937ea8b55dc52a5c0e85305 Mon Sep 17 00:00:00 2001 From: TripleA150 <46062533+TripleA150@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:03:05 +0700 Subject: [PATCH 07/11] configure: check the nDPI version Report an unsupported (or not yet configured) nDPI tree at configure time instead of failing deep into the build. Ticket: 8077 --- configure.ac | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/configure.ac b/configure.ac index 9553ad5902..5b9d1a0b6a 100644 --- a/configure.ac +++ b/configure.ac @@ -2292,6 +2292,18 @@ fi CPPFLAGS="${CPPFLAGS} -I$NDPI_HOME/src/include" NDPI_LIB="$NDPI_HOME/src/lib/libndpi.a" AC_SUBST([NDPI_LIB]) + + # see plugins/ndpi/ndpi-compat.h for the supported releases + AC_MSG_CHECKING([for nDPI 4.12 or later]) + AC_COMPILE_IFELSE([AC_LANG_PROGRAM([[ + #include "ndpi_define.h" + #if NDPI_MAJOR < 4 || (NDPI_MAJOR == 4 && NDPI_MINOR < 12) + #error "nDPI too old" + #endif + ]], [[]])], + [AC_MSG_RESULT([yes])], + [AC_MSG_RESULT([no]) + AC_MSG_ERROR([the nDPI plugin requires nDPI 4.12 or later; make sure the nDPI source tree at $NDPI_HOME has been configured])]) else AC_MSG_RESULT(not found) enable_ndpi="no" From cdfb39544d7868a59c2c58b48bdf154e8539a685 Mon Sep 17 00:00:00 2001 From: TripleA150 <46062533+TripleA150@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:50:14 +0700 Subject: [PATCH 08/11] doc/ndpi: nDPI versions and ndpi.license option Ticket: 8077 --- doc/userguide/plugins/ndpi.rst | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/doc/userguide/plugins/ndpi.rst b/doc/userguide/plugins/ndpi.rst index a07f2f5707..7d7094013d 100644 --- a/doc/userguide/plugins/ndpi.rst +++ b/doc/userguide/plugins/ndpi.rst @@ -6,6 +6,9 @@ nDPI Installation ************ +The plugin supports nDPI 4.12, 5.0 and 6.0. The nDPI source tree has to +be configured and built before building Suricata against it. + Before using nDPI, Suricata must be built with nDPI support, for example: @@ -26,6 +29,40 @@ building Suricata with nDPI support. For more information on nDPI, see https://www.ntop.org/products/deep-packet-inspection/ndpi/. +Configuration +************* + +Starting with nDPI 6.0, the library has to be told under which license it +is used, as some of its dissectors are dual-licensed by ntop. The plugin +exposes this through the optional ``ndpi`` section of ``suricata.yaml``: + +.. code-block:: yaml + + ndpi: + license: not-for-profit + +Accepted values: + +``not-for-profit`` + Not-for-profit use. Every dissector is enabled. This is the default when + the option is absent. + +``for-profit`` + For-profit use without a license agreement with ntop. nDPI does not load + its dual-licensed dissectors, DHCP, DNS, QUIC and TLS as of nDPI 6.0, so + rules matching on those protocols never fire. The plugin logs a warning + at startup when this value is selected. + +``for-profit-dual`` + For-profit use with a license agreement signed with ntop covering the + dual-licensed components. Every dissector is enabled. + +An invalid value is a fatal error. With nDPI releases older than 6.0 the +option has no effect and a warning is logged when it is set. + +See https://www.ntop.org/announcing-ndpi-dual-license-change/ for the +licensing terms themselves. + Keywords ******** From 85b84f124cfc58b421859715f9e4cd75e754fb4e Mon Sep 17 00:00:00 2001 From: TripleA150 <46062533+TripleA150@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:51:04 +0700 Subject: [PATCH 09/11] github-ci: build ndpi plugin with nDPI 4.12/5.0/6.0 Build the plugin against every nDPI release it supports. Ticket: 8077 --- .github/workflows/builds.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 3034704b10..7d2432cc02 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -726,10 +726,15 @@ jobs: - run: suricata-update -V centos-stream9: - name: CentOS Stream 9 + name: CentOS Stream 9 (nDPI ${{ matrix.ndpi }}) runs-on: ubuntu-latest container: quay.io/centos/centos:stream9 needs: [prepare-deps, ubuntu-22-04-dist] + strategy: + fail-fast: false + matrix: + # nDPI releases supported by the plugin + ndpi: [ "4.14", "5.0", "6.0" ] steps: # Cache Rust stuff. - name: Cache cargo registry @@ -786,18 +791,18 @@ jobs: with: name: dist - - name: Build and install nDPI + - name: Build nDPI ${{ matrix.ndpi }} run: | - curl -OL https://github.com/ntop/nDPI/archive/refs/tags/4.14.tar.gz - tar xvf 4.14.tar.gz - cd nDPI-4.14 + curl -OL https://github.com/ntop/nDPI/archive/refs/tags/${{ matrix.ndpi }}.tar.gz + tar xvf ${{ matrix.ndpi }}.tar.gz + cd nDPI-${{ matrix.ndpi }} ./autogen.sh ./configure make -j ${{ env.CPUS }} - run: tar zxvf suricata-*.tar.gz --strip-components=1 - name: ./configure - run: CFLAGS="${DEFAULT_CFLAGS}" ./configure --enable-ndpi --with-ndpi=$(pwd)/nDPI-4.14 --enable-debug --enable-qa-simulation + run: CFLAGS="${DEFAULT_CFLAGS}" ./configure --enable-ndpi --with-ndpi=$(pwd)/nDPI-${{ matrix.ndpi }} --enable-debug --enable-qa-simulation - run: make -j ${{ env.CPUS }} - run: make install - run: make install-conf From a52a2905426b36f264774062db116e375795a5aa Mon Sep 17 00:00:00 2001 From: TripleA150 <46062533+TripleA150@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:28:01 +0700 Subject: [PATCH 10/11] ndpi: check ndpi_finalize_initialization result It returns -1 when the dissectors could not be initialized, in which case the module was used anyway. Free it and fail module creation instead; the callers already handle that. Ticket: 8077 --- plugins/ndpi/ndpi.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/ndpi/ndpi.c b/plugins/ndpi/ndpi.c index af5e63f5f5..7fda90c49f 100644 --- a/plugins/ndpi/ndpi.c +++ b/plugins/ndpi/ndpi.c @@ -164,7 +164,10 @@ static struct ndpi_detection_module_struct *NdpiModuleNew(bool shared_caches) } } - ndpi_finalize_initialization(ndpi); + if (ndpi_finalize_initialization(ndpi) != 0) { + ndpi_exit_detection_module(ndpi); + return NULL; + } return ndpi; } From 07225b9527227ab3f2848f9b5ee2329c492dd938 Mon Sep 17 00:00:00 2001 From: TripleA150 <46062533+TripleA150@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:06:14 +0700 Subject: [PATCH 11/11] ndpi: validate the JSON nDPI produces before adding it to EVE The output of ndpi_dpi2json() is inserted into the EVE record as is, so a malformed fragment takes the whole record down with it. nDPI 6.0 does produce one: its serializer sizes the buffer for the unescaped length of a string, its new string escaper expands non printable characters and returns -1 when it runs out of room, and the callers add that -1 to the buffer position. A DNS flow whose name is full of non printable characters hits this, which is what the dns-truncated-rname suricata-verify test exercises. Check the syntax of the fragment before inserting it and drop the nDPI object of the record otherwise, with a warning the first time. The same applies when SCJbSetFormatted() rejects it as invalid UTF-8. The fragment is also copied to a terminated string first, as nDPI only terminates its buffer when there is room left for it. Also start the serializer with a 4KiB buffer through ndpi_init_serializer_ll() rather than nDPI's 256 byte default, so the escaper has room to expand strings in the common case. The strings nDPI serializes are not bounded, so the check stays as the safety net. Ticket: 8077 --- plugins/ndpi/ndpi.c | 185 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 3 deletions(-) diff --git a/plugins/ndpi/ndpi.c b/plugins/ndpi/ndpi.c index 7fda90c49f..6a0f65775d 100644 --- a/plugins/ndpi/ndpi.c +++ b/plugins/ndpi/ndpi.c @@ -41,6 +41,14 @@ static int ndpi_protocol_keyword_id = -1; static int ndpi_risk_keyword_id = -1; static struct ndpi_global_context *ndpi_g_ctx = NULL; static NdpiCompatLicense ndpi_license = NDPI_COMPAT_LICENSE_NOT_FOR_PROFIT; +/* EVE objects dropped because nDPI produced malformed JSON */ +static SC_ATOMIC_DECL_AND_INIT_WITH_VAL(unsigned int, ndpi_json_dropped, 0); + +/* Initial size of the buffer ndpi_dpi2json() writes to. nDPI grows it by + * the unescaped length of a string only, while escaping can expand a byte + * to six; starting with a buffer large enough for the common case keeps + * its escaper from running out of room. */ +#define NDPI_EVE_SERIALIZER_SIZE 4096 struct NdpiThreadContext { struct ndpi_detection_module_struct *ndpi; @@ -574,6 +582,159 @@ static void DetectnDPIRiskFree(DetectEngineCtx *de_ctx, void *ptr) SCFree(ptr); } +/* Syntax check of the JSON nDPI produces for a flow. It is inserted into + * the EVE record as is, so a malformed fragment would take the whole + * record down with it, and nDPI has been seen producing one (6.0 corrupts + * its output when escaping strings with non printable characters). */ + +typedef struct NdpiJsonCursor_ { + const char *p; + const char *end; +} NdpiJsonCursor; + +static bool NdpiJsonValue(NdpiJsonCursor *c, int depth); + +static void NdpiJsonSkipSpace(NdpiJsonCursor *c) +{ + while (c->p < c->end && (*c->p == ' ' || *c->p == '\t' || *c->p == '\n' || *c->p == '\r')) + c->p++; +} + +static bool NdpiJsonString(NdpiJsonCursor *c) +{ + if (c->p >= c->end || *c->p != '"') + return false; + c->p++; + + while (c->p < c->end) { + const unsigned char ch = (unsigned char)*c->p++; + if (ch == '"') + return true; + if (ch < 0x20) + return false; + if (ch != '\\') + continue; + + if (c->p >= c->end) + return false; + switch (*c->p++) { + case '"': + case '\\': + case '/': + case 'b': + case 'f': + case 'n': + case 'r': + case 't': + break; + case 'u': + for (int i = 0; i < 4; i++, c->p++) { + if (c->p >= c->end || !isxdigit((unsigned char)*c->p)) + return false; + } + break; + default: + return false; + } + } + return false; +} + +static bool NdpiJsonLiteral(NdpiJsonCursor *c, const char *lit) +{ + const size_t len = strlen(lit); + if ((size_t)(c->end - c->p) < len || memcmp(c->p, lit, len) != 0) + return false; + c->p += len; + return true; +} + +static bool NdpiJsonNumber(NdpiJsonCursor *c) +{ + bool digits = false; + while (c->p < c->end && (isdigit((unsigned char)*c->p) || *c->p == '-' || *c->p == '+' || + *c->p == '.' || *c->p == 'e' || *c->p == 'E')) { + digits |= isdigit((unsigned char)*c->p) != 0; + c->p++; + } + return digits; +} + +/** + * Members of an object or elements of an array, up to the closing + * bracket, or up to the end of the input for a bare object body. + */ +static bool NdpiJsonMembers(NdpiJsonCursor *c, int depth, char closing) +{ + NdpiJsonSkipSpace(c); + if (closing != '\0' && c->p < c->end && *c->p == closing) { + c->p++; + return true; + } + + for (;;) { + if (closing != ']') { + if (!NdpiJsonString(c)) + return false; + NdpiJsonSkipSpace(c); + if (c->p >= c->end || *c->p++ != ':') + return false; + } + if (!NdpiJsonValue(c, depth)) + return false; + + NdpiJsonSkipSpace(c); + if (c->p >= c->end) + return closing == '\0'; + if (closing != '\0' && *c->p == closing) { + c->p++; + return true; + } + if (*c->p++ != ',') + return false; + NdpiJsonSkipSpace(c); + } +} + +static bool NdpiJsonValue(NdpiJsonCursor *c, int depth) +{ + if (depth > 32) + return false; + + NdpiJsonSkipSpace(c); + if (c->p >= c->end) + return false; + + switch (*c->p) { + case '{': + c->p++; + return NdpiJsonMembers(c, depth + 1, '}'); + case '[': + c->p++; + return NdpiJsonMembers(c, depth + 1, ']'); + case '"': + return NdpiJsonString(c); + case 't': + return NdpiJsonLiteral(c, "true"); + case 'f': + return NdpiJsonLiteral(c, "false"); + case 'n': + return NdpiJsonLiteral(c, "null"); + default: + return NdpiJsonNumber(c); + } +} + +/** + * \brief Whether buf holds well formed object members, i.e. the body of + * a JSON object without its braces, as SCJbSetFormatted() expects. + */ +static bool NdpiJsonFragmentValid(const char *buf, uint32_t len) +{ + NdpiJsonCursor c = { .p = buf, .end = buf + len }; + return NdpiJsonMembers(&c, 1, '\0'); +} + static void EveCallback(ThreadVars *tv, const Packet *p, Flow *f, SCJsonBuilder *jb, void *data) { /* Adding ndpi info to EVE requires a flow. */ @@ -597,7 +758,8 @@ static void EveCallback(ThreadVars *tv, const Packet *p, Flow *f, SCJsonBuilder SCLogDebug("EveCallback: tv=%p, p=%p, f=%p", tv, p, f); - if (ndpi_init_serializer(&serializer, ndpi_serialization_format_inner_json) != 0) { + if (ndpi_init_serializer_ll(&serializer, ndpi_serialization_format_inner_json, + NDPI_EVE_SERIALIZER_SIZE) != 0) { SCLogDebug("Failed to initialize nDPI serializer"); return; } @@ -608,8 +770,25 @@ static void EveCallback(ThreadVars *tv, const Packet *p, Flow *f, SCJsonBuilder buffer = ndpi_serializer_get_buffer(&serializer, &buffer_len); if (buffer != NULL && buffer_len > 0) { - /* Inject the nDPI JSON to the JsonBuilder */ - SCJbSetFormatted(jb, buffer); + bool valid = NdpiJsonFragmentValid(buffer, buffer_len); + if (valid) { + /* SCJbSetFormatted() takes a C string and nDPI only terminates + * its buffer when there is room left for it */ + char stack_json[2048]; + char *json = buffer_len < sizeof(stack_json) ? stack_json : SCMalloc(buffer_len + 1); + if (json != NULL) { + memcpy(json, buffer, buffer_len); + json[buffer_len] = '\0'; + /* also rejects invalid UTF-8, without inserting anything */ + valid = SCJbSetFormatted(jb, json); + if (json != stack_json) + SCFree(json); + } + } + if (!valid && SC_ATOMIC_ADD(ndpi_json_dropped, 1) == 0) { + SCLogWarning("nDPI produced malformed JSON for a flow, dropping its EVE object " + "(only reported once)"); + } } ndpi_term_serializer(&serializer);