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 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/doc/userguide/configuration/landlock.rst b/doc/userguide/configuration/landlock.rst index 65c7e8135bd8..003b8142ca64 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/ @@ -35,8 +36,156 @@ 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 +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. + +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 +----------------------------------- + +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. + +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 +-------------------------------- + +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, 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`` 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 b13890f6b9f9..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 { @@ -338,6 +343,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/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(); } diff --git a/src/conf.c b/src/conf.c index 4dc511808136..747e1f48f4be 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; @@ -886,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); diff --git a/src/log-pcap.c b/src/log-pcap.c index 1468cd144780..9fefe9992bd3 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,43 @@ static OutputInitResult PcapLogInitCtx(SCConfNode *); static void PcapLogProfilingDump(PcapLogData *); 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"); + 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 { + snprintf(path, sizeof(path), "%s/%s", SCConfigGetLogDirectory(), s_dir); + 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); + } +} + +static void PcapLogLandlockEnable(void *ruleset) +{ + SCLandlockForEachOutput(ruleset, "pcap-log", PcapLogLandlockEnableInstance); +} + void PcapLogRegister(void) { OutputPacketLoggerFunctions output_logger_functions = { @@ -228,6 +266,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 */ @@ -1977,6 +2019,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/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/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); 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/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/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 f7fa2b036670..863e9f55afdb 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 @@ -24,16 +24,137 @@ #include "suricata.h" #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" #include "util-mem.h" #include "util-path.h" +#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); + } +} + +/* 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) +{ +} + +void SCLandlockGrantWritePath(void *ruleset, const char *path) +{ +} + +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) +{ +} + +void SCLandlockGrantFile(void *ruleset, const char *path, uint32_t access) +{ +} + +void SCLandlockGrantNetBindTCP(void *ruleset, uint16_t port) +{ +} + +void SCLandlockGrantNetConnectTCP(void *ruleset, uint16_t port) { } @@ -74,19 +195,52 @@ 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 | \ 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) +/* 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_REMOVE_FILE | 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) +#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; @@ -103,20 +257,49 @@ 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) { 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: + /* 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 */ +#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 */ + ruleset->attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_IOCTL_DEV; + __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 */ + ruleset->attr.handled_access_fs &= ~LANDLOCK_ACCESS_FS_RESOLVE_UNIX; } ruleset->fd = landlock_create_ruleset(&ruleset->attr, sizeof(ruleset->attr), 0); @@ -148,7 +331,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; @@ -163,22 +354,347 @@ 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) +/** + * \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, 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 + */ +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 | + 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); + } +} + +/** + * \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; + if (ruleset == NULL || directory == NULL) + return; if (LandlockSandboxingAddRule(ruleset, directory, _LANDLOCK_ACCESS_FS_READ) == 0) { SCLogConfig("Added read permission to '%s'", 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 + * + * 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); +} + +#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) +{ + 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); +} +#endif + +/** + * \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 HAVE_LANDLOCK_RULESET_ATTR_HANDLED_ACCESS_NET + 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 HAVE_LANDLOCK_RULESET_ATTR_HANDLED_ACCESS_NET + LandlockGrantNetPort((struct landlock_ruleset *)vruleset, port, LANDLOCK_ACCESS_NET_CONNECT_TCP, + "connect-tcp"); +#else + (void)vruleset; + (void)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); + } +} + +/** \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); + } +} + +/* 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); + } + } +} + +/** \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 */ @@ -196,27 +712,52 @@ void LandlockSandboxing(SCInstance *suri) return; } - LandlockSandboxingWritePath(ruleset, SCConfigGetLogDirectory()); + LandlockGrantSystemReadPaths(ruleset); + + 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) { + /* 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)) { - LandlockSandboxingReadPath(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 { - LandlockSandboxingReadPath(ruleset, dirname(file_name)); + SCLandlockGrantReadPath(ruleset, dir); } } else { SCLogError("Can't open pcap file"); @@ -228,39 +769,73 @@ 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); } } + /* 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) { + /* 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) { - LandlockSandboxingWritePath(ruleset, dirname(file_name)); + SCLandlockGrantWriteRemovePath(ruleset, dirname(file_name)); SCFree(file_name); } } - if (ConfUnixSocketIsEnable()) { + /* 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) { - LandlockSandboxingWritePath(ruleset, dirname(file_name)); + SCLandlockGrantSocketPath(ruleset, dirname(file_name)); SCFree(file_name); } } else { - LandlockSandboxingWritePath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); + SCLandlockGrantSocketPath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); } } else { - LandlockSandboxingWritePath(ruleset, LOCAL_STATE_DIR "/run/suricata/"); + SCLandlockGrantSocketPath(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); } } + /* 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) { @@ -270,7 +845,7 @@ void LandlockSandboxing(SCInstance *suri) } else { SCConfNode *directory; TAILQ_FOREACH (directory, &read_dirs->head, next) { - LandlockSandboxingReadPath(ruleset, directory->val); + SCLandlockGrantReadPath(ruleset, directory->val); } } } @@ -282,12 +857,60 @@ void LandlockSandboxing(SCInstance *suri) } else { SCConfNode *directory; TAILQ_FOREACH (directory, &write_dirs->head, next) { - LandlockSandboxingWritePath(ruleset, directory->val); + SCLandlockGrantWritePath(ruleset, directory->val); + } + } + } + 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); + 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; + 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. */ + OutputModule *output_module; + TAILQ_FOREACH (output_module, &output_modules, entries) { + if (output_module->LandlockEnable != NULL) { + output_module->LandlockEnable(ruleset); + } + } + LandlockEnforceRuleset(ruleset); SCFree(ruleset); + SCLandlockPendingFilesFree(); + + SCLogInfo("Sandboxing via landlock is active"); } #endif /* HAVE_LINUX_LANDLOCK_H */ diff --git a/src/util-landlock.h b/src/util-landlock.h index 9d6c61dccb88..52d425ef8467 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,37 @@ #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); + +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. */ +#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); + void LandlockSandboxing(SCInstance *suri); #endif /* SURICATA_UTIL_LANDLOCK_H */ 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 */ 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; diff --git a/suricata.yaml.in b/suricata.yaml.in index 2e417d94b7cc..3b4053e39bf4 100644 --- a/suricata.yaml.in +++ b/suricata.yaml.in @@ -1361,9 +1361,18 @@ 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@ + # 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: