From 0876578da2cba87ff374888fa45b2003dd8754f4 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Fri, 24 Jul 2026 00:09:54 +0200 Subject: [PATCH 01/27] conf: better handling of set In the case of --set=foo.0=bar, the 0 was not creating a sequence if ever the sequence is not existing. Ticket: 8606 --- src/conf.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/conf.c b/src/conf.c index 4dc511808136..64d0448a4a65 100644 --- a/src/conf.c +++ b/src/conf.c @@ -97,6 +97,18 @@ SCConfNode *SCConfNodeGetNodeOrCreate(SCConfNode *parent, const char *name, int node->parent = parent; node->final = final; TAILQ_INSERT_TAIL(&parent->head, node, next); + + /* Numeric-only key implies the parent is a sequence (yaml + * "- foo" produces the same shape). This lets --set style + * overrides populate list-typed config nodes. */ + if (parent != NULL) { + const char *p = key; + while (*p != '\0' && isdigit((unsigned char)*p)) + p++; + if (*p == '\0' && p != key) { + parent->is_seq = 1; + } + } } key = next; parent = node; From cf54099d93aa6b5aa5bf4e25879cc858925e7273 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Wed, 29 Jul 2026 16:25:42 +0200 Subject: [PATCH 02/27] conf: add an util to iterate on sequence A YAML sequence of single key maps, such as `outputs`, stores its entries at outputs.. so a direct lookup of outputs. never matches. Add SCConfNodeLookupInSequence() to walk such a sequence and return the entries matching a given name. Ticket: 8606 --- rust/sys/src/sys.rs | 5 +++++ src/conf.c | 42 ++++++++++++++++++++++++++++++++++++++++++ src/conf.h | 2 ++ 3 files changed, 49 insertions(+) diff --git a/rust/sys/src/sys.rs b/rust/sys/src/sys.rs index b13890f6b9f9..b2c4bd97568e 100644 --- a/rust/sys/src/sys.rs +++ b/rust/sys/src/sys.rs @@ -338,6 +338,11 @@ extern "C" { node: *const SCConfNode, key: *const ::std::os::raw::c_char, ) -> *const ::std::os::raw::c_char; } +extern "C" { + pub fn SCConfNodeLookupInSequence( + seq: *const SCConfNode, key: *const ::std::os::raw::c_char, prev: *const SCConfNode, + ) -> *mut SCConfNode; +} extern "C" { pub fn SCConfNodeRemove(arg1: *mut SCConfNode); } diff --git a/src/conf.c b/src/conf.c index 64d0448a4a65..747e1f48f4be 100644 --- a/src/conf.c +++ b/src/conf.c @@ -898,6 +898,48 @@ const char *SCConfNodeLookupChildValue(const SCConfNode *node, const char *name) return NULL; } +/** + * \brief Find a named entry inside a sequence node. + * + * A YAML sequence of single key maps -- the shape used by "outputs" -- + * stores each entry as an unnamed node holding one child, so an entry is + * found at seq.. and not at seq.. This walks the sequence and + * returns the child named \a key. + * + * A sequence may hold several entries with the same name (several eve-log + * outputs for instance), so \a prev allows iterating over all of them: + * pass NULL to get the first match, then the previous result to get the + * next one. + * + * \param seq The sequence node, may be NULL. + * \param key The name of the entry to look for. + * \param prev NULL to start, or the previously returned node to continue. + * + * \retval The matching SCConfNode or NULL when there is no (further) match. + */ +SCConfNode *SCConfNodeLookupInSequence( + const SCConfNode *seq, const char *key, const SCConfNode *prev) +{ + if (seq == NULL || key == NULL) + return NULL; + + bool seen = (prev == NULL); + SCConfNode *entry; + TAILQ_FOREACH (entry, &seq->head, next) { + SCConfNode *child = SCConfNodeLookupChild(entry, key); + if (child == NULL) + continue; + if (!seen) { + if (child == prev) + seen = true; + continue; + } + return child; + } + + return NULL; +} + /** * \brief Lookup for a key value under a specific node * diff --git a/src/conf.h b/src/conf.h index 34f1c0709f8d..9cf84b53a202 100644 --- a/src/conf.h +++ b/src/conf.h @@ -81,6 +81,8 @@ void SCConfCreateContextBackup(void); void SCConfRestoreContextBackup(void); SCConfNode *SCConfNodeLookupChild(const SCConfNode *node, const char *key); const char *SCConfNodeLookupChildValue(const SCConfNode *node, const char *key); +SCConfNode *SCConfNodeLookupInSequence( + const SCConfNode *seq, const char *key, const SCConfNode *prev); void SCConfNodeRemove(SCConfNode *); void SCConfRegisterTests(void); int SCConfNodeChildValueIsTrue(const SCConfNode *node, const char *key); From 0815f7dfbe8739078732a39142d3b4ef9b51d067 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Sun, 9 Aug 2026 12:44:44 +0200 Subject: [PATCH 03/27] landlock: expose path grant helpers as public API The landlock read and write path helpers were static and only usable from the sandboxing function itself. Rename them to the SC prefixed public API and declare them in the header so that other parts of the engine, and later plugins and output modules, can declare the paths they need before the sandbox is enforced. The ruleset is passed as an opaque void pointer to avoid exposing the landlock internal structure to callers, and the helpers are no-op when landlock is not available so they can be called unconditionally. Ticket: 8606 --- src/util-landlock.c | 44 ++++++++++++++++++++++++++++---------------- src/util-landlock.h | 5 ++++- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/util-landlock.c b/src/util-landlock.c index f7fa2b036670..7045159bff5b 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -1,4 +1,4 @@ -/* Copyright (C) 2022 Open Information Security Foundation +/* Copyright (C) 2022,2026 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 @@ -37,6 +37,14 @@ void LandlockSandboxing(SCInstance *suri) { } +void SCLandlockGrantReadPath(void *ruleset, const char *path) +{ +} + +void SCLandlockGrantWritePath(void *ruleset, const char *path) +{ +} + #else /* HAVE_LINUX_LANDLOCK_H */ #include @@ -163,17 +171,21 @@ static int LandlockSandboxingAddRule( return 0; } -static inline void LandlockSandboxingWritePath( - struct landlock_ruleset *ruleset, const char *directory) +void SCLandlockGrantWritePath(void *vruleset, const char *directory) { + struct landlock_ruleset *ruleset = vruleset; + if (ruleset == NULL || directory == NULL) + return; if (LandlockSandboxingAddRule(ruleset, directory, _LANDLOCK_SURI_ACCESS_FS_WRITE) == 0) { SCLogConfig("Added write permission to '%s'", directory); } } -static inline void LandlockSandboxingReadPath( - struct landlock_ruleset *ruleset, const char *directory) +void SCLandlockGrantReadPath(void *vruleset, const char *directory) { + struct landlock_ruleset *ruleset = vruleset; + if (ruleset == NULL || directory == NULL) + return; if (LandlockSandboxingAddRule(ruleset, directory, _LANDLOCK_ACCESS_FS_READ) == 0) { SCLogConfig("Added read permission to '%s'", directory); } @@ -196,7 +208,7 @@ void LandlockSandboxing(SCInstance *suri) return; } - LandlockSandboxingWritePath(ruleset, SCConfigGetLogDirectory()); + SCLandlockGrantWritePath(ruleset, SCConfigGetLogDirectory()); struct stat sb; if (stat(ConfigGetDataDirectory(), &sb) == 0) { LandlockSandboxingAddRule(ruleset, ConfigGetDataDirectory(), @@ -214,9 +226,9 @@ void LandlockSandboxing(SCInstance *suri) struct stat statbuf; if (stat(file_name, &statbuf) != -1) { if (S_ISDIR(statbuf.st_mode)) { - LandlockSandboxingReadPath(ruleset, file_name); + SCLandlockGrantReadPath(ruleset, file_name); } else { - LandlockSandboxingReadPath(ruleset, dirname(file_name)); + SCLandlockGrantReadPath(ruleset, dirname(file_name)); } } else { SCLogError("Can't open pcap file"); @@ -228,14 +240,14 @@ void LandlockSandboxing(SCInstance *suri) if (suri->sig_file) { char *file_name = SCStrdup(suri->sig_file); if (file_name != NULL) { - LandlockSandboxingReadPath(ruleset, dirname(file_name)); + SCLandlockGrantReadPath(ruleset, dirname(file_name)); SCFree(file_name); } } if (suri->pid_filename) { char *file_name = SCStrdup(suri->pid_filename); if (file_name != NULL) { - LandlockSandboxingWritePath(ruleset, dirname(file_name)); + SCLandlockGrantWritePath(ruleset, dirname(file_name)); SCFree(file_name); } } @@ -245,20 +257,20 @@ void LandlockSandboxing(SCInstance *suri) if (PathIsAbsolute(socketname)) { char *file_name = SCStrdup(socketname); if (file_name != NULL) { - LandlockSandboxingWritePath(ruleset, dirname(file_name)); + SCLandlockGrantWritePath(ruleset, dirname(file_name)); SCFree(file_name); } } else { - LandlockSandboxingWritePath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); + SCLandlockGrantWritePath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); } } else { - LandlockSandboxingWritePath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); + SCLandlockGrantWritePath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); } } if (!suri->sig_file_exclusive) { const char *rule_path; if (SCConfGetNonNull("default-rule-path", &rule_path) == 1 && rule_path) { - LandlockSandboxingReadPath(ruleset, rule_path); + SCLandlockGrantReadPath(ruleset, rule_path); } } @@ -270,7 +282,7 @@ void LandlockSandboxing(SCInstance *suri) } else { SCConfNode *directory; TAILQ_FOREACH (directory, &read_dirs->head, next) { - LandlockSandboxingReadPath(ruleset, directory->val); + SCLandlockGrantReadPath(ruleset, directory->val); } } } @@ -282,7 +294,7 @@ void LandlockSandboxing(SCInstance *suri) } else { SCConfNode *directory; TAILQ_FOREACH (directory, &write_dirs->head, next) { - LandlockSandboxingWritePath(ruleset, directory->val); + SCLandlockGrantWritePath(ruleset, directory->val); } } } diff --git a/src/util-landlock.h b/src/util-landlock.h index 9d6c61dccb88..3adbbfb4f443 100644 --- a/src/util-landlock.h +++ b/src/util-landlock.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2022 Open Information Security Foundation +/* Copyright (C) 2022-2026 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 @@ -26,6 +26,9 @@ #include "suricata.h" +void SCLandlockGrantReadPath(void *ruleset, const char *path); +void SCLandlockGrantWritePath(void *ruleset, const char *path); + void LandlockSandboxing(SCInstance *suri); #endif /* SURICATA_UTIL_LANDLOCK_H */ From 5a97f3afad8f04f65d22f7750323bbc5f47a7da7 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Sun, 9 Aug 2026 12:45:28 +0200 Subject: [PATCH 04/27] landlock: add network access sandboxing Landlock gained network access control in ABI version 4 (Linux 6.7), allowing to restrict TCP bind and connect to a set of ports. Handle the network access rights in the ruleset and add two helpers, SCLandlockGrantNetBindTCP() and SCLandlockGrantNetConnectTCP(), so that the components needing to bind or connect a socket can declare the port they use before the sandbox is enforced. The ABI check is turned into a switch as the number of features depending on the kernel version is growing: the network access rights are removed from the ruleset when running on a kernel with an ABI lower than 4. Ticket: 8606 --- src/util-landlock.c | 100 ++++++++++++++++++++++++++++++++++++++++---- src/util-landlock.h | 4 ++ 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/util-landlock.c b/src/util-landlock.c index 7045159bff5b..a057005597d7 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -45,6 +45,14 @@ void SCLandlockGrantWritePath(void *ruleset, const char *path) { } +void SCLandlockGrantNetBindTCP(void *ruleset, uint16_t port) +{ +} + +void SCLandlockGrantNetConnectTCP(void *ruleset, uint16_t port) +{ +} + #else /* HAVE_LINUX_LANDLOCK_H */ #include @@ -96,6 +104,14 @@ static inline int landlock_restrict_self(const int ruleset_fd, const __u32 flags (LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_MAKE_DIR | LANDLOCK_ACCESS_FS_MAKE_REG | \ LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_MAKE_SOCK) +#ifndef LANDLOCK_ACCESS_NET_BIND_TCP +#define LANDLOCK_ACCESS_NET_BIND_TCP (1ULL << 0) +#endif +#ifndef LANDLOCK_ACCESS_NET_CONNECT_TCP +#define LANDLOCK_ACCESS_NET_CONNECT_TCP (1ULL << 1) +#endif +#define _LANDLOCK_ACCESS_NET (LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP) + struct landlock_ruleset { int fd; struct landlock_ruleset_attr attr; @@ -111,20 +127,28 @@ static inline struct landlock_ruleset *LandlockCreateRuleset(void) ruleset->attr.handled_access_fs = _LANDLOCK_ACCESS_FS_READ | _LANDLOCK_ACCESS_FS_WRITE | LANDLOCK_ACCESS_FS_EXECUTE; + ruleset->attr.handled_access_net = _LANDLOCK_ACCESS_NET; int abi = landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION); if (abi < 0) { SCFree(ruleset); return NULL; } - if (abi < 2) { - if (SCRequiresFeature(FEATURE_OUTPUT_FILESTORE)) { - SCLogError("Landlock disabled: need Linux 5.19+ for file store support"); - SCFree(ruleset); - return NULL; - } else { - ruleset->attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_REFER; - } + switch (abi) { + case 1: + /* Refer is only available from ABI 2 */ + if (SCRequiresFeature(FEATURE_OUTPUT_FILESTORE)) { + SCLogError("Landlock disabled: need Linux 5.19+ for file store support"); + SCFree(ruleset); + return NULL; + } else { + ruleset->attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_REFER; + } + __attribute__((fallthrough)); + case 2: + case 3: + /* Network access is only available from ABI 4 */ + ruleset->attr.handled_access_net &= ~_LANDLOCK_ACCESS_NET; } ruleset->fd = landlock_create_ruleset(&ruleset->attr, sizeof(ruleset->attr), 0); @@ -191,6 +215,66 @@ void SCLandlockGrantReadPath(void *vruleset, const char *directory) } } +static void LandlockGrantNetPort( + struct landlock_ruleset *ruleset, uint16_t port, uint64_t access, const char *access_name) +{ + if (ruleset == NULL) + return; + if ((ruleset->attr.handled_access_net & access) == 0) { + SCLogInfo("Landlock network access %s not available; skipping port %u", access_name, port); + return; + } + struct landlock_net_port_attr net_port = { + .allowed_access = access, + .port = port, + }; + if (landlock_add_rule(ruleset->fd, LANDLOCK_RULE_NET_PORT, &net_port, 0)) { + SCLogError("Can't add net rule (%s, port %u): %s", access_name, port, strerror(errno)); + return; + } + SCLogConfig("Added net %s permission on port %u", access_name, port); +} + +/** + * \brief Grant TCP bind permission on the given port + * + * Silently no-op when running on a kernel where landlock network support is + * not available. + * + * \param vruleset opaque landlock ruleset + * \param port TCP port to allow bind() on + */ +void SCLandlockGrantNetBindTCP(void *vruleset, uint16_t port) +{ +#ifdef LANDLOCK_ACCESS_NET_BIND_TCP + LandlockGrantNetPort( + (struct landlock_ruleset *)vruleset, port, LANDLOCK_ACCESS_NET_BIND_TCP, "bind-tcp"); +#else + (void)vruleset; + (void)port; +#endif +} + +/** + * \brief Grant TCP connect permission on the given port + * + * Silently no-op when running on a kernel where landlock network support is + * not available. + * + * \param vruleset opaque landlock ruleset + * \param port TCP port to allow connect() on + */ +void SCLandlockGrantNetConnectTCP(void *vruleset, uint16_t port) +{ +#ifdef LANDLOCK_ACCESS_NET_CONNECT_TCP + LandlockGrantNetPort((struct landlock_ruleset *)vruleset, port, LANDLOCK_ACCESS_NET_CONNECT_TCP, + "connect-tcp"); +#else + (void)vruleset; + (void)port; +#endif +} + void LandlockSandboxing(SCInstance *suri) { /* Read configuration variable and exit if no enforcement */ diff --git a/src/util-landlock.h b/src/util-landlock.h index 3adbbfb4f443..6996bb669bb3 100644 --- a/src/util-landlock.h +++ b/src/util-landlock.h @@ -29,6 +29,10 @@ void SCLandlockGrantReadPath(void *ruleset, const char *path); void SCLandlockGrantWritePath(void *ruleset, const char *path); +void SCLandlockGrantNetBindTCP(void *ruleset, uint16_t port); + +void SCLandlockGrantNetConnectTCP(void *ruleset, uint16_t port); + void LandlockSandboxing(SCInstance *suri); #endif /* SURICATA_UTIL_LANDLOCK_H */ From 9921b05ed467ebaf98633acd07cc21e3b7c9c994 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Sun, 9 Aug 2026 12:45:50 +0200 Subject: [PATCH 05/27] landlock: support new kernel filesystem access rights Recent kernels added filesystem access rights that were not handled by the ruleset: LANDLOCK_ACCESS_FS_TRUNCATE (ABI 3), IOCTL_DEV (ABI 5) and RESOLVE_UNIX (ABI 9). As they are not part of the handled access, they were simply allowed, weakening the sandbox. Add them to the write access set and extend the ABI switch to remove the ones that are not known by the running kernel. Asking for an access right the kernel does not know makes the ruleset creation fail, so a missing case here would disable the sandbox instead of loosening it. Ticket: 8606 --- src/util-landlock.c | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/util-landlock.c b/src/util-landlock.c index a057005597d7..ba7637980989 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -90,13 +90,18 @@ static inline int landlock_restrict_self(const int ruleset_fd, const __u32 flags #define LANDLOCK_ACCESS_FS_REFER (1ULL << 13) #endif +#ifndef LANDLOCK_ACCESS_FS_RESOLVE_UNIX +#define LANDLOCK_ACCESS_FS_RESOLVE_UNIX (1ULL << 18) +#endif + #define _LANDLOCK_ACCESS_FS_WRITE \ (LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_REMOVE_DIR | \ LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_MAKE_CHAR | \ LANDLOCK_ACCESS_FS_MAKE_DIR | LANDLOCK_ACCESS_FS_MAKE_REG | \ LANDLOCK_ACCESS_FS_MAKE_SOCK | LANDLOCK_ACCESS_FS_MAKE_FIFO | \ LANDLOCK_ACCESS_FS_MAKE_BLOCK | LANDLOCK_ACCESS_FS_MAKE_SYM | \ - LANDLOCK_ACCESS_FS_REFER) + LANDLOCK_ACCESS_FS_REFER | LANDLOCK_ACCESS_FS_TRUNCATE | \ + LANDLOCK_ACCESS_FS_IOCTL_DEV | LANDLOCK_ACCESS_FS_RESOLVE_UNIX) #define _LANDLOCK_ACCESS_FS_READ (LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR) @@ -146,9 +151,24 @@ static inline struct landlock_ruleset *LandlockCreateRuleset(void) } __attribute__((fallthrough)); case 2: + /* Truncate is only available from ABI 3 */ + ruleset->attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_TRUNCATE; + __attribute__((fallthrough)); case 3: /* Network access is only available from ABI 4 */ ruleset->attr.handled_access_net &= ~_LANDLOCK_ACCESS_NET; + __attribute__((fallthrough)); + case 4: + /* Device ioctl is only available from ABI 5 */ + ruleset->attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_IOCTL_DEV; + __attribute__((fallthrough)); + case 5: + /* Scoping is only available from ABI 6 */ + ruleset->attr.scoped &= ~(LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET | LANDLOCK_SCOPE_SIGNAL); + __attribute__((fallthrough)); + case 6 ... 8: + /* Unix socket resolution is only available from ABI 9 */ + ruleset->attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_RESOLVE_UNIX; } ruleset->fd = landlock_create_ruleset(&ruleset->attr, sizeof(ruleset->attr), 0); From 320675cfd8c7fcb7e9d8b467d95de34968c789d8 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Sun, 9 Aug 2026 12:46:26 +0200 Subject: [PATCH 06/27] landlock: allow plugin to declare their needs A plugin opening a file or a socket is denied by the sandbox as it has no way to declare the accesses it needs. Add an optional LandlockEnable callback to the SCPlugin structure: it is called on every loaded plugin just before the ruleset is enforced and the plugin can use the SCLandlockGrant* helpers to declare what it needs to work properly. The plugin API version is bumped as the SCPlugin structure is modified. Ticket: 8606 --- examples/plugins/altemplate/src/plugin.rs | 1 + rust/ffi/src/plugin.rs | 1 + rust/sys/src/sys.rs | 7 ++++++- src/suricata-plugin.h | 17 ++++++++++++++++- src/util-landlock.c | 7 +++++++ src/util-plugin.c | 15 +++++++++++++++ src/util-plugin.h | 2 ++ 7 files changed, 48 insertions(+), 2 deletions(-) diff --git a/examples/plugins/altemplate/src/plugin.rs b/examples/plugins/altemplate/src/plugin.rs index 48a51a8f597f..a80720f4aec3 100644 --- a/examples/plugins/altemplate/src/plugin.rs +++ b/examples/plugins/altemplate/src/plugin.rs @@ -40,6 +40,7 @@ extern "C" fn SCPluginRegister() -> *const SCPlugin { license: b"MIT\0".as_ptr() as *const libc::c_char, author: b"Philippe Antoine\0".as_ptr() as *const libc::c_char, Init: Some(altemplate_plugin_init), + LandlockEnable: None, }; Box::into_raw(Box::new(plugin)) } diff --git a/rust/ffi/src/plugin.rs b/rust/ffi/src/plugin.rs index 2ae83affb4d6..ed39192c957b 100644 --- a/rust/ffi/src/plugin.rs +++ b/rust/ffi/src/plugin.rs @@ -61,6 +61,7 @@ impl Plugin { license, author, Init: Some(self.init), + LandlockEnable: None, }; Box::into_raw(Box::new(plugin)) diff --git a/rust/sys/src/sys.rs b/rust/sys/src/sys.rs index b2c4bd97568e..dbd315df7491 100644 --- a/rust/sys/src/sys.rs +++ b/rust/sys/src/sys.rs @@ -84,7 +84,10 @@ extern "C" { alproto: AppProto, proto_name: *const ::std::os::raw::c_char, ); } -pub const SC_API_VERSION: u64 = 2304; +pub const SC_API_VERSION: u64 = 2305; +#[doc = " Callback signature for plugins, output modules and EVE filetypes that need to\n declare additional landlock permissions before the sandbox is enforced.\n\n The ruleset is opaque: implementations must not dereference it and must only\n hand it back to the SCLandlockGrant* helpers declared in util-landlock.h.\n Those helpers are no-ops when landlock is unavailable, so an implementation\n never has to guard its grants."] +pub type SCLandlockEnableFunc = + ::std::option::Option; #[doc = " Structure to define a Suricata plugin."] #[repr(C)] #[derive(Debug, Copy, Clone, PartialEq, Eq)] @@ -96,6 +99,8 @@ pub struct SCPlugin_ { pub license: *const ::std::os::raw::c_char, pub author: *const ::std::os::raw::c_char, pub Init: ::std::option::Option, + #[doc = " Optional callback invoked before landlock sandboxing is enforced.\n The plugin may grant additional filesystem/network access using the\n SCLandlockGrant* helpers in util-landlock.h. May be NULL."] + pub LandlockEnable: SCLandlockEnableFunc, } impl Default for SCPlugin_ { fn default() -> Self { diff --git a/src/suricata-plugin.h b/src/suricata-plugin.h index a89f42ce4331..a07bb35f3752 100644 --- a/src/suricata-plugin.h +++ b/src/suricata-plugin.h @@ -32,9 +32,20 @@ // Do not reuse autoconf PACKAGE_VERSION which is a string // Defined as major version.minor version (no patch version) -static const uint64_t SC_API_VERSION = 0x0900; +static const uint64_t SC_API_VERSION = 0x0901; #define SC_PACKAGE_VERSION PACKAGE_VERSION +/** + * Callback signature for plugins, output modules and EVE filetypes that need to + * declare additional landlock permissions before the sandbox is enforced. + * + * The ruleset is opaque: implementations must not dereference it and must only + * hand it back to the SCLandlockGrant* helpers declared in util-landlock.h. + * Those helpers are no-ops when landlock is unavailable, so an implementation + * never has to guard its grants. + */ +typedef void (*SCLandlockEnableFunc)(void *ruleset); + /** * Structure to define a Suricata plugin. */ @@ -47,6 +58,10 @@ typedef struct SCPlugin_ { const char *license; const char *author; void (*Init)(void); + /** Optional callback invoked before landlock sandboxing is enforced. + * The plugin may grant additional filesystem/network access using the + * SCLandlockGrant* helpers in util-landlock.h. May be NULL. */ + SCLandlockEnableFunc LandlockEnable; } SCPlugin; typedef SCPlugin *(*SCPluginRegisterFunc)(void); diff --git a/src/util-landlock.c b/src/util-landlock.c index ba7637980989..322fb46e4aaa 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -29,6 +29,7 @@ #include "util-landlock.h" #include "util-mem.h" #include "util-path.h" +#include "util-plugin.h" #include "util-validate.h" #ifndef HAVE_LINUX_LANDLOCK_H @@ -402,6 +403,12 @@ void LandlockSandboxing(SCInstance *suri) } } } + + /* Let plugins declare their landlock needs. */ +#ifdef HAVE_PLUGINS + SCPluginsLandlockEnable(ruleset); +#endif + LandlockEnforceRuleset(ruleset); SCFree(ruleset); } diff --git a/src/util-plugin.c b/src/util-plugin.c index de55357d73a6..015b297a1a0d 100644 --- a/src/util-plugin.c +++ b/src/util-plugin.c @@ -172,6 +172,21 @@ SCCapturePlugin *SCPluginFindCaptureByName(const char *name) return plugin; } +/** + * \brief Invoke LandlockEnable on every loaded plugin that defines one + * + * \param ruleset landlock ruleset the plugins declare their accesses on + */ +void SCPluginsLandlockEnable(void *ruleset) +{ + PluginListNode *node; + TAILQ_FOREACH (node, &plugins, entries) { + if (node->plugin->LandlockEnable != NULL) { + node->plugin->LandlockEnable(ruleset); + } + } +} + int SCPluginRegisterAppLayer(SCAppLayerPlugin *plugin) { AppProto alproto = AppProtoNewProtoFromString(plugin->name); diff --git a/src/util-plugin.h b/src/util-plugin.h index 84f17fe190b4..1b92af24e76a 100644 --- a/src/util-plugin.h +++ b/src/util-plugin.h @@ -26,4 +26,6 @@ SCCapturePlugin *SCPluginFindCaptureByName(const char *name); bool RegisterPlugin(SCPlugin *, void *); +void SCPluginsLandlockEnable(void *ruleset); + #endif /* SURICATA_UTIL_PLUGIN_H */ From 43bdcb0f8a832594e1b5c025543189a8fc1866af Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Sun, 9 Aug 2026 12:46:42 +0200 Subject: [PATCH 07/27] landlock: allow output modules to declare their needs Output modules write to files or sockets that are not necessarily under the default log directory, and the sandbox has no way to know about them. Add an optional LandlockEnable callback to OutputModule which is called on every registered output module just before the ruleset is enforced, so a module can grant the accesses it needs with the SCLandlockGrant* helpers. Ticket: 8606 --- src/output.h | 6 ++++++ src/util-landlock.c | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/src/output.h b/src/output.h index 79ac966f9394..ace1ee11e8ce 100644 --- a/src/output.h +++ b/src/output.h @@ -25,6 +25,7 @@ #define SURICATA_OUTPUT_H #include "decode.h" +#include "suricata-plugin.h" #include "tm-modules.h" #define DEFAULT_LOG_MODE_APPEND "yes" @@ -79,6 +80,11 @@ typedef struct OutputModule_ { int tc_log_progress; int ts_log_progress; + /** Optional callback invoked before landlock sandboxing is enforced. May be + * NULL. Look the module up with OutputGetModuleByConfName() right after + * registration to assign it. */ + SCLandlockEnableFunc LandlockEnable; + TAILQ_ENTRY(OutputModule_) entries; } OutputModule; diff --git a/src/util-landlock.c b/src/util-landlock.c index 322fb46e4aaa..f4eca506d0af 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -24,6 +24,7 @@ #include "suricata.h" #include "detect-engine.h" #include "feature.h" +#include "output.h" #include "util-conf.h" #include "util-file.h" #include "util-landlock.h" @@ -409,6 +410,14 @@ void LandlockSandboxing(SCInstance *suri) SCPluginsLandlockEnable(ruleset); #endif + /* Let registered output modules declare theirs. */ + OutputModule *output_module; + TAILQ_FOREACH (output_module, &output_modules, entries) { + if (output_module->LandlockEnable != NULL) { + output_module->LandlockEnable(ruleset); + } + } + LandlockEnforceRuleset(ruleset); SCFree(ruleset); } From 2eed5e992e15444c303e6787178192ddab00196a Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Thu, 6 Aug 2026 22:20:48 +0200 Subject: [PATCH 08/27] landlock: add SCLandlockForEachOutput helper Iterating over the outputs sequence to find every enabled instance of a given output is needed by each module implementing a LandlockEnable callback. Provide it once in util-landlock.c so no module has to walk the sequence itself and get the outputs.. shape wrong. Ticket: 8606 --- src/util-landlock.c | 29 +++++++++++++++++++++++++++++ src/util-landlock.h | 7 +++++++ 2 files changed, 36 insertions(+) diff --git a/src/util-landlock.c b/src/util-landlock.c index f4eca506d0af..c0d6d22b7d06 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -33,6 +33,35 @@ #include "util-plugin.h" #include "util-validate.h" +/** + * \brief Run \a cb for every enabled instance of the \a name output + * + * The "outputs" configuration is a YAML sequence, so an output is found at + * outputs.. and can be declared more than once. Instances whose + * "enabled" key is absent or not true are skipped. + * + * \param ruleset opaque landlock ruleset, passed as-is to \a cb + * \param name name of the output, as used in the YAML configuration + * \param cb callback run for each enabled instance of the output + */ +void SCLandlockForEachOutput(void *ruleset, const char *name, SCLandlockOutputFunc cb) +{ + if (name == NULL || cb == NULL) + return; + + SCConfNode *outputs = SCConfGetNode("outputs"); + if (outputs == NULL) + return; + + SCConfNode *conf = NULL; + while ((conf = SCConfNodeLookupInSequence(outputs, name, conf)) != NULL) { + const char *enabled = SCConfNodeLookupChildValue(conf, "enabled"); + if (enabled == NULL || !SCConfValIsTrue(enabled)) + continue; + cb(ruleset, conf); + } +} + #ifndef HAVE_LINUX_LANDLOCK_H void LandlockSandboxing(SCInstance *suri) diff --git a/src/util-landlock.h b/src/util-landlock.h index 6996bb669bb3..691afdc49ae2 100644 --- a/src/util-landlock.h +++ b/src/util-landlock.h @@ -26,6 +26,13 @@ #include "suricata.h" +/** Callback invoked by SCLandlockForEachOutput() for one output instance. + * \a conf is the node named after the output (e.g. the "eve-log" node), not + * the enclosing sequence entry. */ +typedef void (*SCLandlockOutputFunc)(void *ruleset, SCConfNode *conf); + +void SCLandlockForEachOutput(void *ruleset, const char *name, SCLandlockOutputFunc cb); + void SCLandlockGrantReadPath(void *ruleset, const char *path); void SCLandlockGrantWritePath(void *ruleset, const char *path); From 183a0ec848aebb276d27c50ddfe1db38aa9d8c57 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Fri, 12 Jun 2026 10:12:33 +0200 Subject: [PATCH 09/27] landlock: add YAML option to override network lock As the plugin may not implement correctly landlock, user has a way to override the configuration. Ticket: 8606 --- src/util-landlock.c | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/util-landlock.c b/src/util-landlock.c index c0d6d22b7d06..d92763b41e0f 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -25,6 +25,7 @@ #include "detect-engine.h" #include "feature.h" #include "output.h" +#include "util-byte.h" #include "util-conf.h" #include "util-file.h" #include "util-landlock.h" @@ -326,6 +327,32 @@ void SCLandlockGrantNetConnectTCP(void *vruleset, uint16_t port) #endif } +static void LandlockSandboxingApplyNetPorts( + void *v_ruleset, const char *conf_key, void (*grant)(void *, uint16_t)) +{ + struct landlock_ruleset *ruleset = v_ruleset; + SCConfNode *ports = SCConfGetNode(conf_key); + if (ports == NULL) + return; + if (!SCConfNodeIsSequence(ports)) { + SCLogWarning( + "Invalid %s configuration section: expected a list of port numbers.", conf_key); + return; + } + SCConfNode *port_node; + TAILQ_FOREACH (port_node, &ports->head, next) { + if (port_node->val == NULL) + continue; + uint16_t port = 0; + if (StringParseUint16(&port, 10, 0, port_node->val) < 0 || port == 0) { + SCLogWarning("Invalid port '%s' in %s: expected a value in [1, 65535].", port_node->val, + conf_key); + continue; + } + grant(ruleset, port); + } +} + void LandlockSandboxing(SCInstance *suri) { /* Read configuration variable and exit if no enforcement */ @@ -434,6 +461,11 @@ void LandlockSandboxing(SCInstance *suri) } } + LandlockSandboxingApplyNetPorts( + ruleset, "security.landlock.network.connect.tcp", SCLandlockGrantNetConnectTCP); + LandlockSandboxingApplyNetPorts( + ruleset, "security.landlock.network.bind.tcp", SCLandlockGrantNetBindTCP); + /* Let plugins declare their landlock needs. */ #ifdef HAVE_PLUGINS SCPluginsLandlockEnable(ruleset); From b85cf684d38b8d336f7c3af283b745e473f31480 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Sun, 14 Jun 2026 16:04:33 +0200 Subject: [PATCH 10/27] landlock: add some needed access to sandbox Some paths in dev, sys, proc were needed to get Suricata function properly. Fallback was working but we were loosing some features there. These are the paths glibc, jemalloc and the Rust standard library read during startup and runtime. Granting them read access avoids spurious EACCES errors and the Landlock audit noise coming with them without widening the sandbox in a meaningful way as any process on the system can already read them. Ticket: 8606 --- src/util-landlock.c | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/util-landlock.c b/src/util-landlock.c index d92763b41e0f..4f1d92979e8f 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -353,6 +353,42 @@ static void LandlockSandboxingApplyNetPorts( } } +/** \brief Grant read access on the system pseudo-filesystem paths in use. + * + * These are the paths glibc, jemalloc and the Rust standard library read + * during startup and runtime. A path that does not exist is skipped. + * + * \param ruleset the landlock ruleset to add the read rules to + */ +static void LandlockGrantSystemReadPaths(struct landlock_ruleset *ruleset) +{ + static const char *const system_read_paths[] = { + "/sys/devices/system/cpu", /* sysconf(_SC_NPROCESSORS_*) */ + "/proc/stat", /* CPU/system statistics */ + "/proc/sys/vm/overcommit_memory", /* malloc tuning */ + "/dev/urandom", /* RNG seeding fallback */ + }; + + for (size_t i = 0; i < sizeof(system_read_paths) / sizeof(system_read_paths[0]); i++) { + const char *path = system_read_paths[i]; + /* Open directly instead of stat()+open() to avoid a TOCTOU race: a + * missing or unreadable path simply fails here and is skipped. */ + int path_fd = open(path, O_PATH | O_CLOEXEC); + if (path_fd == -1) { + SCLogDebug("Can't open %s for landlock: %s", path, strerror(errno)); + continue; + } + struct landlock_path_beneath_attr path_beneath = { + .allowed_access = LANDLOCK_ACCESS_FS_READ_FILE & ruleset->attr.handled_access_fs, + .parent_fd = path_fd, + }; + if (landlock_add_rule(ruleset->fd, LANDLOCK_RULE_PATH_BENEATH, &path_beneath, 0)) { + SCLogDebug("Can't add system read rule for %s: %s", path, strerror(errno)); + } + close(path_fd); + } +} + void LandlockSandboxing(SCInstance *suri) { /* Read configuration variable and exit if no enforcement */ @@ -370,6 +406,8 @@ void LandlockSandboxing(SCInstance *suri) return; } + LandlockGrantSystemReadPaths(ruleset); + SCLandlockGrantWritePath(ruleset, SCConfigGetLogDirectory()); struct stat sb; if (stat(ConfigGetDataDirectory(), &sb) == 0) { From d1eb439e08c066dc898536c427ca8d4424e3af31 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Sun, 14 Jun 2026 16:57:31 +0200 Subject: [PATCH 11/27] landlock: sandbox earlier This patch triggers Landlock sandboxing earlier. This is useful as the sandboxing is now applied before output is initiated which triggers network and file access. Thus restriction apply from the start and not at file reopening. This is safer and should allow better debugging in case of problem. Ticket: 8606 --- src/suricata.c | 4 ++-- src/util-landlock.c | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/suricata.c b/src/suricata.c index 14b3f4d6b19a..2c45c819e2b7 100644 --- a/src/suricata.c +++ b/src/suricata.c @@ -3155,6 +3155,8 @@ void SuricataInit(void) exit(EXIT_FAILURE); } + LandlockSandboxing(&suricata); + SCDropMainThreadCaps(suricata.userid, suricata.groupid); /* Re-enable coredumps after privileges are dropped. */ @@ -3168,8 +3170,6 @@ void SuricataInit(void) SCOnLoggingReady(); - LandlockSandboxing(&suricata); - PostConfLoadedDetectSetup(&suricata); if (suricata.run_mode == RUNMODE_ENGINE_ANALYSIS) { goto out; diff --git a/src/util-landlock.c b/src/util-landlock.c index 4f1d92979e8f..bf5ebc22d9a5 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -519,6 +519,8 @@ void LandlockSandboxing(SCInstance *suri) LandlockEnforceRuleset(ruleset); SCFree(ruleset); + + SCLogInfo("Sandboxing via landlock is active"); } #endif /* HAVE_LINUX_LANDLOCK_H */ From c9fac6e5d8fc10458470e5b96505e2036595ce91 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Sun, 14 Jun 2026 19:16:41 +0200 Subject: [PATCH 12/27] landlock: allow disabling landlock setup in plugin In the case the implementation of plugins is not trusted, the new `plugin-setup` option can be used to disallow plugin to set up their sandboxing. Ticket: 8606 --- src/util-landlock.c | 8 +++++++- suricata.yaml.in | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/util-landlock.c b/src/util-landlock.c index bf5ebc22d9a5..20ecc20d26c7 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -506,7 +506,13 @@ void LandlockSandboxing(SCInstance *suri) /* Let plugins declare their landlock needs. */ #ifdef HAVE_PLUGINS - SCPluginsLandlockEnable(ruleset); + int enabled = 1; + int ret = SCConfGetBool("security.landlock.plugin-setup", &enabled); + if (ret == 0 || enabled == 1) { + SCPluginsLandlockEnable(ruleset); + } else { + SCLogInfo("Landlock sandboxing function of plugins will not be called"); + } #endif /* Let registered output modules declare theirs. */ diff --git a/suricata.yaml.in b/suricata.yaml.in index 2e417d94b7cc..9219a325cd1a 100644 --- a/suricata.yaml.in +++ b/suricata.yaml.in @@ -1361,6 +1361,9 @@ security: # Use landlock security module under Linux landlock: enabled: no + # set to false if ever plugins are not trusted. This means the + # landlock configuration needs to be done manually in this section. + plugin-setup: true directories: #write: # - @e_rundir@ From 72d7d469abf8082f69583d11de07bd6594972e37 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Fri, 12 Jun 2026 00:19:59 +0200 Subject: [PATCH 13/27] output/pcap: implement landlock enabler This patch adds the function needed to add to Landlock ruleset what is needed for the module to function properly. Ticket: 8606 --- src/log-pcap.c | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/log-pcap.c b/src/log-pcap.c index 1468cd144780..f0a9638c5288 100644 --- a/src/log-pcap.c +++ b/src/log-pcap.c @@ -51,6 +51,7 @@ #include "util-conf.h" #include "util-cpu.h" #include "util-datalink.h" +#include "util-landlock.h" #include "util-misc.h" #include "util-path.h" #include "util-time.h" @@ -217,6 +218,25 @@ static OutputInitResult PcapLogInitCtx(SCConfNode *); static void PcapLogProfilingDump(PcapLogData *); static bool PcapLogCondition(ThreadVars *, void *, const Packet *); +static void PcapLogLandlockEnableInstance(void *ruleset, SCConfNode *conf) +{ + const char *s_dir = SCConfNodeLookupChildValue(conf, "dir"); + if (s_dir == NULL) + return; /* default dir is the log directory, already granted */ + if (PathIsAbsolute(s_dir)) { + SCLandlockGrantWritePath(ruleset, s_dir); + } else { + char path[PATH_MAX]; + snprintf(path, sizeof(path), "%s/%s", SCConfigGetLogDirectory(), s_dir); + SCLandlockGrantWritePath(ruleset, path); + } +} + +static void PcapLogLandlockEnable(void *ruleset) +{ + SCLandlockForEachOutput(ruleset, "pcap-log", PcapLogLandlockEnableInstance); +} + void PcapLogRegister(void) { OutputPacketLoggerFunctions output_logger_functions = { @@ -228,6 +248,10 @@ void PcapLogRegister(void) }; OutputRegisterPacketModule( LOGGER_PCAP, MODULE_NAME, "pcap-log", PcapLogInitCtx, &output_logger_functions); + OutputModule *module = OutputGetModuleByConfName("pcap-log"); + if (module != NULL) { + module->LandlockEnable = PcapLogLandlockEnable; + } PcapLogProfileSetup(); SC_ATOMIC_INIT(thread_cnt); SC_ATOMIC_SET(thread_cnt, 1); /* first id is 1 */ From bf69d1abb4de064721bad43aecd8172b1457ab0a Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Fri, 12 Jun 2026 00:20:39 +0200 Subject: [PATCH 14/27] eve: implement landlock enabler This patch adds configuration for Landlock in eve including for new network capabilities linked to redis. Ticket: 8606 --- src/output-json.c | 77 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/output-json.c b/src/output-json.c index b6c6436ec938..f0a971e11692 100644 --- a/src/output-json.c +++ b/src/output-json.c @@ -47,6 +47,10 @@ #include "output.h" #include "output-json.h" +#include "util-conf.h" +#include "util-landlock.h" +#include "util-path.h" + #include "util-byte.h" #include "util-print.h" #include "util-proto-name.h" @@ -80,9 +84,82 @@ static size_t traffic_label_prefix_len = 0; const JsonAddrInfo json_addr_info_zero; +/** \brief Grant write access on the directory containing \a path. + * + * Only an absolute path is handled and the log directory is skipped as it is + * already granted. + */ +static void EveGrantFileDir(void *ruleset, const char *path) +{ + if (path == NULL || !PathIsAbsolute(path)) + return; + char *copy = SCStrdup(path); + if (copy == NULL) + return; + const char *dir = dirname(copy); + const char *log_dir = SCConfigGetLogDirectory(); + if (log_dir == NULL || strcmp(dir, log_dir) != 0) { + SCLandlockGrantWritePath(ruleset, dir); + } + SCFree(copy); +} + +static void EveLandlockEnableInstance(void *ruleset, SCConfNode *eve_conf) +{ + const char *filetype = SCConfNodeLookupChildValue(eve_conf, "filetype"); + if (filetype == NULL) + filetype = DEFAULT_LOG_FILETYPE; + + if (strcasecmp(filetype, "regular") == 0 || strcasecmp(filetype, "unix_dgram") == 0 || + strcasecmp(filetype, "unix_stream") == 0) { + const char *filename = SCConfNodeLookupChildValue(eve_conf, "filename"); + if (filename != NULL) + EveGrantFileDir(ruleset, filename); + return; + } + + if (strcasecmp(filetype, "redis") == 0) { + SCConfNode *redis_node = SCConfNodeLookupChild(eve_conf, "redis"); + const char *server = NULL; + const char *port_str = NULL; + if (redis_node != NULL) { + server = SCConfNodeLookupChildValue(redis_node, "server"); + port_str = SCConfNodeLookupChildValue(redis_node, "port"); + } + if (server != NULL && strchr(server, '/') != NULL) { + /* unix socket path */ + EveGrantFileDir(ruleset, server); + return; + } + uint16_t port = 6379; + if (port_str != NULL) { + if (StringParseUint16(&port, 10, 0, (const char *)port_str) < 0) { + SCLogError("Invalid value for redis port: %s", port_str); + return; + } + } + SCLandlockGrantNetConnectTCP(ruleset, port); + return; + } + + /* syslog opens its socket eagerly via openlog() during eve init, before + * landlock is enforced; no permission needed at sandboxing time. Other + * filetypes (e.g. nullsink, plugin-provided) are responsible for their + * own declarations via SCPlugin.LandlockEnable. */ +} + +static void OutputJsonLandlockEnable(void *ruleset) +{ + SCLandlockForEachOutput(ruleset, "eve-log", EveLandlockEnableInstance); +} + void OutputJsonRegister (void) { OutputRegisterModule(MODULE_NAME, "eve-log", OutputJsonInitCtx); + OutputModule *module = OutputGetModuleByConfName("eve-log"); + if (module != NULL) { + module->LandlockEnable = OutputJsonLandlockEnable; + } traffic_id_prefix_len = strlen(TRAFFIC_ID_PREFIX); traffic_label_prefix_len = strlen(TRAFFIC_LABEL_PREFIX); From 8fcce215c1043f1368b8aa96e634b0c5b15d0af4 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Thu, 23 Jul 2026 20:43:43 +0200 Subject: [PATCH 15/27] landlock: handle truncation of files In profiling modules, the setting `append: no` is using truncate() call which are prevented by landlock. This patch adds a new utility SCLandlockGrantFile which allows a module to ask for truncate on a specific file. By using a file as parameter, we will avoid any unwanted truncation (for example of eve.json). It also add a new helper to register file permission at init before landlock sandbox is created. Ticket: 8606 --- src/log-pcap.c | 2 + src/util-landlock.c | 128 ++++++++++++++++++++++++++++++++ src/util-landlock.h | 9 +++ src/util-profiling-keywords.c | 3 + src/util-profiling-prefilter.c | 3 + src/util-profiling-rulegroups.c | 3 + src/util-profiling-rules.c | 3 + src/util-profiling.c | 5 ++ 8 files changed, 156 insertions(+) diff --git a/src/log-pcap.c b/src/log-pcap.c index f0a9638c5288..6ed873f3b9ad 100644 --- a/src/log-pcap.c +++ b/src/log-pcap.c @@ -2001,6 +2001,8 @@ void PcapLogProfileSetup(void) profiling_pcaplog_file_mode = "a"; } else { profiling_pcaplog_file_mode = "w"; + SCLandlockRegisterFile(profiling_pcaplog_file_name, + SC_LANDLOCK_FILE_WRITE | SC_LANDLOCK_FILE_TRUNCATE); } profiling_pcaplog_output_to_file = 1; diff --git a/src/util-landlock.c b/src/util-landlock.c index 20ecc20d26c7..6b3b4adb386e 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -63,10 +63,63 @@ void SCLandlockForEachOutput(void *ruleset, const char *name, SCLandlockOutputFu } } +/* Registry of pending per-file grants populated during configuration + * parsing. Consumed by LandlockSandboxing() before enforcement (see the + * HAVE_LINUX_LANDLOCK_H branch below). Kept out of the LSM-specific block + * so callers can register unconditionally. */ +typedef struct SCLandlockPendingFile_ { + char *path; + uint32_t access; + TAILQ_ENTRY(SCLandlockPendingFile_) next; +} SCLandlockPendingFile; + +static TAILQ_HEAD(, SCLandlockPendingFile_) sc_landlock_pending_files = TAILQ_HEAD_INITIALIZER( + sc_landlock_pending_files); + +/** + * \brief Register a per-file landlock grant to be applied at sandbox setup + * + * For callers resolving their file paths at configuration parsing time, when + * the landlock ruleset does not exist yet. The registration is consumed by + * LandlockSandboxing(), which calls SCLandlockGrantFile() on each entry. It + * can be called whether or not landlock is enabled at runtime. + * + * \param path path of the file, duplicated internally + * \param access bit field of SC_LANDLOCK_FILE_* access flags + */ +void SCLandlockRegisterFile(const char *path, uint32_t access) +{ + if (path == NULL || access == 0) + return; + SCLandlockPendingFile *e = SCCalloc(1, sizeof(*e)); + if (e == NULL) + return; + e->path = SCStrdup(path); + if (e->path == NULL) { + SCFree(e); + return; + } + e->access = access; + TAILQ_INSERT_TAIL(&sc_landlock_pending_files, e, next); +} + +static void SCLandlockPendingFilesFree(void) +{ + SCLandlockPendingFile *e, *tmp; + TAILQ_FOREACH_SAFE (e, &sc_landlock_pending_files, next, tmp) { + TAILQ_REMOVE(&sc_landlock_pending_files, e, next); + SCFree(e->path); + SCFree(e); + } +} + #ifndef HAVE_LINUX_LANDLOCK_H void LandlockSandboxing(SCInstance *suri) { + /* Drop any pending file registrations even when the sandbox is not + * built in, so callers do not leak. */ + SCLandlockPendingFilesFree(); } void SCLandlockGrantReadPath(void *ruleset, const char *path) @@ -77,6 +130,10 @@ void SCLandlockGrantWritePath(void *ruleset, const char *path) { } +void SCLandlockGrantFile(void *ruleset, const char *path, uint32_t access) +{ +} + void SCLandlockGrantNetBindTCP(void *ruleset, uint16_t port) { } @@ -267,6 +324,68 @@ void SCLandlockGrantReadPath(void *vruleset, const char *directory) } } +/** + * \brief Grant read, write and/or truncate access on a single file + * + * The access is granted on the file itself, so the parent directory keeps its + * own, potentially more restrictive, permissions. The file is created (0644, + * O_NOFOLLOW) if it does not exist and a write or truncate access is asked + * for, as a landlock rule can only be attached to an existing inode. + * + * \param vruleset opaque landlock ruleset + * \param path path of the file to grant the access on + * \param access bit field of SC_LANDLOCK_FILE_* access flags + */ +void SCLandlockGrantFile(void *vruleset, const char *path, uint32_t access) +{ + struct landlock_ruleset *ruleset = vruleset; + if (ruleset == NULL || path == NULL || access == 0) + return; + + uint64_t permission = 0; + if (access & SC_LANDLOCK_FILE_READ) + permission |= LANDLOCK_ACCESS_FS_READ_FILE; + if (access & SC_LANDLOCK_FILE_WRITE) + permission |= LANDLOCK_ACCESS_FS_WRITE_FILE; + if (access & SC_LANDLOCK_FILE_TRUNCATE) + permission |= LANDLOCK_ACCESS_FS_TRUNCATE; + + permission &= ruleset->attr.handled_access_fs; + if (permission == 0) { + SCLogInfo("Landlock: no supported access bits for file '%s'; skipping", path); + return; + } + + int open_flags = O_PATH | O_CLOEXEC | O_NOFOLLOW; + int need_create = (access & (SC_LANDLOCK_FILE_WRITE | SC_LANDLOCK_FILE_TRUNCATE)) != 0; + if (need_create) { + int cfd = open(path, O_WRONLY | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0644); + if (cfd == -1) { + SCLogWarning("Can't create '%s' for landlock rule: %s", path, strerror(errno)); + return; + } + close(cfd); + } + + int fd = open(path, open_flags); + if (fd == -1) { + SCLogWarning("Can't open '%s' for landlock rule: %s", path, strerror(errno)); + return; + } + + struct landlock_path_beneath_attr path_beneath = { + .allowed_access = permission, + .parent_fd = fd, + }; + if (landlock_add_rule(ruleset->fd, LANDLOCK_RULE_PATH_BENEATH, &path_beneath, 0)) { + SCLogWarning("Can't add file rule for '%s': %s", path, strerror(errno)); + close(fd); + return; + } + close(fd); + SCLogConfig("Added file permission (0x%x) on '%s'", access, path); +} + static void LandlockGrantNetPort( struct landlock_ruleset *ruleset, uint16_t port, uint64_t access, const char *access_name) { @@ -504,6 +623,14 @@ void LandlockSandboxing(SCInstance *suri) LandlockSandboxingApplyNetPorts( ruleset, "security.landlock.network.bind.tcp", SCLandlockGrantNetBindTCP); + /* Apply per-file grants registered by core subsystems during + * configuration parsing (typically SC_LANDLOCK_FILE_TRUNCATE for + * profiling outputs with "append: no"). */ + SCLandlockPendingFile *pending; + TAILQ_FOREACH (pending, &sc_landlock_pending_files, next) { + SCLandlockGrantFile(ruleset, pending->path, pending->access); + } + /* Let plugins declare their landlock needs. */ #ifdef HAVE_PLUGINS int enabled = 1; @@ -525,6 +652,7 @@ void LandlockSandboxing(SCInstance *suri) LandlockEnforceRuleset(ruleset); SCFree(ruleset); + SCLandlockPendingFilesFree(); SCLogInfo("Sandboxing via landlock is active"); } diff --git a/src/util-landlock.h b/src/util-landlock.h index 691afdc49ae2..fd0d5264b4e7 100644 --- a/src/util-landlock.h +++ b/src/util-landlock.h @@ -36,6 +36,15 @@ void SCLandlockForEachOutput(void *ruleset, const char *name, SCLandlockOutputFu void SCLandlockGrantReadPath(void *ruleset, const char *path); void SCLandlockGrantWritePath(void *ruleset, const char *path); +/** Per-file access flags for SCLandlockGrantFile(). Combine as needed. */ +#define SC_LANDLOCK_FILE_READ (1U << 0) +#define SC_LANDLOCK_FILE_WRITE (1U << 1) +#define SC_LANDLOCK_FILE_TRUNCATE (1U << 2) + +void SCLandlockGrantFile(void *ruleset, const char *path, uint32_t access); + +void SCLandlockRegisterFile(const char *path, uint32_t access); + void SCLandlockGrantNetBindTCP(void *ruleset, uint16_t port); void SCLandlockGrantNetConnectTCP(void *ruleset, uint16_t port); diff --git a/src/util-profiling-keywords.c b/src/util-profiling-keywords.c index d33462752b0d..94ea8bea398a 100644 --- a/src/util-profiling-keywords.c +++ b/src/util-profiling-keywords.c @@ -32,6 +32,7 @@ #include "detect-engine.h" #include "tm-threads.h" #include "util-conf.h" +#include "util-landlock.h" #include "util-path.h" #include "util-time.h" @@ -81,6 +82,8 @@ void SCProfilingKeywordsGlobalInit(void) profiling_file_mode = "a"; } else { profiling_file_mode = "w"; + SCLandlockRegisterFile(profiling_file_name, + SC_LANDLOCK_FILE_WRITE | SC_LANDLOCK_FILE_TRUNCATE); } profiling_keywords_output_to_file = 1; diff --git a/src/util-profiling-prefilter.c b/src/util-profiling-prefilter.c index d7ff071790e8..9f857ce9a402 100644 --- a/src/util-profiling-prefilter.c +++ b/src/util-profiling-prefilter.c @@ -30,6 +30,7 @@ #ifdef PROFILING #include "detect-engine-prefilter.h" #include "util-conf.h" +#include "util-landlock.h" #include "util-path.h" #include "util-time.h" @@ -81,6 +82,8 @@ void SCProfilingPrefilterGlobalInit(void) profiling_file_mode = "a"; } else { profiling_file_mode = "w"; + SCLandlockRegisterFile(profiling_file_name, + SC_LANDLOCK_FILE_WRITE | SC_LANDLOCK_FILE_TRUNCATE); } profiling_prefilter_output_to_file = 1; diff --git a/src/util-profiling-rulegroups.c b/src/util-profiling-rulegroups.c index 86f608920939..fb6a38c97ed7 100644 --- a/src/util-profiling-rulegroups.c +++ b/src/util-profiling-rulegroups.c @@ -29,6 +29,7 @@ #ifdef PROFILING #include "util-conf.h" +#include "util-landlock.h" #include "util-path.h" #include "util-time.h" @@ -81,6 +82,8 @@ void SCProfilingSghsGlobalInit(void) profiling_file_mode = "a"; } else { profiling_file_mode = "w"; + SCLandlockRegisterFile(profiling_file_name, + SC_LANDLOCK_FILE_WRITE | SC_LANDLOCK_FILE_TRUNCATE); } profiling_sghs_output_to_file = 1; diff --git a/src/util-profiling-rules.c b/src/util-profiling-rules.c index e64fc456ff60..adbe0adaa69f 100644 --- a/src/util-profiling-rules.c +++ b/src/util-profiling-rules.c @@ -29,6 +29,7 @@ #include "util-byte.h" #include "util-conf.h" +#include "util-landlock.h" #include "util-path.h" #include "util-time.h" @@ -153,6 +154,8 @@ void SCProfilingRulesGlobalInit(void) profiling_file_mode = "a"; } else { profiling_file_mode = "w"; + SCLandlockRegisterFile(profiling_file_name, + SC_LANDLOCK_FILE_WRITE | SC_LANDLOCK_FILE_TRUNCATE); } profiling_output_to_file = 1; diff --git a/src/util-profiling.c b/src/util-profiling.c index b6ec465495b4..29c8b74dbaae 100644 --- a/src/util-profiling.c +++ b/src/util-profiling.c @@ -34,6 +34,7 @@ #include "conf.h" #include "util-unittest.h" #include "util-byte.h" +#include "util-landlock.h" #include "util-profiling-locks.h" #include "util-conf.h" #include "util-path.h" @@ -190,6 +191,8 @@ SCProfilingInit(void) profiling_packets_file_mode = "a"; } else { profiling_packets_file_mode = "w"; + SCLandlockRegisterFile(profiling_packets_file_name, + SC_LANDLOCK_FILE_WRITE | SC_LANDLOCK_FILE_TRUNCATE); } profiling_packets_output_to_file = 1; @@ -259,6 +262,8 @@ SCProfilingInit(void) profiling_locks_file_mode = "a"; } else { profiling_locks_file_mode = "w"; + SCLandlockRegisterFile(profiling_locks_file_name, + SC_LANDLOCK_FILE_WRITE | SC_LANDLOCK_FILE_TRUNCATE); } profiling_locks_output_to_file = 1; From a8e337c7e2f3a4f3e806b7661869d6252cd4327f Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Thu, 23 Jul 2026 22:50:46 +0200 Subject: [PATCH 16/27] landlock: authorization for some files The classification and reference files need read access. The access is granted per file as classification.config, reference.config and threshold.config can be set to a path outside of any of the granted directories. When the configuration key is not set, Suricata falls back to the compiled-in CONFIG_DIR default which is granted too if the file exists. Ticket: 8606 --- src/util-landlock.c | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/util-landlock.c b/src/util-landlock.c index 6b3b4adb386e..41918bb78101 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -563,6 +563,28 @@ void LandlockSandboxing(SCInstance *suri) SCFree(file_name); } } + /* Per-file read grants for classification.config, reference.config and + * threshold.config as they can live outside of any granted directory. + * When the configuration key is unset, Suricata falls back to the + * compiled-in CONFIG_DIR default which is granted if the file exists. */ + const char *class_file; + if (SCConfGetNonNull("classification-file", &class_file) == 1) { + SCLandlockGrantFile(ruleset, class_file, SC_LANDLOCK_FILE_READ); + } else if (SCPathExists(CONFIG_DIR "/classification.config")) { + SCLandlockGrantFile(ruleset, CONFIG_DIR "/classification.config", SC_LANDLOCK_FILE_READ); + } + const char *ref_file; + if (SCConfGetNonNull("reference-config-file", &ref_file) == 1) { + SCLandlockGrantFile(ruleset, ref_file, SC_LANDLOCK_FILE_READ); + } else if (SCPathExists(CONFIG_DIR "/reference.config")) { + SCLandlockGrantFile(ruleset, CONFIG_DIR "/reference.config", SC_LANDLOCK_FILE_READ); + } + const char *thr_file; + if (SCConfGetNonNull("threshold-file", &thr_file) == 1) { + SCLandlockGrantFile(ruleset, thr_file, SC_LANDLOCK_FILE_READ); + } else if (SCPathExists(CONFIG_DIR "/threshold.config")) { + SCLandlockGrantFile(ruleset, CONFIG_DIR "/threshold.config", SC_LANDLOCK_FILE_READ); + } if (suri->pid_filename) { char *file_name = SCStrdup(suri->pid_filename); if (file_name != NULL) { From 822420a645c9b8ac77452f3c9e330049d590d678 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Fri, 24 Jul 2026 00:29:03 +0200 Subject: [PATCH 17/27] landlock: handle renaming in filestore The file store writes its files under a temporary directory and renames them into the final tree, which requires LANDLOCK_ACCESS_FS_REFER on the file store directory. As the rules are attached to an existing inode, the directory is also created if it does not exist yet. Ticket: 8606 --- src/output-filestore.c | 37 +++++++++++++++++++++++++++++++++++++ src/util-landlock.c | 26 ++++++++++++++++++++++++++ src/util-landlock.h | 2 ++ 3 files changed, 65 insertions(+) diff --git a/src/output-filestore.c b/src/output-filestore.c index a5e2a3fa3052..0766e021164b 100644 --- a/src/output-filestore.c +++ b/src/output-filestore.c @@ -26,6 +26,7 @@ #include "output-json-file.h" #include "util-conf.h" +#include "util-landlock.h" #include "util-misc.h" #include "util-path.h" #include "util-print.h" @@ -534,12 +535,48 @@ static OutputInitResult OutputFilestoreLogInitCtx(SCConfNode *conf) SCReturnCT(result, "OutputInitResult"); } +static void OutputFilestoreLandlockEnableInstance(void *ruleset, SCConfNode *fs_conf) +{ + /* Resolve the same directory OutputFilestoreLogInitCtx() will use. */ + char dir[PATH_MAX]; + const char *log_base_dir = SCConfNodeLookupChildValue(fs_conf, "dir"); + if (log_base_dir == NULL) + log_base_dir = default_log_dir; + if (PathIsAbsolute(log_base_dir)) { + strlcpy(dir, log_base_dir, sizeof(dir)); + } else { + if (PathMerge(dir, sizeof(dir), SCConfigGetLogDirectory(), log_base_dir) < 0) + return; + } + /* The directory is created lazily by OutputFilestoreLogInitCtx() + * later, but landlock rules must attach to an existing inode. */ + if (!SCPathExists(dir)) { + if (SCCreateDirectoryTree(dir, true) != 0) { + SCLogWarning("Filestore landlock: can't create %s: %s", dir, strerror(errno)); + return; + } + } + /* Filestore renames files from /tmp to //, so we + * need FS_REFER in addition to the standard write access. */ + SCLandlockGrantWriteReferPath(ruleset, dir); +} + +static void OutputFilestoreLandlockEnable(void *ruleset) +{ + SCLandlockForEachOutput(ruleset, "file-store", OutputFilestoreLandlockEnableInstance); +} + void OutputFilestoreRegister(void) { OutputRegisterFiledataModule(LOGGER_FILE_STORE, MODULE_NAME, "file-store", OutputFilestoreLogInitCtx, OutputFilestoreLogger, OutputFilestoreLogThreadInit, OutputFilestoreLogThreadDeinit); + OutputModule *module = OutputGetModuleByConfName("file-store"); + if (module != NULL) { + module->LandlockEnable = OutputFilestoreLandlockEnable; + } + SC_ATOMIC_INIT(filestore_open_file_cnt); SC_ATOMIC_SET(filestore_open_file_cnt, 0); } diff --git a/src/util-landlock.c b/src/util-landlock.c index 41918bb78101..6cfb9998465c 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -130,6 +130,10 @@ void SCLandlockGrantWritePath(void *ruleset, const char *path) { } +void SCLandlockGrantWriteReferPath(void *ruleset, const char *path) +{ +} + void SCLandlockGrantFile(void *ruleset, const char *path, uint32_t access) { } @@ -314,6 +318,28 @@ void SCLandlockGrantWritePath(void *vruleset, const char *directory) } } +/** + * \brief Grant write access on a directory, plus rename inside it + * + * Same as SCLandlockGrantWritePath() but also grants + * LANDLOCK_ACCESS_FS_REFER, allowing rename() between subdirectories rooted + * at \a directory. It should only be used on a directory fully owned by the + * caller. + * + * \param vruleset opaque landlock ruleset + * \param directory directory to grant the access on + */ +void SCLandlockGrantWriteReferPath(void *vruleset, const char *directory) +{ + struct landlock_ruleset *ruleset = vruleset; + if (ruleset == NULL || directory == NULL) + return; + uint64_t access = _LANDLOCK_SURI_ACCESS_FS_WRITE | LANDLOCK_ACCESS_FS_REFER; + if (LandlockSandboxingAddRule(ruleset, directory, access) == 0) { + SCLogConfig("Added write+refer permission to '%s'", directory); + } +} + void SCLandlockGrantReadPath(void *vruleset, const char *directory) { struct landlock_ruleset *ruleset = vruleset; diff --git a/src/util-landlock.h b/src/util-landlock.h index fd0d5264b4e7..485dca818952 100644 --- a/src/util-landlock.h +++ b/src/util-landlock.h @@ -36,6 +36,8 @@ void SCLandlockForEachOutput(void *ruleset, const char *name, SCLandlockOutputFu void SCLandlockGrantReadPath(void *ruleset, const char *path); void SCLandlockGrantWritePath(void *ruleset, const char *path); +void SCLandlockGrantWriteReferPath(void *ruleset, const char *path); + /** Per-file access flags for SCLandlockGrantFile(). Combine as needed. */ #define SC_LANDLOCK_FILE_READ (1U << 0) #define SC_LANDLOCK_FILE_WRITE (1U << 1) From 4ccf591bbbe5fd5d67090b1992dd1d5de96ba3ee Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Fri, 24 Jul 2026 08:40:14 +0200 Subject: [PATCH 18/27] landlock: fix dataset state keyword It is using write that does an implicit truncate so we need to add truncate capability in the dataset directory. Datasets declared in the configuration have a known state file so the truncate permission is set on this file only. Rules can declare their own dataset and their files are not known when the sandbox is set up, so the permission is set on the data directory. This is only done if datasets.rules.allow-write is not disabled as it is the setting deciding whether such rules are loaded at all. Ticket: 8606 --- src/util-landlock.c | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/util-landlock.c b/src/util-landlock.c index 6cfb9998465c..e1463f673496 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -534,6 +534,37 @@ static void LandlockGrantSystemReadPaths(struct landlock_ruleset *ruleset) } } +/* Datasets declared in the configuration rewrite their state file with + * fopen(..., "w"), which needs truncate on that exact file. */ +static void LandlockGrantDatasetsState(struct landlock_ruleset *ruleset) +{ + SCConfNode *datasets = SCConfGetNode("datasets"); + if (datasets == NULL) + return; + + SCConfNode *iter; + TAILQ_FOREACH (iter, &datasets->head, next) { + const char *state = SCConfNodeLookupChildValue(iter, "state"); + if (state == NULL) + continue; + + char path[PATH_MAX]; + if (PathIsAbsolute(state)) { + strlcpy(path, state, sizeof(path)); + } else { + snprintf(path, sizeof(path), "%s/%s", ConfigGetDataDirectory(), state); + } + + /* Only an existing file needs truncate, creating a missing one is + * covered by the write permission on the parent directory. */ + struct stat sb; + if (stat(path, &sb) == 0) { + SCLandlockGrantFile(ruleset, path, + SC_LANDLOCK_FILE_READ | SC_LANDLOCK_FILE_WRITE | SC_LANDLOCK_FILE_TRUNCATE); + } + } +} + void LandlockSandboxing(SCInstance *suri) { /* Read configuration variable and exit if no enforcement */ @@ -556,9 +587,16 @@ void LandlockSandboxing(SCInstance *suri) SCLandlockGrantWritePath(ruleset, SCConfigGetLogDirectory()); struct stat sb; if (stat(ConfigGetDataDirectory(), &sb) == 0) { - LandlockSandboxingAddRule(ruleset, ConfigGetDataDirectory(), - _LANDLOCK_SURI_ACCESS_FS_WRITE | _LANDLOCK_ACCESS_FS_READ); + uint64_t data_dir_access = _LANDLOCK_SURI_ACCESS_FS_WRITE | _LANDLOCK_ACCESS_FS_READ; + /* Rule declared dataset files are unknown at this point, so truncate + * is granted on the directory if rules are allowed to write. */ + int allow_write = 1; + if (SCConfGetBool("datasets.rules.allow-write", &allow_write) == 0 || allow_write) { + data_dir_access |= LANDLOCK_ACCESS_FS_TRUNCATE; + } + LandlockSandboxingAddRule(ruleset, ConfigGetDataDirectory(), data_dir_access); } + LandlockGrantDatasetsState(ruleset); if (DetectEngineMpmCachingEnabled() && stat(DetectEngineMpmCachingGetPath(), &sb) == 0) { LandlockSandboxingAddRule(ruleset, DetectEngineMpmCachingGetPath(), _LANDLOCK_SURI_ACCESS_FS_WRITE | _LANDLOCK_ACCESS_FS_READ); From c24c58b2fe31090438c592e3dd92bf084f90434e Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Fri, 24 Jul 2026 16:35:40 +0200 Subject: [PATCH 19/27] landlock: remove removal permission when sandboxing This write permission was too lax as it was including the right to remove a file. As we don't want Suricata to be able to remove files that it has written (like eve.json) to cover traces we should not allow this. As a result, a new function providing write and remove has been added. It is, for example, necessary to use this for log-pcap or PID file. Ticket: 8606 --- src/log-pcap.c | 30 +++++++++++++---- src/util-landlock.c | 79 +++++++++++++++++++++++++++++++++++++-------- src/util-landlock.h | 2 ++ 3 files changed, 92 insertions(+), 19 deletions(-) diff --git a/src/log-pcap.c b/src/log-pcap.c index 6ed873f3b9ad..9fefe9992bd3 100644 --- a/src/log-pcap.c +++ b/src/log-pcap.c @@ -220,15 +220,33 @@ static bool PcapLogCondition(ThreadVars *, void *, const Packet *); static void PcapLogLandlockEnableInstance(void *ruleset, SCConfNode *conf) { + /* Ring-buffer mode (max-files set) recycles the oldest pcap via + * remove(), so we need FS_REMOVE_FILE on the pcap directory. Without + * max-files there is no rotation and REMOVE stays out. */ + bool ring_buffer = SCConfNodeLookupChildValue(conf, "max-files") != NULL; const char *s_dir = SCConfNodeLookupChildValue(conf, "dir"); - if (s_dir == NULL) - return; /* default dir is the log directory, already granted */ - if (PathIsAbsolute(s_dir)) { - SCLandlockGrantWritePath(ruleset, s_dir); + char path[PATH_MAX]; + const char *target; + if (s_dir == NULL) { + /* default dir is the log directory, already granted for write */ + if (!ring_buffer) + return; + target = SCConfigGetLogDirectory(); + } else if (PathIsAbsolute(s_dir)) { + target = s_dir; } else { - char path[PATH_MAX]; snprintf(path, sizeof(path), "%s/%s", SCConfigGetLogDirectory(), s_dir); - SCLandlockGrantWritePath(ruleset, path); + target = path; + } + if (ring_buffer) { + SCLandlockGrantWriteRemovePath(ruleset, target); + /* PcapLogInitRingBuffer() opendir()s the pcap directory to rebuild + * the ring from the files already on disk, so READ_DIR is needed on + * top of the write grant -- including when the pcap directory is the + * log directory, which is otherwise only granted for write. */ + SCLandlockGrantReadPath(ruleset, target); + } else if (s_dir != NULL) { + SCLandlockGrantWritePath(ruleset, target); } } diff --git a/src/util-landlock.c b/src/util-landlock.c index e1463f673496..5665116e9a04 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -134,6 +134,10 @@ void SCLandlockGrantWriteReferPath(void *ruleset, const char *path) { } +void SCLandlockGrantWriteRemovePath(void *ruleset, const char *path) +{ +} + void SCLandlockGrantFile(void *ruleset, const char *path, uint32_t access) { } @@ -198,9 +202,16 @@ static inline int landlock_restrict_self(const int ruleset_fd, const __u32 flags #define _LANDLOCK_ACCESS_FS_READ (LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR) +/* Default write grant for directories Suricata owns. Deliberately excludes + * LANDLOCK_ACCESS_FS_REMOVE_FILE and LANDLOCK_ACCESS_FS_TRUNCATE: those are + * classic anti-forensics primitives (unlinking or zeroing logs/state to + * erase attacker traces). Subsystems that legitimately need to unlink or + * truncate their own files -- filestore staging cleanup, pcap ring-buffer + * rotation, datasets state.csv rewrite -- must register a scoped grant on + * their own directory or file. */ #define _LANDLOCK_SURI_ACCESS_FS_WRITE \ (LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_MAKE_DIR | LANDLOCK_ACCESS_FS_MAKE_REG | \ - LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_MAKE_SOCK) + LANDLOCK_ACCESS_FS_MAKE_SOCK) #ifndef LANDLOCK_ACCESS_NET_BIND_TCP #define LANDLOCK_ACCESS_NET_BIND_TCP (1ULL << 0) @@ -319,12 +330,12 @@ void SCLandlockGrantWritePath(void *vruleset, const char *directory) } /** - * \brief Grant write access on a directory, plus rename inside it + * \brief Grant write access on a directory, plus rename and file removal * * Same as SCLandlockGrantWritePath() but also grants * LANDLOCK_ACCESS_FS_REFER, allowing rename() between subdirectories rooted - * at \a directory. It should only be used on a directory fully owned by the - * caller. + * at \a directory, and LANDLOCK_ACCESS_FS_REMOVE_FILE. It should only be used + * on a directory fully owned by the caller. * * \param vruleset opaque landlock ruleset * \param directory directory to grant the access on @@ -334,12 +345,34 @@ void SCLandlockGrantWriteReferPath(void *vruleset, const char *directory) struct landlock_ruleset *ruleset = vruleset; if (ruleset == NULL || directory == NULL) return; - uint64_t access = _LANDLOCK_SURI_ACCESS_FS_WRITE | LANDLOCK_ACCESS_FS_REFER; + uint64_t access = _LANDLOCK_SURI_ACCESS_FS_WRITE | LANDLOCK_ACCESS_FS_REFER | + LANDLOCK_ACCESS_FS_REMOVE_FILE; if (LandlockSandboxingAddRule(ruleset, directory, access) == 0) { SCLogConfig("Added write+refer permission to '%s'", directory); } } +/** + * \brief Grant write access on a directory, plus file removal + * + * Same as SCLandlockGrantWritePath() but also grants + * LANDLOCK_ACCESS_FS_REMOVE_FILE, allowing the caller to unlink the files it + * has created in \a directory. + * + * \param vruleset opaque landlock ruleset + * \param directory directory to grant the access on + */ +void SCLandlockGrantWriteRemovePath(void *vruleset, const char *directory) +{ + struct landlock_ruleset *ruleset = vruleset; + if (ruleset == NULL || directory == NULL) + return; + uint64_t access = _LANDLOCK_SURI_ACCESS_FS_WRITE | LANDLOCK_ACCESS_FS_REMOVE_FILE; + if (LandlockSandboxingAddRule(ruleset, directory, access) == 0) { + SCLogConfig("Added write+remove permission to '%s'", directory); + } +} + void SCLandlockGrantReadPath(void *vruleset, const char *directory) { struct landlock_ruleset *ruleset = vruleset; @@ -598,20 +631,36 @@ void LandlockSandboxing(SCInstance *suri) } LandlockGrantDatasetsState(ruleset); if (DetectEngineMpmCachingEnabled() && stat(DetectEngineMpmCachingGetPath(), &sb) == 0) { + /* MPM cache is a Suricata-private directory: HS pruning + corruption + * cleanup remove entries there. Grant REMOVE alongside write+read. */ LandlockSandboxingAddRule(ruleset, DetectEngineMpmCachingGetPath(), - _LANDLOCK_SURI_ACCESS_FS_WRITE | _LANDLOCK_ACCESS_FS_READ); + _LANDLOCK_SURI_ACCESS_FS_WRITE | _LANDLOCK_ACCESS_FS_READ | + LANDLOCK_ACCESS_FS_REMOVE_FILE); } if (suri->run_mode == RUNMODE_PCAP_FILE) { const char *pcap_file; if (SCConfGetNonNull("pcap-file.file", &pcap_file) == 1) { + /* When delete-when-done is set, the pcap reader unlinks the + * source pcap after processing; we then need REMOVE on the + * containing directory in addition to read. */ + const char *delete_str = NULL; + int delete_bool = 0; + bool delete_when_done = + (SCConfGetNonNull("pcap-file.delete-when-done", &delete_str) == 1 && + (strcmp(delete_str, "non-alerts") == 0 || + (SCConfGetBool("pcap-file.delete-when-done", &delete_bool) == + 1 && + delete_bool))); char *file_name = SCStrdup(pcap_file); if (file_name != NULL) { struct stat statbuf; if (stat(file_name, &statbuf) != -1) { - if (S_ISDIR(statbuf.st_mode)) { - SCLandlockGrantReadPath(ruleset, file_name); + const char *dir = S_ISDIR(statbuf.st_mode) ? file_name : dirname(file_name); + if (delete_when_done) { + LandlockSandboxingAddRule(ruleset, dir, + _LANDLOCK_ACCESS_FS_READ | LANDLOCK_ACCESS_FS_REMOVE_FILE); } else { - SCLandlockGrantReadPath(ruleset, dirname(file_name)); + SCLandlockGrantReadPath(ruleset, dir); } } else { SCLogError("Can't open pcap file"); @@ -650,26 +699,30 @@ void LandlockSandboxing(SCInstance *suri) SCLandlockGrantFile(ruleset, CONFIG_DIR "/threshold.config", SC_LANDLOCK_FILE_READ); } if (suri->pid_filename) { + /* PID file is written at startup and unlinked on shutdown, so REMOVE + * is required on its containing directory. */ char *file_name = SCStrdup(suri->pid_filename); if (file_name != NULL) { - SCLandlockGrantWritePath(ruleset, dirname(file_name)); + SCLandlockGrantWriteRemovePath(ruleset, dirname(file_name)); SCFree(file_name); } } if (ConfUnixSocketIsEnable()) { + /* Suricata unlinks any stale socket before bind(), so REMOVE is + * required on the socket directory. */ const char *socketname; if (SCConfGetNonNull("unix-command.filename", &socketname) == 1) { if (PathIsAbsolute(socketname)) { char *file_name = SCStrdup(socketname); if (file_name != NULL) { - SCLandlockGrantWritePath(ruleset, dirname(file_name)); + SCLandlockGrantWriteRemovePath(ruleset, dirname(file_name)); SCFree(file_name); } } else { - SCLandlockGrantWritePath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); + SCLandlockGrantWriteRemovePath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); } } else { - SCLandlockGrantWritePath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); + SCLandlockGrantWriteRemovePath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); } } if (!suri->sig_file_exclusive) { diff --git a/src/util-landlock.h b/src/util-landlock.h index 485dca818952..5f12538e4b24 100644 --- a/src/util-landlock.h +++ b/src/util-landlock.h @@ -38,6 +38,8 @@ void SCLandlockGrantWritePath(void *ruleset, const char *path); void SCLandlockGrantWriteReferPath(void *ruleset, const char *path); +void SCLandlockGrantWriteRemovePath(void *ruleset, const char *path); + /** Per-file access flags for SCLandlockGrantFile(). Combine as needed. */ #define SC_LANDLOCK_FILE_READ (1U << 0) #define SC_LANDLOCK_FILE_WRITE (1U << 1) From 8807e2cd4d1ceff6bcb806bb512347d8f41a1ea0 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Tue, 28 Jul 2026 23:25:01 +0200 Subject: [PATCH 20/27] fastlog: add landlock support The access is asked for on the file itself and not on the parent directory as `filename: /dev/null` is a common way to enable the module while discarding its output and granting write access on the whole /dev would widen the sandbox for no reason. Ticket: 8606 --- src/alert-fastlog.c | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/alert-fastlog.c b/src/alert-fastlog.c index 489003ff5147..cc81e1b9d614 100644 --- a/src/alert-fastlog.c +++ b/src/alert-fastlog.c @@ -53,6 +53,8 @@ #include "util-optimize.h" #include "util-logopenfile.h" #include "util-time.h" +#include "util-landlock.h" +#include "util-path.h" #include "action-globals.h" @@ -74,6 +76,33 @@ static void AlertFastLogDeInitCtx(OutputCtx *); static bool AlertFastLogCondition(ThreadVars *tv, void *thread_data, const Packet *p); int AlertFastLogger(ThreadVars *tv, void *data, const Packet *p); +/** \brief Declare the filesystem access the "fast" output needs. + * + * Only an absolute filename needs a grant, a relative one being created in + * the log directory which is already granted. The access is asked for on the + * file itself and truncation is added when append is disabled. + */ +static void AlertFastLogLandlockEnableInstance(void *ruleset, SCConfNode *fast_conf) +{ + const char *filename = SCConfNodeLookupChildValue(fast_conf, "filename"); + if (filename == NULL) + filename = DEFAULT_LOG_FILENAME; + if (!PathIsAbsolute(filename)) + return; + + uint32_t access = SC_LANDLOCK_FILE_WRITE; + const char *append = SCConfNodeLookupChildValue(fast_conf, "append"); + if (append != NULL && !SCConfValIsTrue(append)) + access |= SC_LANDLOCK_FILE_TRUNCATE; + + SCLandlockGrantFile(ruleset, filename, access); +} + +static void AlertFastLogLandlockEnable(void *ruleset) +{ + SCLandlockForEachOutput(ruleset, "fast", AlertFastLogLandlockEnableInstance); +} + void AlertFastLogRegister(void) { OutputPacketLoggerFunctions output_logger_functions = { @@ -86,6 +115,10 @@ void AlertFastLogRegister(void) OutputRegisterPacketModule( LOGGER_ALERT_FAST, MODULE_NAME, "fast", AlertFastLogInitCtx, &output_logger_functions); + OutputModule *module = OutputGetModuleByConfName("fast"); + if (module != NULL) { + module->LandlockEnable = AlertFastLogLandlockEnable; + } AlertFastLogRegisterTests(); } From ad0467453711f56512c74116ed8fb7f01b015fd6 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Fri, 12 Jun 2026 10:13:41 +0200 Subject: [PATCH 21/27] docs: document landlock update Ticket: 8606 --- doc/userguide/configuration/landlock.rst | 71 ++++++ doc/userguide/devguide/libsuricata/index.rst | 239 +++++++++++++++++++ 2 files changed, 310 insertions(+) diff --git a/doc/userguide/configuration/landlock.rst b/doc/userguide/configuration/landlock.rst index 65c7e8135bd8..3f823bf4f896 100644 --- a/doc/userguide/configuration/landlock.rst +++ b/doc/userguide/configuration/landlock.rst @@ -25,6 +25,7 @@ To enable Landlock, edit the YAML and set ``enabled`` to ``yes``: landlock: enabled: yes + plugin-setup: false directories: write: - /var/log/suricata/ @@ -38,6 +39,76 @@ Following your running configuration you may have to add some directories. There are two lists you can use, ``write`` to add directories where write is needed and ``read`` for directories where read access is needed. +Built-in outputs (``pcap-log``, ``fast``, ``eve-log`` with ``redis``, ``unix_*`` +and custom ``filename`` paths, ...) declare the filesystem and network access +they need on their own. In particular, an absolute ``filename`` on the ``fast`` +output is granted per-file, so the common ``filename: /dev/null`` idiom (enable +the module but discard its output) works without opening up write access to the +whole ``/dev`` directory. +Plugins can do the same by implementing the ``LandlockEnable`` +callback on ``SCPlugin`` (see :ref:`libsuricata`). The lists above only need to +contain directories that are not covered by these declarations. If ever letting +the plugin set up landlock is not wanted, one can set the `plugin-setup` option +to `false`. + +A handful of system pseudo-files are also granted read access automatically: +``/sys/devices/system/cpu`` (online-CPU detection via ``sysconf``), ``/proc/stat``, +``/proc/sys/vm/overcommit_memory`` (allocator tuning) and ``/dev/urandom`` (RNG +seeding fallback). These are probed by glibc, the system allocator and the Rust +standard library during normal startup; granting them avoids spurious ``EACCES`` +errors and Landlock audit noise without meaningfully widening the sandbox. +Missing paths are silently skipped. + +Lua scripts writing their own files +----------------------------------- + +Suricata cannot know in advance which files a Lua output script will open: +the path is chosen by the script at runtime, often built from per-flow data +such as addresses and ports. Such writes are therefore *not* granted +automatically and will fail with ``Permission denied`` once the sandbox is +active, for example:: + + Info: output-lua: failed to run script: ./streaming-tcp.lua:25: + /var/log/suricata/6-10.0.0.1-10.0.0.2-1234-80: Permission denied + +When using a Lua script that writes files on its own, add the target +directory to ``security.landlock.directories.write``:: + + landlock: + enabled: yes + directories: + write: + - /var/log/suricata/ + +Scripts that only write through Suricata's own logging facilities do not +need any extra permission. + +Granting access to network ports +-------------------------------- + +When a module or plugin cannot declare its needs (for example a third-party +filetype that opens an unknown TCP service), TCP ports can be granted manually +under ``security.landlock.network``. There is no default value: ports listed +here are *added* to whatever the modules and plugins have already declared. + +:: + + landlock: + enabled: yes + network: + connect: + tcp: + - 6379 + - 9092 + bind: + tcp: + - 8080 + +``connect.tcp`` lists ports the process is allowed to connect to (e.g. a Redis +or Kafka broker). ``bind.tcp`` lists ports it is allowed to bind/listen on. +Both options are silently ignored on kernels whose Landlock ABI does not +support network rules (ABI < 4). + Landlock is not active in some distributions and you may need to activate it at boot by adding ``lsm=landock`` to the Linux command line. For example, on a Debian distribution with at least a linux 5.13, you can edit ``/etc/default/grub`` diff --git a/doc/userguide/devguide/libsuricata/index.rst b/doc/userguide/devguide/libsuricata/index.rst index 9632a35a7640..089bf062d78d 100644 --- a/doc/userguide/devguide/libsuricata/index.rst +++ b/doc/userguide/devguide/libsuricata/index.rst @@ -56,6 +56,245 @@ resolved at runtime. The ``SCPlugin`` begins by a version number ``SC_API_VERSION`` for runtime compatibility between Suricata and the plugin. +Declaring Landlock permissions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When Landlock sandboxing is enabled (see :ref:`landlock`), Suricata +restricts the set of files and network ports the process can access. +A plugin that needs to read or write outside the standard Suricata +directories, or that opens network sockets, can declare its +requirements through the optional ``LandlockEnable`` callback on +``SCPlugin``: + +.. code-block:: c + + #include "util-landlock.h" + + static void MyPluginLandlockEnable(void *ruleset) + { + SCLandlockGrantReadPath(ruleset, "/etc/my-plugin/"); + SCLandlockGrantWritePath(ruleset, "/var/lib/my-plugin/"); + SCLandlockGrantNetConnectTCP(ruleset, 5044); + } + + const SCPlugin PluginRegistration = { + .version = SC_API_VERSION, + /* ... */ + .Init = MyPluginInit, + .LandlockEnable = MyPluginLandlockEnable, + }; + +The callback is invoked once, just before the sandbox is enforced. +``ruleset`` is an opaque handle: callbacks must only use the +``SCLandlockGrant*`` helpers declared in ``util-landlock.h``. The +``LandlockEnable`` field may be left ``NULL`` when no extra +permissions are required. + +Two network helpers are available: ``SCLandlockGrantNetConnectTCP`` for +a port the plugin connects out to, and ``SCLandlockGrantNetBindTCP`` for +a port it listens on. Both silently no-op on kernels whose Landlock ABI +does not support network rules, so callbacks do not need to guard them. + +Iterating over output instances +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``outputs`` is a YAML *sequence*, so an output module is configured at +``outputs..`` and never at ``outputs.``. Looking the node +up directly with ``SCConfGetNode("outputs.eve-log")`` therefore always +returns ``NULL``, which reads as "not configured" and silently drops the +module's whole permission declaration. The same name may also appear +more than once, as with multiple ``eve-log`` instances. + +``SCLandlockForEachOutput`` handles both cases: it walks the sequence and +invokes the callback once per instance whose ``enabled`` key is true, so +the callback only ever sees outputs that will actually run. + +.. code-block:: c + + #include "util-landlock.h" + + static void MyOutputLandlockEnableInstance(void *ruleset, SCConfNode *conf) + { + const char *dir = SCConfNodeLookupChildValue(conf, "dir"); + if (dir != NULL) + SCLandlockGrantWritePath(ruleset, dir); + } + + static void MyOutputLandlockEnable(void *ruleset) + { + SCLandlockForEachOutput(ruleset, "my-output", MyOutputLandlockEnableInstance); + } + +The ``conf`` node passed to the callback is the one named after the +output -- the ``my-output`` node itself, not the enclosing sequence +entry. Output modules should always go through this helper rather than +walking the sequence themselves. + +Per-file grants +^^^^^^^^^^^^^^^ + +``SCLandlockGrantWritePath`` grants a broad set of write permissions on +the target directory but deliberately does **not** grant +``LANDLOCK_ACCESS_FS_TRUNCATE``. Opening an existing file with mode +``"w"`` (``O_TRUNC``) is therefore denied inside the sandbox. This is +intentional: it prevents a compromised or misbehaving component from +truncating unrelated files such as ``eve.json`` sitting in the same log +directory. + +When a plugin does need to truncate a specific file on open (for +example when its configuration exposes an ``append: no`` option), it +can request a minimal per-file grant with ``SCLandlockGrantFile``: + +.. code-block:: c + + #include "util-landlock.h" + + static void MyPluginLandlockEnable(void *ruleset) + { + SCLandlockGrantWritePath(ruleset, "/var/log/my-plugin/"); + /* Allow truncate-on-open only on this specific file. */ + SCLandlockGrantFile(ruleset, "/var/log/my-plugin/stats.log", + SC_LANDLOCK_FILE_WRITE | SC_LANDLOCK_FILE_TRUNCATE); + } + +The available access flags are: + +- ``SC_LANDLOCK_FILE_READ`` — open for reading. +- ``SC_LANDLOCK_FILE_WRITE`` — open for writing / append. +- ``SC_LANDLOCK_FILE_TRUNCATE`` — allow ``O_TRUNC`` / ``fopen`` mode + ``"w"`` on this file. + +The target file is created (mode ``0644``, ``O_NOFOLLOW``) if missing +when a write flag is requested, so Landlock can attach the rule to a +real inode. Grants apply only to the exact path passed in; other files +in the same directory keep the stricter directory-level policy. +``SCLandlockGrantFile`` is a no-op when Landlock is not compiled in or +the running kernel does not support it. + +Deferred per-file grants +^^^^^^^^^^^^^^^^^^^^^^^^ + +Some code paths -- for example a plugin that resolves its output +filename during configuration parsing, well before the ``LandlockEnable`` +callback runs -- do not have a ``ruleset`` handle available at the time +the grant needs to be described. For these cases, +``SCLandlockRegisterFile`` records the request in a small pending list +that ``LandlockSandboxing`` drains just before enforcing the sandbox: + +.. code-block:: c + + #include "util-landlock.h" + + void MyPluginConfigInit(void) + { + /* ... parse config, resolve /var/log/my-plugin/stats.log ... */ + if (append_no_configured) { + SCLandlockRegisterFile("/var/log/my-plugin/stats.log", + SC_LANDLOCK_FILE_WRITE | SC_LANDLOCK_FILE_TRUNCATE); + } + } + +The path is duplicated internally, so the caller does not need to keep +the string alive. Calling ``SCLandlockRegisterFile`` is always safe: if +Landlock is disabled at runtime the pending entries are simply freed +when the sandbox setup finishes. Suricata itself uses this entry point +from the profiling subsystems (``profiling.rules``, +``profiling.keywords``, ``profiling.prefilter``, ``profiling.rulegroups``, +``profiling.packets``, ``profiling.locks`` and ``profiling.pcap-log``) +whenever ``append: no`` is set, so the same pattern is available to +plugins that expose equivalent options. + +Cross-directory renames +^^^^^^^^^^^^^^^^^^^^^^^ + +Starting with Landlock ABI 2 (Linux 5.19), ``rename(2)`` between +different directories requires ``LANDLOCK_ACCESS_FS_REFER`` on both +source and destination sides. ``SCLandlockGrantWritePath`` deliberately +does **not** include this bit, which means a plain write grant does not +allow moving a file from ``/tmp`` to ``/final`` even though +both paths sit inside the same granted directory. When such a move is +needed -- as ``file-store`` does when it finalises a staged download -- +use ``SCLandlockGrantWriteReferPath`` instead: + +.. code-block:: c + + #include "util-landlock.h" + + static void MyPluginLandlockEnable(void *ruleset) + { + /* Grants standard write access + FS_REFER, so rename() between + * subdirectories rooted at /var/lib/my-plugin/store/ works. */ + SCLandlockGrantWriteReferPath(ruleset, "/var/lib/my-plugin/store/"); + } + +The grant is limited to renames staying under the given directory: +moves *out of* it are still refused by the kernel. Use this helper only +on directories fully owned by the module, and keep unrelated log or +data directories on the stricter ``SCLandlockGrantWritePath`` grant so +that a compromised code path cannot pull unrelated files into the +sandbox tree. + +Deleting your own files +^^^^^^^^^^^^^^^^^^^^^^^ + +``SCLandlockGrantWritePath`` does not grant +``LANDLOCK_ACCESS_FS_REMOVE_FILE`` either: unlinking files is a classic +anti-forensics primitive, and a module that only appends to its own +output never needs it. A module that legitimately recycles the files it +created -- as ``pcap-log`` does when ``max-files`` turns the output into +a ring buffer -- can opt in with ``SCLandlockGrantWriteRemovePath``: + +.. code-block:: c + + #include "util-landlock.h" + + static void MyPluginLandlockEnable(void *ruleset) + { + /* Standard write access + FS_REMOVE_FILE, so the module can + * unlink the files it created itself. */ + SCLandlockGrantWriteRemovePath(ruleset, "/var/log/my-plugin/ring/"); + } + +Use this helper only on a directory the module fully owns. Pointing it +at the shared log directory would let a compromised code path delete +``eve.json`` and every other log sitting next to it. + +Rewriting a whole directory in place +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``SCLandlockGrantRewritePath`` grants read and write access plus +``LANDLOCK_ACCESS_FS_TRUNCATE`` on a directory. It backs the +``security.landlock.directories.rewrite`` YAML list and targets state +files that are rewritten in place with ``fopen(..., "w")`` -- typically +dataset ``save``/``state`` files living outside the data directory. + +Prefer ``SCLandlockGrantFile`` with ``SC_LANDLOCK_FILE_TRUNCATE`` +whenever the set of files is known up front: it keeps truncate scoped to +a single inode instead of opening it up on every file in the directory. + +Binding a unix socket +^^^^^^^^^^^^^^^^^^^^^ + +Creating a unix socket requires ``LANDLOCK_ACCESS_FS_MAKE_SOCK``, which +is kept out of the default write grant because a module that merely +``connect()``\ s to an existing socket -- as the ``unix_stream`` and +``unix_dgram`` eve filetypes do -- never creates one. +``SCLandlockGrantSocketPath`` grants ``MAKE_SOCK`` on top of the write +access, plus ``FS_REMOVE_FILE`` because a stale socket has to be +unlinked before ``bind()`` can succeed: + +.. code-block:: c + + #include "util-landlock.h" + + static void MyPluginLandlockEnable(void *ruleset) + { + SCLandlockGrantSocketPath(ruleset, "/var/run/my-plugin/"); + } + +Only the directory holding the socket needs this grant; a plugin that +just connects to a socket created by somebody else does not. + Known limitations are: - Plugins can only use simple logging as defined by ``EveJsonSimpleTxLogFunc`` From b17e715c36d7abc1726c23318c0cbae1be80bbbe Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Tue, 28 Jul 2026 23:49:10 +0200 Subject: [PATCH 22/27] landlock: read permission for rules files The firewall rule file (--firewall-rules-exclusive) is loaded from the path as provided so an absolute one can be outside of every directory granted by the sandbox. It is thus granted per file. A relative path is resolved against firewall.rule-path which is already granted as a directory. Ticket: 8606 --- doc/userguide/configuration/landlock.rst | 6 ++++++ src/util-landlock.c | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/doc/userguide/configuration/landlock.rst b/doc/userguide/configuration/landlock.rst index 3f823bf4f896..0fa4dfab538d 100644 --- a/doc/userguide/configuration/landlock.rst +++ b/doc/userguide/configuration/landlock.rst @@ -59,6 +59,12 @@ standard library during normal startup; granting them avoids spurious ``EACCES`` errors and Landlock audit noise without meaningfully widening the sandbox. Missing paths are silently skipped. +Rule files passed on the command line are handled too: ``-s``/``-S`` get read +access on the directory holding the rule file, and +``--firewall-rules-exclusive`` gets a per-file read grant. A relative path is +resolved against ``default-rule-path`` (``firewall.rule-path`` for the firewall +rule file), which is granted as a directory. + Lua scripts writing their own files ----------------------------------- diff --git a/src/util-landlock.c b/src/util-landlock.c index 5665116e9a04..4b99f41d9e63 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -598,6 +598,21 @@ static void LandlockGrantDatasetsState(struct landlock_ruleset *ruleset) } } +/** \brief Grant read access on a rule file given on the command line. + * + * Only an absolute path gets a rule, a relative one being resolved by + * DetectLoadCompleteSigPathWithKey() against a configured rule path that is + * granted separately. No-op on a NULL path or on a file that does not exist. + */ +static void LandlockGrantRuleFile(struct landlock_ruleset *ruleset, const char *path) +{ + if (path == NULL || !PathIsAbsolute(path)) + return; + if (!SCPathExists(path)) + return; + SCLandlockGrantFile(ruleset, path, SC_LANDLOCK_FILE_READ); +} + void LandlockSandboxing(SCInstance *suri) { /* Read configuration variable and exit if no enforcement */ @@ -731,6 +746,11 @@ void LandlockSandboxing(SCInstance *suri) SCLandlockGrantReadPath(ruleset, rule_path); } } + /* The firewall rule file (--firewall-rules-exclusive) is loaded from the + * path as provided so an absolute one can be outside of the directories + * granted above. A relative path is resolved against firewall.rule-path + * which is already covered by the directory grants. */ + LandlockGrantRuleFile(ruleset, suri->firewall_rule_file); SCConfNode *read_dirs = SCConfGetNode("security.landlock.directories.read"); if (read_dirs) { From 0e970d7a9c2e9ac424a88480ef51cbd8048296ff Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Wed, 29 Jul 2026 09:07:05 +0200 Subject: [PATCH 23/27] landlock: add an option to rewrite a dir content This is needed to be able to get for example dataset saving state in custom directories. Truncation is not part of the default write access as zeroing a file is an anti-forensics primitive, so it has to be asked for explicitly on a directory owned by the caller. Ticket: 8606 --- doc/userguide/configuration/landlock.rst | 76 +++++++++++++++++++++++- src/util-landlock.c | 39 ++++++++++++ src/util-landlock.h | 2 + suricata.yaml.in | 6 ++ 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/doc/userguide/configuration/landlock.rst b/doc/userguide/configuration/landlock.rst index 0fa4dfab538d..003b8142ca64 100644 --- a/doc/userguide/configuration/landlock.rst +++ b/doc/userguide/configuration/landlock.rst @@ -36,8 +36,22 @@ To enable Landlock, edit the YAML and set ``enabled`` to ``yes``: - /etc/suricata/ Following your running configuration you may have to add some directories. -There are two lists you can use, ``write`` to add directories where write is needed -and ``read`` for directories where read access is needed. +There are three lists you can use, ``write`` to add directories where write is needed, +``read`` for directories where read access is needed and ``rewrite`` for +directories holding files that are rewritten in place. + +``rewrite`` grants read, write *and* truncate on the directory. It is needed +for files that are replaced in place each time they are updated, the previous +content being discarded first -- dataset ``save``/``state`` files are the +typical case. Plain ``write`` deliberately leaves truncation out, since +emptying a file is a classic way to erase traces, so a file that already has +content cannot be rewritten with ``write`` alone:: + + landlock: + enabled: yes + directories: + rewrite: + - /var/lib/mysets/ Built-in outputs (``pcap-log``, ``fast``, ``eve-log`` with ``redis``, ``unix_*`` and custom ``filename`` paths, ...) declare the filesystem and network access @@ -89,6 +103,64 @@ directory to ``security.landlock.directories.write``:: Scripts that only write through Suricata's own logging facilities do not need any extra permission. +Datasets using an absolute path +------------------------------- + +Suricata's data directory (``$localstatedir/lib/suricata``, where datasets are +kept by default) is granted read and write access automatically, so rules +whose ``load``, ``save`` or ``state`` file is a plain relative name work out +of the box. + +A rule that points at an *absolute* path is different: the file lives outside +the data directory and Suricata cannot guess it, so nothing grants it. Note +that absolute paths are refused outright unless +``datasets.rules.allow-absolute-filenames`` is enabled -- once it is, the +sandbox becomes the next thing in the way and the access has to be declared by +hand. + +Which list to use depends on the dataset keyword used in the signature: + +``load`` + add its directory to ``security.landlock.directories.read``, otherwise the + rule fails to load. + +``save`` + add its directory to ``security.landlock.directories.rewrite``. Plain + ``write`` creates the file the first time but cannot replace the content of + an existing one, so the set would silently stop being updated from the second + run on. Note that ``rewrite`` also grants read, which a ``save``-only set + does not need. + +``state`` + a shorthand for ``load`` plus ``save`` on the same file, so it needs the + same ``rewrite`` grant as ``save``. + +Note that a denied dataset save is quiet: the set is simply not written and no +error is logged. If a ``save``/``state`` file stops being updated after +enabling Landlock, a missing ``rewrite`` entry is the first thing to check. + +For example, with a rule such as:: + + alert dns any any -> any any (dns.query; \ + dataset:isnotset,dns-seen,type string,state /var/lib/mysets/dns-seen.txt; \ + sid:1; rev:1;) + +the matching configuration is:: + + landlock: + enabled: yes + directories: + rewrite: + - /var/lib/mysets/ + +The same directories can be passed on the command line instead:: + + suricata --set security.landlock.directories.rewrite.0=/var/lib/mysets/ + +Beware that ``--set`` on a list *replaces* the entry at that index: if the +YAML already defines entries, use the next free index (or add the directory to +the YAML) rather than overwriting index ``0``. + Granting access to network ports -------------------------------- diff --git a/src/util-landlock.c b/src/util-landlock.c index 4b99f41d9e63..0eb20e2796a4 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -138,6 +138,10 @@ void SCLandlockGrantWriteRemovePath(void *ruleset, const char *path) { } +void SCLandlockGrantRewritePath(void *ruleset, const char *path) +{ +} + void SCLandlockGrantFile(void *ruleset, const char *path, uint32_t access) { } @@ -383,6 +387,29 @@ void SCLandlockGrantReadPath(void *vruleset, const char *directory) } } +/** + * \brief Grant read and write access on a directory, plus truncation + * + * Same as SCLandlockGrantWritePath() but also grants read access and + * LANDLOCK_ACCESS_FS_TRUNCATE, so the files in \a directory can be rewritten + * in place with fopen(..., "w"). This backs the + * security.landlock.directories.rewrite configuration list. + * + * \param vruleset opaque landlock ruleset + * \param directory directory to grant the access on + */ +void SCLandlockGrantRewritePath(void *vruleset, const char *directory) +{ + struct landlock_ruleset *ruleset = vruleset; + if (ruleset == NULL || directory == NULL) + return; + uint64_t access = + _LANDLOCK_ACCESS_FS_READ | _LANDLOCK_SURI_ACCESS_FS_WRITE | LANDLOCK_ACCESS_FS_TRUNCATE; + if (LandlockSandboxingAddRule(ruleset, directory, access) == 0) { + SCLogConfig("Added read+write+truncate permission to '%s'", directory); + } +} + /** * \brief Grant read, write and/or truncate access on a single file * @@ -776,6 +803,18 @@ void LandlockSandboxing(SCInstance *suri) } } } + SCConfNode *rewrite_dirs = SCConfGetNode("security.landlock.directories.rewrite"); + if (rewrite_dirs) { + if (!SCConfNodeIsSequence(rewrite_dirs)) { + SCLogWarning("Invalid security.landlock.directories.rewrite configuration section: " + "expected a list of directory names."); + } else { + SCConfNode *directory; + TAILQ_FOREACH (directory, &rewrite_dirs->head, next) { + SCLandlockGrantRewritePath(ruleset, directory->val); + } + } + } LandlockSandboxingApplyNetPorts( ruleset, "security.landlock.network.connect.tcp", SCLandlockGrantNetConnectTCP); diff --git a/src/util-landlock.h b/src/util-landlock.h index 5f12538e4b24..40b6147313ec 100644 --- a/src/util-landlock.h +++ b/src/util-landlock.h @@ -40,6 +40,8 @@ void SCLandlockGrantWriteReferPath(void *ruleset, const char *path); void SCLandlockGrantWriteRemovePath(void *ruleset, const char *path); +void SCLandlockGrantRewritePath(void *ruleset, const char *path); + /** Per-file access flags for SCLandlockGrantFile(). Combine as needed. */ #define SC_LANDLOCK_FILE_READ (1U << 0) #define SC_LANDLOCK_FILE_WRITE (1U << 1) diff --git a/suricata.yaml.in b/suricata.yaml.in index 9219a325cd1a..3b4053e39bf4 100644 --- a/suricata.yaml.in +++ b/suricata.yaml.in @@ -1367,6 +1367,12 @@ security: directories: #write: # - @e_rundir@ + # rewrite grants read, write and truncate. Needed for files that are + # replaced in place each time they are updated, such as dataset + # save/state files stored outside the default data directory. Plain + # write does not allow overwriting an existing file's content. + #rewrite: + # - /var/lib/mysets/ # /usr and /etc folders are added to read list to allow # file magic to be used. read: From e4632bf8d1820063a01d78bb9ceee9b5b938769a Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Wed, 29 Jul 2026 09:46:09 +0200 Subject: [PATCH 24/27] github: add landlock workflow Run S-V with landlock enable. Ticket: 8606 --- .github/workflows/landlock.yml | 179 +++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 .github/workflows/landlock.yml diff --git a/.github/workflows/landlock.yml b/.github/workflows/landlock.yml new file mode 100644 index 000000000000..3b98074af867 --- /dev/null +++ b/.github/workflows/landlock.yml @@ -0,0 +1,179 @@ +name: landlock + +on: + push: + paths-ignore: + # Don't run this workflow if only files under doc/ have been + # modified. + - "doc/**" + - "etc/schema.json" + pull_request: + paths-ignore: + # Don't run this workflow if only files under doc/ have been + # modified. + - "doc/**" + - "etc/schema.json" + workflow_dispatch: + inputs: + SU_REPO: + SU_BRANCH: + SV_REPO: + SV_BRANCH: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: read-all + +env: + DEFAULT_CFLAGS: "-Wall -Wextra -Werror -Wno-unused-parameter -Wno-unused-function" + + # Apt sometimes likes to ask for user input, this will prevent that. + DEBIAN_FRONTEND: "noninteractive" + + # A recent version of stable Rust that is known to pass build, test and other + # verification steps in this workflow. This was added because using "stable" + # could cause some steps to fail. + RUST_VERSION_KNOWN: "1.95.0" + +jobs: + + prepare-deps: + name: Prepare dependencies + uses: ./.github/workflows/prepare-deps.yml + + # Run suricata-verify with the Landlock sandbox enabled. + # + # This job deliberately runs directly on the runner instead of in a + # container: Landlock is a kernel LSM, so enforcement depends on the host + # kernel rather than on the image. The Ubuntu runners ship a kernel with + # Landlock available (5.13+, ABI 1+), which is all Suricata needs. + landlock: + name: Landlock sandbox (suricata-verify) + runs-on: ubuntu-24.04 + needs: [prepare-deps] + steps: + - name: Determine number of CPUs + run: echo CPUS=$(nproc --all) >> $GITHUB_ENV + + - name: Cache ~/.cargo + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ~/.cargo/registry + key: cargo-registry + + - name: Install dependencies + run: | + sudo apt update + sudo apt -y install \ + autoconf \ + automake \ + build-essential \ + cargo \ + cbindgen \ + git \ + jq \ + libcap-ng-dev \ + libevent-dev \ + libhiredis-dev \ + libjansson-dev \ + liblz4-dev \ + liblua5.1-dev \ + libmagic-dev \ + libnet1-dev \ + libnetfilter-queue-dev \ + libnfnetlink-dev \ + libpcap-dev \ + libpcre2-dev \ + libtool \ + libyaml-dev \ + make \ + python3-yaml \ + rustc \ + zlib1g-dev + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + # Landlock is only compiled in when linux/landlock.h is present, and it + # is only enforced when the running kernel supports it. If either is + # missing Suricata logs an error and carries on unsandboxed, which would + # make every test below pass without exercising anything. Fail loudly + # instead. + - name: Check kernel Landlock support + run: | + echo "kernel: $(uname -r)" + if [ ! -e /sys/kernel/security/lsm ]; then + echo "securityfs not mounted, cannot confirm Landlock is active" >&2 + exit 1 + fi + cat /sys/kernel/security/lsm + if ! grep -q landlock /sys/kernel/security/lsm; then + echo "Landlock is not among the active LSMs on this kernel" >&2 + exit 1 + fi + + # Comes from prepare-deps rather than a plain checkout, so that a pull + # request can point at a matching suricata-verify branch or PR from its + # body (SV_REPO / SV_BRANCH). Landlock changes usually need paired test + # updates, so that override matters here. + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: prep + path: prep + - run: tar xf prep/suricata-verify.tar.gz + + - run: ./autogen.sh + - run: ./configure + - run: make -j ${{ env.CPUS }} + + - name: Check Landlock was built in + run: | + ./src/suricata --build-info | grep -E '^ *Landlock support: +yes$' + + # Confirm the sandbox actually engages before relying on the suite: a + # kernel or build without Landlock only logs an error and keeps running, + # so a green suite would otherwise prove nothing. + # Suricata's exit code is deliberately ignored: run from the build tree + # it also complains about the not-yet-installed sysconfdir and rule + # directory. Only the sandbox message matters here, the suite below is + # what checks Suricata actually behaves. + - name: Check the sandbox is enforced + run: | + mkdir -p landlock-check + ./src/suricata -c suricata.yaml -l landlock-check \ + --set security.landlock.enabled=yes \ + --set logging.default-log-level=info \ + -r suricata-verify/tests/datasets/datasets-parent-path/one-packet.pcap \ + > landlock-check/run.log 2>&1 || true + cat landlock-check/run.log + grep -q "Sandboxing via landlock is active" landlock-check/run.log + + # SV_LANDLOCK makes the handful of tests that cannot work under the + # sandbox skip themselves (see their test.yaml). + # + # The magic database has to be granted explicitly. Most tests ship their + # own suricata.yaml, which replaces the top level one entirely and so + # carries no landlock section at all -- the /usr/ read entry from the + # default configuration does not apply to them, and any test using the + # filemagic keyword then dies with "could not find any valid magic + # files". + # + # /usr is granted wholesale, the same thing the default suricata.yaml + # does, rather than guessing at the magic database location: libmagic + # picks its own compiled-in path and distributions disagree about it + # (/usr/share/misc, /usr/share/file, /usr/lib/file, ...). + # + # Index 3 is used because --set on a list replaces the entry at that + # index instead of appending: 0..2 are taken in the default + # suricata.yaml, and overwriting index 0 would silently drop /usr/. + - name: Run suricata-verify with Landlock enabled + run: | + SV_LANDLOCK=1 python3 ./suricata-verify/run.py -q --debug-failed \ + --set security.landlock.enabled=yes \ + --set security.landlock.directories.read.3=/usr + + # A run without the sandbox, to make sure a failure above is attributable + # to Landlock rather than to something unrelated. + - name: Run suricata-verify without Landlock (control) + run: python3 ./suricata-verify/run.py -q --debug-failed From cc6e4bfdb8df1a7ce736c9db8acc47b96ed0019f Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Wed, 29 Jul 2026 12:04:56 +0200 Subject: [PATCH 25/27] landlock: support older kernel headers struct landlock_ruleset_attr gained the scoped field in Linux 6.12 (Landlock ABI 6) and handled_access_net in Linux 6.7 (ABI 4). Missing constants can be defined by hand, a struct member cannot, so both need their own configure check and the code using them has to be guarded. Ticket: 8606 --- configure.ac | 17 +++++++++++++++++ src/util-landlock.c | 28 ++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/configure.ac b/configure.ac index 7f430ef4710c..142aecaa5f22 100644 --- a/configure.ac +++ b/configure.ac @@ -407,6 +407,23 @@ enable_landlock="no" if test "$ac_cv_header_linux_landlock_h" = "yes"; then enable_landlock="yes" + # struct landlock_ruleset_attr gained the scoped field in Linux 6.12 + # (Landlock ABI 6). Missing constants can be defined by hand, a + # struct member cannot, so it needs its own check. + AC_CHECK_MEMBER([struct landlock_ruleset_attr.scoped], + [AC_DEFINE([HAVE_LANDLOCK_RULESET_ATTR_SCOPED], [1], + [Set if struct landlock_ruleset_attr has the scoped field])], + [], + [[#include ]]) + # struct landlock_ruleset_attr gained the handled_access_net field in + # Linux 6.7 (Landlock ABI 4), together with struct + # landlock_net_port_attr and LANDLOCK_RULE_NET_PORT. As with scoped, a + # struct member cannot be defined by hand, so it needs its own check. + AC_CHECK_MEMBER([struct landlock_ruleset_attr.handled_access_net], + [AC_DEFINE([HAVE_LANDLOCK_RULESET_ATTR_HANDLED_ACCESS_NET], [1], + [Set if struct landlock_ruleset_attr has the handled_access_net field])], + [], + [[#include ]]) fi #check for plugin support diff --git a/src/util-landlock.c b/src/util-landlock.c index 0eb20e2796a4..f8fb6d781a9b 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -191,10 +191,26 @@ static inline int landlock_restrict_self(const int ruleset_fd, const __u32 flags #define LANDLOCK_ACCESS_FS_REFER (1ULL << 13) #endif +#ifndef LANDLOCK_ACCESS_FS_TRUNCATE +#define LANDLOCK_ACCESS_FS_TRUNCATE (1ULL << 14) +#endif + +#ifndef LANDLOCK_ACCESS_FS_IOCTL_DEV +#define LANDLOCK_ACCESS_FS_IOCTL_DEV (1ULL << 15) +#endif + #ifndef LANDLOCK_ACCESS_FS_RESOLVE_UNIX #define LANDLOCK_ACCESS_FS_RESOLVE_UNIX (1ULL << 18) #endif +#ifndef LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET +#define LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET (1ULL << 0) +#endif + +#ifndef LANDLOCK_SCOPE_SIGNAL +#define LANDLOCK_SCOPE_SIGNAL (1ULL << 1) +#endif + #define _LANDLOCK_ACCESS_FS_WRITE \ (LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_REMOVE_DIR | \ LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_MAKE_CHAR | \ @@ -240,7 +256,9 @@ static inline struct landlock_ruleset *LandlockCreateRuleset(void) ruleset->attr.handled_access_fs = _LANDLOCK_ACCESS_FS_READ | _LANDLOCK_ACCESS_FS_WRITE | LANDLOCK_ACCESS_FS_EXECUTE; +#ifdef HAVE_LANDLOCK_RULESET_ATTR_HANDLED_ACCESS_NET ruleset->attr.handled_access_net = _LANDLOCK_ACCESS_NET; +#endif int abi = landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION); if (abi < 0) { @@ -264,7 +282,9 @@ static inline struct landlock_ruleset *LandlockCreateRuleset(void) __attribute__((fallthrough)); case 3: /* Network access is only available from ABI 4 */ +#ifdef HAVE_LANDLOCK_RULESET_ATTR_HANDLED_ACCESS_NET ruleset->attr.handled_access_net &= ~_LANDLOCK_ACCESS_NET; +#endif __attribute__((fallthrough)); case 4: /* Device ioctl is only available from ABI 5 */ @@ -272,7 +292,9 @@ static inline struct landlock_ruleset *LandlockCreateRuleset(void) __attribute__((fallthrough)); case 5: /* Scoping is only available from ABI 6 */ +#ifdef HAVE_LANDLOCK_RULESET_ATTR_SCOPED ruleset->attr.scoped &= ~(LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET | LANDLOCK_SCOPE_SIGNAL); +#endif __attribute__((fallthrough)); case 6 ... 8: /* Unix socket resolution is only available from ABI 9 */ @@ -472,6 +494,7 @@ void SCLandlockGrantFile(void *vruleset, const char *path, uint32_t access) SCLogConfig("Added file permission (0x%x) on '%s'", access, path); } +#ifdef HAVE_LANDLOCK_RULESET_ATTR_HANDLED_ACCESS_NET static void LandlockGrantNetPort( struct landlock_ruleset *ruleset, uint16_t port, uint64_t access, const char *access_name) { @@ -491,6 +514,7 @@ static void LandlockGrantNetPort( } SCLogConfig("Added net %s permission on port %u", access_name, port); } +#endif /** * \brief Grant TCP bind permission on the given port @@ -503,7 +527,7 @@ static void LandlockGrantNetPort( */ void SCLandlockGrantNetBindTCP(void *vruleset, uint16_t port) { -#ifdef LANDLOCK_ACCESS_NET_BIND_TCP +#ifdef HAVE_LANDLOCK_RULESET_ATTR_HANDLED_ACCESS_NET LandlockGrantNetPort( (struct landlock_ruleset *)vruleset, port, LANDLOCK_ACCESS_NET_BIND_TCP, "bind-tcp"); #else @@ -523,7 +547,7 @@ void SCLandlockGrantNetBindTCP(void *vruleset, uint16_t port) */ void SCLandlockGrantNetConnectTCP(void *vruleset, uint16_t port) { -#ifdef LANDLOCK_ACCESS_NET_CONNECT_TCP +#ifdef HAVE_LANDLOCK_RULESET_ATTR_HANDLED_ACCESS_NET LandlockGrantNetPort((struct landlock_ruleset *)vruleset, port, LANDLOCK_ACCESS_NET_CONNECT_TCP, "connect-tcp"); #else From 7b3575085c69ca8d09a901089096e5197f1659c4 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Wed, 29 Jul 2026 13:54:02 +0200 Subject: [PATCH 26/27] landlock: absent config dir is not an error A directory listed in the configuration that does not exist on the system is not an error, there is simply nothing to grant. This is for example the case of the sysconfdir when Suricata is run from a build tree. Anything else than ENOENT is still reported as a warning. Ticket: 8606 --- src/util-landlock.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/util-landlock.c b/src/util-landlock.c index f8fb6d781a9b..36baac677a2d 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -330,7 +330,15 @@ static int LandlockSandboxingAddRule( int dir_fd = open(directory, O_PATH | O_CLOEXEC | O_DIRECTORY); if (dir_fd == -1) { - SCLogError("Can't open %s", directory); + /* A directory listed in the configuration that does not exist on this + * system is not an error: there is simply nothing to grant. Default + * paths such as the sysconfdir are missing whenever Suricata runs + * from a build tree. Report anything else as a warning. */ + if (errno == ENOENT) { + SCLogConfig("Skipping landlock rule for missing directory '%s'", directory); + } else { + SCLogWarning("Can't open '%s' for landlock rule: %s", directory, strerror(errno)); + } return -1; } path_beneath.parent_fd = dir_fd; From b50a3b2c4d7dd71c9b9a6285703a6f5eb1ef91f1 Mon Sep 17 00:00:00 2001 From: Eric Leblond Date: Fri, 31 Jul 2026 15:25:48 +0200 Subject: [PATCH 27/27] landlock: be stricter on write permissions Also just allow sockets usage in socket dirs. File removal, truncation and socket creation are not part of the default write access anymore. Removal and truncation are classic anti-forensics primitives, unlinking or zeroing logs and state files to erase traces, and socket creation is only needed by the unix command socket as connecting to an existing socket does not require it. The subsystems legitimately needing one of them now ask for it on the directory or the file they own. Directory creation stays in as creating a subdirectory on the fly is common enough (tls-store certs directory, a log filename holding a path, the Hyperscan cache) that removing it would only push the same grant into most of the callers. Ticket: 8606 --- src/util-landlock.c | 56 +++++++++++++++++++++++++++++++++------------ src/util-landlock.h | 2 ++ 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/util-landlock.c b/src/util-landlock.c index 36baac677a2d..863e9f55afdb 100644 --- a/src/util-landlock.c +++ b/src/util-landlock.c @@ -138,6 +138,10 @@ void SCLandlockGrantWriteRemovePath(void *ruleset, const char *path) { } +void SCLandlockGrantSocketPath(void *ruleset, const char *path) +{ +} + void SCLandlockGrantRewritePath(void *ruleset, const char *path) { } @@ -222,16 +226,13 @@ static inline int landlock_restrict_self(const int ruleset_fd, const __u32 flags #define _LANDLOCK_ACCESS_FS_READ (LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR) -/* Default write grant for directories Suricata owns. Deliberately excludes - * LANDLOCK_ACCESS_FS_REMOVE_FILE and LANDLOCK_ACCESS_FS_TRUNCATE: those are - * classic anti-forensics primitives (unlinking or zeroing logs/state to - * erase attacker traces). Subsystems that legitimately need to unlink or - * truncate their own files -- filestore staging cleanup, pcap ring-buffer - * rotation, datasets state.csv rewrite -- must register a scoped grant on - * their own directory or file. */ +/* Default write access granted on the directories Suricata writes to. + * + * File removal, truncation and socket creation are not part of it. A + * subsystem needing one of them has to ask for it explicitly on the + * directory or the file it owns. */ #define _LANDLOCK_SURI_ACCESS_FS_WRITE \ - (LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_MAKE_DIR | LANDLOCK_ACCESS_FS_MAKE_REG | \ - LANDLOCK_ACCESS_FS_MAKE_SOCK) + (LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_MAKE_REG | LANDLOCK_ACCESS_FS_MAKE_DIR) #ifndef LANDLOCK_ACCESS_NET_BIND_TCP #define LANDLOCK_ACCESS_NET_BIND_TCP (1ULL << 0) @@ -407,6 +408,28 @@ void SCLandlockGrantWriteRemovePath(void *vruleset, const char *directory) } } +/** + * \brief Grant write and removal access on a directory, plus socket creation + * + * Same as SCLandlockGrantWriteRemovePath() but also grants + * LANDLOCK_ACCESS_FS_MAKE_SOCK, so a unix socket can be bound in \a + * directory. Connecting to an existing socket does not need this access. + * + * \param vruleset opaque landlock ruleset + * \param directory directory to grant the access on + */ +void SCLandlockGrantSocketPath(void *vruleset, const char *directory) +{ + struct landlock_ruleset *ruleset = vruleset; + if (ruleset == NULL || directory == NULL) + return; + uint64_t access = _LANDLOCK_SURI_ACCESS_FS_WRITE | LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_SOCK; + if (LandlockSandboxingAddRule(ruleset, directory, access) == 0) { + SCLogConfig("Added socket permission to '%s'", directory); + } +} + void SCLandlockGrantReadPath(void *vruleset, const char *directory) { struct landlock_ruleset *ruleset = vruleset; @@ -781,22 +804,25 @@ void LandlockSandboxing(SCInstance *suri) SCFree(file_name); } } - if (ConfUnixSocketIsEnable()) { - /* Suricata unlinks any stale socket before bind(), so REMOVE is - * required on the socket directory. */ + /* ConfUnixSocketIsEnable() only looks at unix-command.enabled which + * --unix-socket does not set, it selects the runmode instead, so both + * have to be checked here. */ + if (ConfUnixSocketIsEnable() || SCRunmodeGet() == RUNMODE_UNIX_SOCKET) { + /* Binding the socket needs MAKE_SOCK, and Suricata unlinks any stale + * socket first, so REMOVE is required on the socket directory too. */ const char *socketname; if (SCConfGetNonNull("unix-command.filename", &socketname) == 1) { if (PathIsAbsolute(socketname)) { char *file_name = SCStrdup(socketname); if (file_name != NULL) { - SCLandlockGrantWriteRemovePath(ruleset, dirname(file_name)); + SCLandlockGrantSocketPath(ruleset, dirname(file_name)); SCFree(file_name); } } else { - SCLandlockGrantWriteRemovePath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); + SCLandlockGrantSocketPath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); } } else { - SCLandlockGrantWriteRemovePath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); + SCLandlockGrantSocketPath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); } } if (!suri->sig_file_exclusive) { diff --git a/src/util-landlock.h b/src/util-landlock.h index 40b6147313ec..52d425ef8467 100644 --- a/src/util-landlock.h +++ b/src/util-landlock.h @@ -40,6 +40,8 @@ void SCLandlockGrantWriteReferPath(void *ruleset, const char *path); void SCLandlockGrantWriteRemovePath(void *ruleset, const char *path); +void SCLandlockGrantSocketPath(void *ruleset, const char *path); + void SCLandlockGrantRewritePath(void *ruleset, const char *path); /** Per-file access flags for SCLandlockGrantFile(). Combine as needed. */