selftests/landlock: Add filesystem tracepoint tests

Add filesystem-specific trace tests in a dedicated file, following the
audit-test pattern of living alongside each subsystem's functional
tests.

trace_fs_test.c verifies that the add_rule_fs, check_rule_fs, and
deny_access_fs events fire with the correct fields on matching rules and
denied accesses, that check_rule_fs does not fire for unhandled access
types, and that no event fires without a sandbox.  A denial covered by a
quiet rule still emits a deny_access_fs event but with logged=0, the
same suppression verdict audit applies; because that verdict must not
depend on CONFIG_AUDIT, the test also runs under a tracepoints-only
build.

Add trace_layout1 fixture tests in fs_test.c that reuse the layout1
hierarchy to verify the per-layer grants field: field values, multi-rule
pathwalk short-circuit, request intersection, the optional truncate
right surfacing in the request and grants, and an empty grants set from
a rule that grants none of the requested rights.

Cc: Günther Noack <gnoack@google.com>
Cc: Tingmao Wang <m@maowtm.org>
Link: https://patch.msgid.link/20260811094338.288094-16-mic@digikod.net
Signed-off-by: Mickaël Salaün <mic@digikod.net>
This commit is contained in:
Mickaël Salaün
2026-08-17 10:17:17 +02:00
parent 2651712a15
commit 30478db56c
2 changed files with 979 additions and 0 deletions
+483
View File
@@ -44,6 +44,9 @@
#include "audit.h"
#include "common.h"
#include "trace.h"
#define TRACE_TASK "fs_test"
#ifndef renameat2
int renameat2(int olddirfd, const char *oldpath, int newdirfd,
@@ -10436,4 +10439,484 @@ TEST_F(audit_quiet_rename, quiet_behind_mountpoint_disconnected)
ASSERT_EQ(0, records.access);
}
/* clang-format off */
FIXTURE(trace_layout1) {
/* clang-format on */
int tracefs_ok;
};
FIXTURE_SETUP(trace_layout1)
{
struct stat st;
/*
* Check tracefs availability before creating the layout, following the
* layout3_fs pattern: skip before any layout creation to avoid leaving
* stale TMP_DIR on skip.
*/
if (stat(TRACEFS_LANDLOCK_DIR, &st)) {
self->tracefs_ok = 0;
SKIP(return, "tracefs not available");
}
self->tracefs_ok = 1;
/* Isolate tracefs state (PID filter, event enables). */
set_cap(_metadata, CAP_SYS_ADMIN);
ASSERT_EQ(0, unshare(CLONE_NEWNS));
ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL));
clear_cap(_metadata, CAP_SYS_ADMIN);
prepare_layout(_metadata);
create_layout1(_metadata);
set_cap(_metadata, CAP_DAC_OVERRIDE);
ASSERT_EQ(0, tracefs_fixture_setup());
ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, true));
ASSERT_EQ(0, tracefs_clear());
ASSERT_EQ(0, tracefs_set_pid_filter(getpid()));
clear_cap(_metadata, CAP_DAC_OVERRIDE);
}
FIXTURE_TEARDOWN_PARENT(trace_layout1)
{
if (!self->tracefs_ok)
return;
set_cap(_metadata, CAP_DAC_OVERRIDE);
tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, false);
tracefs_clear_pid_filter();
tracefs_fixture_teardown();
clear_cap(_metadata, CAP_DAC_OVERRIDE);
remove_layout1(_metadata);
cleanup_layout(_metadata);
}
/*
* Verifies that check_rule_fs events include correct field values: domain, dev,
* ino, access_request, and grants. All values are verified against stat() of
* the rule path on a deterministic tmpfs layout.
*/
TEST_F(trace_layout1, check_rule_fs_fields)
{
struct stat dir_stat;
char expected_dev[32];
char expected_ino[32];
char *buf;
char field[64];
if (!self->tracefs_ok)
SKIP(return, "tracefs not available");
ASSERT_EQ(0, stat(dir_s1d1, &dir_stat));
snprintf(expected_dev, sizeof(expected_dev), "%u:%u",
major(dir_stat.st_dev), minor(dir_stat.st_dev));
snprintf(expected_ino, sizeof(expected_ino), "%lu", dir_stat.st_ino);
set_cap(_metadata, CAP_DAC_OVERRIDE);
ASSERT_EQ(0, tracefs_clear());
clear_cap(_metadata, CAP_DAC_OVERRIDE);
sandbox_child_fs_access(_metadata, dir_s1d1,
LANDLOCK_ACCESS_FS_READ_DIR,
LANDLOCK_ACCESS_FS_READ_DIR, dir_s1d1);
set_cap(_metadata, CAP_DAC_OVERRIDE);
buf = tracefs_read_trace();
clear_cap(_metadata, CAP_DAC_OVERRIDE);
ASSERT_NE(NULL, buf);
EXPECT_EQ(1,
tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK)))
{
TH_LOG("Expected 1 check_rule_fs event\n%s", buf);
}
ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
"dev", field, sizeof(field)));
EXPECT_STREQ(expected_dev, field)
{
TH_LOG("Expected dev=%s, got %s", expected_dev, field);
}
ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
"ino", field, sizeof(field)));
EXPECT_STREQ(expected_ino, field)
{
TH_LOG("Expected ino=%s, got %s", expected_ino, field);
}
ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
"access_request", field,
sizeof(field)));
EXPECT_STREQ("read_dir", field)
{
TH_LOG("Expected access_request=read_dir, got %s", field);
}
/*
* The domain handles only READ_DIR, so the rule carries the
* unhandled-rights padding; intersecting with the request leaves just
* the requested read_dir (no padding, no hex).
*/
ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
"grants", field, sizeof(field)));
EXPECT_STREQ("{read_dir}", field)
{
TH_LOG("Expected grants={read_dir}, got %s", field);
}
free(buf);
}
/*
* Verifies check_rule_fs behavior with multiple rules. With rules at s1d1 and
* s1d2 (a child of s1d1), accessing s1d2 produces only 1 event because the
* pathwalk short-circuits after the first rule fully unmasks the single layer.
*/
TEST_F(trace_layout1, check_rule_fs_multiple_rules)
{
pid_t pid;
int status;
char *buf;
int count;
if (!self->tracefs_ok)
SKIP(return, "tracefs not available");
set_cap(_metadata, CAP_DAC_OVERRIDE);
ASSERT_EQ(0, tracefs_clear());
clear_cap(_metadata, CAP_DAC_OVERRIDE);
pid = fork();
ASSERT_LE(0, pid);
if (pid == 0) {
struct landlock_ruleset_attr attr = {
.handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR,
};
struct landlock_path_beneath_attr path_beneath = {
.allowed_access = LANDLOCK_ACCESS_FS_READ_DIR,
};
int ruleset_fd, fd;
ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0);
if (ruleset_fd < 0)
_exit(1);
path_beneath.parent_fd =
open(dir_s1d1, O_PATH | O_DIRECTORY | O_CLOEXEC);
if (path_beneath.parent_fd < 0)
_exit(1);
if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
&path_beneath, 0))
_exit(1);
close(path_beneath.parent_fd);
path_beneath.parent_fd =
open(dir_s1d2, O_PATH | O_DIRECTORY | O_CLOEXEC);
if (path_beneath.parent_fd < 0)
_exit(1);
if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
&path_beneath, 0))
_exit(1);
close(path_beneath.parent_fd);
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
if (landlock_restrict_self(ruleset_fd, 0))
_exit(1);
close(ruleset_fd);
fd = open(dir_s1d2, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd >= 0)
close(fd);
_exit(0);
}
ASSERT_EQ(pid, waitpid(pid, &status, 0));
ASSERT_TRUE(WIFEXITED(status));
EXPECT_EQ(0, WEXITSTATUS(status));
set_cap(_metadata, CAP_DAC_OVERRIDE);
buf = tracefs_read_trace();
clear_cap(_metadata, CAP_DAC_OVERRIDE);
ASSERT_NE(NULL, buf);
/*
* Only 1 check_rule_fs event: the rule on dir_s1d2 fully unmasked the
* single layer, so the pathwalk short-circuits before reaching the
* dir_s1d1 rule.
*/
count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
EXPECT_EQ(1, count)
{
TH_LOG("Expected 1 check_rule_fs event, got %d\n%s", count,
buf);
}
free(buf);
}
/*
* Verifies the grants array is intersected with the request: a handled,
* granted, but unrequested right (execute) is filtered out, leaving only the
* requested read_dir.
*/
TEST_F(trace_layout1, check_rule_fs_request_subset)
{
char *buf;
char field[64];
if (!self->tracefs_ok)
SKIP(return, "tracefs not available");
set_cap(_metadata, CAP_DAC_OVERRIDE);
ASSERT_EQ(0, tracefs_clear());
clear_cap(_metadata, CAP_DAC_OVERRIDE);
/*
* Handle and grant READ_DIR|EXECUTE; the open only requests read_dir.
*/
sandbox_child_fs_access(
_metadata, dir_s1d1,
LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE,
LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE,
dir_s1d1);
set_cap(_metadata, CAP_DAC_OVERRIDE);
buf = tracefs_read_trace();
clear_cap(_metadata, CAP_DAC_OVERRIDE);
ASSERT_NE(NULL, buf);
ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
"access_request", field,
sizeof(field)));
EXPECT_STREQ("read_dir", field);
ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
"grants", field, sizeof(field)));
EXPECT_STREQ("{read_dir}", field);
free(buf);
}
/*
* Verifies that the optional TRUNCATE access right, which hook_file_open()
* speculatively evaluates on every open, appears in the access_request= and
* grants= fields. Opening file1_s1d1 read-only needs only read_file, but the
* open hook also evaluates truncate; the domain handles and the rule grants
* both, so the event reports access_request=read_file|truncate and
* grants={read_file|truncate}, and the open is allowed.
*/
TEST_F(trace_layout1, check_rule_fs_optional_access)
{
pid_t pid;
int status;
char *buf;
char field[64];
int count;
if (!self->tracefs_ok)
SKIP(return, "tracefs not available");
set_cap(_metadata, CAP_DAC_OVERRIDE);
ASSERT_EQ(0, tracefs_clear());
clear_cap(_metadata, CAP_DAC_OVERRIDE);
pid = fork();
ASSERT_LE(0, pid);
if (pid == 0) {
struct landlock_ruleset_attr attr = {
.handled_access_fs = LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_TRUNCATE,
};
struct landlock_path_beneath_attr path_beneath = {
.allowed_access = LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_TRUNCATE,
};
int ruleset_fd, fd;
ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0);
if (ruleset_fd < 0)
_exit(1);
path_beneath.parent_fd =
open(dir_s1d1, O_PATH | O_DIRECTORY | O_CLOEXEC);
if (path_beneath.parent_fd < 0)
_exit(1);
if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
&path_beneath, 0))
_exit(1);
close(path_beneath.parent_fd);
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
if (landlock_restrict_self(ruleset_fd, 0))
_exit(1);
close(ruleset_fd);
/* Read-only open needs only read_file; truncate is optional. */
fd = open(file1_s1d1, O_RDONLY | O_CLOEXEC);
if (fd < 0)
_exit(1);
close(fd);
_exit(0);
}
ASSERT_EQ(pid, waitpid(pid, &status, 0));
ASSERT_TRUE(WIFEXITED(status));
/* The open is allowed: the required read_file is granted. */
EXPECT_EQ(0, WEXITSTATUS(status));
set_cap(_metadata, CAP_DAC_OVERRIDE);
buf = tracefs_read_trace();
clear_cap(_metadata, CAP_DAC_OVERRIDE);
ASSERT_NE(NULL, buf);
/* The rule at dir_s1d1 matches when opening file1_s1d1. */
count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
EXPECT_EQ(1, count)
{
TH_LOG("Expected 1 check_rule_fs event, got %d\n%s", count,
buf);
}
/* The open hook adds the optional truncate to the request. */
ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
"access_request", field,
sizeof(field)));
EXPECT_STREQ("read_file|truncate", field);
/* The rule grants both, so truncate appears in the grants array. */
ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
"grants", field, sizeof(field)));
EXPECT_STREQ("{read_file|truncate}", field);
free(buf);
}
/*
* Verifies that check_rule_fs fires for a rule that matches the inode even when
* it grants none of the requested rights, so the grants set is empty. Landlock
* cannot know a rule ignores the request before reading it, so the event is
* still emitted (grants={}), which lets a tracer see that the rule matched.
* The domain handles READ_DIR|EXECUTE, dir_s1d2 grants only EXECUTE and its
* parent dir_s1d1 grants only READ_DIR. Reading dir_s1d2 (requesting read_dir)
* first matches the dir_s1d2 rule, which grants nothing requested (grants={});
* walking up to dir_s1d1 then grants read_dir (grants={read_dir}) and allows
* the access.
*/
TEST_F(trace_layout1, check_rule_fs_empty_grant)
{
pid_t pid;
int status;
char *buf;
int count;
if (!self->tracefs_ok)
SKIP(return, "tracefs not available");
set_cap(_metadata, CAP_DAC_OVERRIDE);
ASSERT_EQ(0, tracefs_clear());
clear_cap(_metadata, CAP_DAC_OVERRIDE);
pid = fork();
ASSERT_LE(0, pid);
if (pid == 0) {
struct landlock_ruleset_attr attr = {
.handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR |
LANDLOCK_ACCESS_FS_EXECUTE,
};
struct landlock_path_beneath_attr path_beneath = {};
int ruleset_fd, fd;
ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0);
if (ruleset_fd < 0)
_exit(1);
/* Parent dir_s1d1 grants only READ_DIR. */
path_beneath.allowed_access = LANDLOCK_ACCESS_FS_READ_DIR;
path_beneath.parent_fd =
open(dir_s1d1, O_PATH | O_DIRECTORY | O_CLOEXEC);
if (path_beneath.parent_fd < 0)
_exit(1);
if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
&path_beneath, 0))
_exit(1);
close(path_beneath.parent_fd);
/* Child dir_s1d2 grants only EXECUTE. */
path_beneath.allowed_access = LANDLOCK_ACCESS_FS_EXECUTE;
path_beneath.parent_fd =
open(dir_s1d2, O_PATH | O_DIRECTORY | O_CLOEXEC);
if (path_beneath.parent_fd < 0)
_exit(1);
if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
&path_beneath, 0))
_exit(1);
close(path_beneath.parent_fd);
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
if (landlock_restrict_self(ruleset_fd, 0))
_exit(1);
close(ruleset_fd);
fd = open(dir_s1d2, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd < 0)
_exit(1);
close(fd);
_exit(0);
}
ASSERT_EQ(pid, waitpid(pid, &status, 0));
ASSERT_TRUE(WIFEXITED(status));
EXPECT_EQ(0, WEXITSTATUS(status));
set_cap(_metadata, CAP_DAC_OVERRIDE);
buf = tracefs_read_trace();
clear_cap(_metadata, CAP_DAC_OVERRIDE);
ASSERT_NE(NULL, buf);
/*
* dir_s1d2 (grants nothing requested) then dir_s1d1 (grants read_dir).
*/
count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
EXPECT_EQ(2, count)
{
TH_LOG("Expected 2 check_rule_fs events, got %d\n%s", count,
buf);
}
/* The dir_s1d2 rule matches the inode but grants none of read_dir. */
EXPECT_EQ(
1,
tracefs_count_matches(
buf,
TRACE_PREFIX(
TRACE_TASK) "landlock_check_rule_fs: domain=[0-9a-f]\\+ "
"access_request=read_dir "
"dev=[0-9]\\+:[0-9]\\+ ino=[0-9]\\+ "
"grants={}$"))
{
TH_LOG("Expected a grants={} event\n%s", buf);
}
/* Walking up to dir_s1d1 grants the requested read_dir. */
EXPECT_EQ(
1,
tracefs_count_matches(
buf,
TRACE_PREFIX(
TRACE_TASK) "landlock_check_rule_fs: domain=[0-9a-f]\\+ "
"access_request=read_dir "
"dev=[0-9]\\+:[0-9]\\+ ino=[0-9]\\+ "
"grants={read_dir}$"))
{
TH_LOG("Expected a grants={read_dir} event\n%s", buf);
}
free(buf);
}
TEST_HARNESS_MAIN
@@ -0,0 +1,496 @@
// SPDX-License-Identifier: GPL-2.0
/*
* Landlock tests - Filesystem tracepoints
*
* Copyright © 2026 Cloudflare, Inc.
*/
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <linux/landlock.h>
#include <sched.h>
#include <stdio.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include "common.h"
#include "trace.h"
#define TRACE_TASK "trace_fs_test"
/*
* Like REGEX_DENY_ACCESS_FS(), but pins the logged field to a specific value
* ("0" or "1") so a test can tell a suppressed (quiet) denial from a logged
* one. The tracepoint fires for every denial; logged carries the audit
* verdict.
*/
#define REGEX_DENY_ACCESS_FS_LOGGED(task, log) \
TRACE_PREFIX(task) \
"landlock_deny_access_fs: " \
"domain=[0-9a-f]\\+ " \
"same_exec=[01] " \
"logged=" log " " \
"blockers=[a-z_|]* " \
"dev=[0-9]\\+:[0-9]\\+ " \
"ino=[0-9]\\+ " \
"path=[^ ]*$"
/* clang-format off */
FIXTURE(trace_fs) {
/* clang-format on */
int tracefs_ok;
};
FIXTURE_SETUP(trace_fs)
{
int ret;
set_cap(_metadata, CAP_SYS_ADMIN);
ASSERT_EQ(0, unshare(CLONE_NEWNS));
ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL));
ret = tracefs_fixture_setup();
if (ret) {
clear_cap(_metadata, CAP_SYS_ADMIN);
self->tracefs_ok = 0;
SKIP(return, "tracefs not available");
}
self->tracefs_ok = 1;
ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ADD_RULE_FS_ENABLE, true));
ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, true));
ASSERT_EQ(0, tracefs_enable_event(TRACEFS_DENY_ACCESS_FS_ENABLE, true));
ASSERT_EQ(0, tracefs_clear());
clear_cap(_metadata, CAP_SYS_ADMIN);
}
FIXTURE_TEARDOWN(trace_fs)
{
if (!self->tracefs_ok)
return;
set_cap(_metadata, CAP_SYS_ADMIN);
tracefs_enable_event(TRACEFS_ADD_RULE_FS_ENABLE, false);
tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, false);
tracefs_enable_event(TRACEFS_DENY_ACCESS_FS_ENABLE, false);
tracefs_fixture_teardown();
clear_cap(_metadata, CAP_SYS_ADMIN);
}
/*
* Baseline: verifies that without Landlock, the operation succeeds and no
* check_rule or deny_access trace events fire.
*/
TEST_F(trace_fs, unsandboxed)
{
char *buf;
int count, status, fd;
pid_t pid;
ASSERT_EQ(0, tracefs_clear_buf());
pid = fork();
ASSERT_LE(0, pid);
if (pid == 0) {
/*
* No sandbox: verify that a normal FS access does not produce
* Landlock trace events.
*/
fd = open("/usr", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd >= 0)
close(fd);
_exit(0);
}
ASSERT_EQ(pid, waitpid(pid, &status, 0));
ASSERT_TRUE(WIFEXITED(status));
EXPECT_EQ(0, WEXITSTATUS(status));
buf = tracefs_read_buf();
ASSERT_NE(NULL, buf);
count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
EXPECT_EQ(0, count);
count = tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK));
EXPECT_EQ(0, count);
free(buf);
}
/*
* Verifies that adding a filesystem rule emits a landlock_add_rule_fs trace
* event with the expected path and field values: ruleset ID is non-zero,
* access_rights is non-zero, and path matches.
*/
TEST_F(trace_fs, add_rule_fs)
{
struct landlock_ruleset_attr ruleset_attr = {
.handled_access_fs = LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_WRITE_FILE |
LANDLOCK_ACCESS_FS_READ_DIR,
};
struct landlock_path_beneath_attr path_beneath = {
.allowed_access = LANDLOCK_ACCESS_FS_READ_FILE,
};
char *buf, field_buf[64];
int ruleset_fd, count;
ruleset_fd =
landlock_create_ruleset(&ruleset_attr, sizeof(ruleset_attr), 0);
ASSERT_LE(0, ruleset_fd);
path_beneath.parent_fd = open("/usr", O_PATH | O_DIRECTORY | O_CLOEXEC);
ASSERT_LE(0, path_beneath.parent_fd);
ASSERT_EQ(0, landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
&path_beneath, 0));
ASSERT_EQ(0, close(path_beneath.parent_fd));
ASSERT_EQ(0, close(ruleset_fd));
buf = tracefs_read_buf();
ASSERT_NE(NULL, buf);
count = tracefs_count_matches(buf, REGEX_ADD_RULE_FS(TRACE_TASK));
EXPECT_EQ(1, count)
{
TH_LOG("Expected 1 add_rule_fs event, got %d\n%s", count, buf);
}
/* Ruleset ID should be non-zero. */
ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_ADD_RULE_FS(TRACE_TASK),
"ruleset", field_buf,
sizeof(field_buf)));
EXPECT_STRNE("0", field_buf);
/* Access rights should be non-zero. */
ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_ADD_RULE_FS(TRACE_TASK),
"access_rights", field_buf,
sizeof(field_buf)));
EXPECT_STRNE("", field_buf);
/* Path should be /usr. */
ASSERT_EQ(0,
tracefs_extract_field(buf, REGEX_ADD_RULE_FS(TRACE_TASK),
"path", field_buf, sizeof(field_buf)));
EXPECT_STREQ("/usr", field_buf);
free(buf);
}
/*
* Verifies that an allowed access emits check_rule events (rule matched during
* pathwalk) but does NOT emit deny_access events (no denial).
*/
TEST_F(trace_fs, allowed_access)
{
char *buf, field_buf[64];
int count;
ASSERT_EQ(0, tracefs_clear_buf());
/* Rule allows READ_DIR for /usr, access /usr which is allowed. */
sandbox_child_fs_access(_metadata, "/usr", LANDLOCK_ACCESS_FS_READ_DIR,
LANDLOCK_ACCESS_FS_READ_DIR, "/usr");
buf = tracefs_read_buf();
ASSERT_NE(NULL, buf);
count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
EXPECT_LE(1, count);
/* Single-layer grants array, intersected with the request. */
ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
"grants", field_buf,
sizeof(field_buf)));
EXPECT_STREQ("{read_dir}", field_buf);
count = tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK));
EXPECT_EQ(0, count);
free(buf);
}
/*
* Verifies that accessing a path whose access type is not in the handled set
* does not emit landlock_check_rule events. The ruleset handles READ_FILE, but
* the directory open checks READ_DIR which is unhandled; Landlock has no
* opinion and no rule evaluation occurs.
*/
TEST_F(trace_fs, check_rule_unhandled)
{
char *buf;
int count;
ASSERT_EQ(0, tracefs_clear_buf());
/* Handles READ_FILE only; READ_DIR is unhandled. */
sandbox_child_fs_access(_metadata, "/usr", LANDLOCK_ACCESS_FS_READ_FILE,
LANDLOCK_ACCESS_FS_READ_FILE, "/tmp");
buf = tracefs_read_buf();
ASSERT_NE(NULL, buf);
/* No check_rule events because READ_DIR is not in the handled set. */
count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
EXPECT_EQ(0, count);
free(buf);
}
/*
* Verifies that nested domains (child sandboxed under a parent domain) emit
* check_rule events from both layers and produce a deny_access event when the
* inner domain's rule does not cover the access.
*/
TEST_F(trace_fs, check_rule_nested)
{
char *buf, field_buf[64], *comma;
size_t first_len, second_len;
int count_rule, count_access, status;
pid_t pid;
ASSERT_EQ(0, tracefs_clear_buf());
pid = fork();
ASSERT_LE(0, pid);
if (pid == 0) {
struct landlock_ruleset_attr ruleset_attr = {
.handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR,
};
struct landlock_path_beneath_attr path_beneath = {
.allowed_access = LANDLOCK_ACCESS_FS_READ_DIR,
};
int ruleset_fd, fd;
/* First layer: allow /usr. */
ruleset_fd = landlock_create_ruleset(&ruleset_attr,
sizeof(ruleset_attr), 0);
if (ruleset_fd < 0)
_exit(1);
path_beneath.parent_fd =
open("/usr", O_PATH | O_DIRECTORY | O_CLOEXEC);
if (path_beneath.parent_fd < 0) {
close(ruleset_fd);
_exit(1);
}
if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
&path_beneath, 0)) {
close(path_beneath.parent_fd);
close(ruleset_fd);
_exit(1);
}
close(path_beneath.parent_fd);
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
if (landlock_restrict_self(ruleset_fd, 0)) {
close(ruleset_fd);
_exit(1);
}
close(ruleset_fd);
/* Second layer: also allow /usr. */
ruleset_fd = landlock_create_ruleset(&ruleset_attr,
sizeof(ruleset_attr), 0);
if (ruleset_fd < 0)
_exit(1);
path_beneath.parent_fd =
open("/usr", O_PATH | O_DIRECTORY | O_CLOEXEC);
if (path_beneath.parent_fd < 0) {
close(ruleset_fd);
_exit(1);
}
if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
&path_beneath, 0)) {
close(path_beneath.parent_fd);
close(ruleset_fd);
_exit(1);
}
close(path_beneath.parent_fd);
if (landlock_restrict_self(ruleset_fd, 0)) {
close(ruleset_fd);
_exit(1);
}
close(ruleset_fd);
/* Access /usr which is allowed by both layers. */
fd = open("/usr", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd >= 0)
close(fd);
/* Access /tmp which has no rule in either layer. */
fd = open("/tmp", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd >= 0)
close(fd);
_exit(0);
}
ASSERT_EQ(pid, waitpid(pid, &status, 0));
ASSERT_TRUE(WIFEXITED(status));
EXPECT_EQ(0, WEXITSTATUS(status));
buf = tracefs_read_buf();
ASSERT_NE(NULL, buf);
count_rule =
tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
EXPECT_LE(1, count_rule);
/*
* Both layers have the same rule, so the grants array must have two
* identical symbolic entries, e.g. {read_dir,read_dir}.
*/
ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
"grants", field_buf,
sizeof(field_buf)));
comma = strchr(field_buf, ',');
EXPECT_NE(0, !!comma);
if (comma) {
/*
* Verify both entries are identical: compare the substring
* before the comma with the substring after it (stripping the
* braces).
*/
first_len = comma - field_buf - 1;
second_len = strlen(comma + 1) - 1;
EXPECT_EQ(first_len, second_len);
EXPECT_EQ(0, strncmp(field_buf + 1, comma + 1, first_len));
}
count_access =
tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK));
EXPECT_LE(1, count_access);
free(buf);
}
/*
* Verifies that a denied FS access emits a landlock_deny_access_fs trace event
* with the blocked access and path.
*/
TEST_F(trace_fs, deny_access_fs_denied)
{
char *buf;
int count;
ASSERT_EQ(0, tracefs_clear_buf());
/*
* Rule allows READ_DIR for /usr, but access /tmp which has no rule.
* READ_DIR access to /tmp is denied by absence and should emit a
* deny_access_fs event.
*/
sandbox_child_fs_access(_metadata, "/usr", LANDLOCK_ACCESS_FS_READ_DIR,
LANDLOCK_ACCESS_FS_READ_DIR, "/tmp");
buf = tracefs_read_buf();
ASSERT_NE(NULL, buf);
count = tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK));
EXPECT_LE(1, count);
free(buf);
}
/*
* A denied FS access covered by a quiet rule (LANDLOCK_ADD_RULE_QUIET with the
* access listed in quiet_access_fs) still emits a landlock_deny_access_fs
* event, but with logged=0, the same audit-logging verdict audit would apply to
* suppress the record.
*/
TEST_F(trace_fs, deny_access_fs_quiet)
{
char *buf, field[64];
pid_t pid;
int status;
ASSERT_EQ(0, tracefs_clear_buf());
pid = fork();
ASSERT_LE(0, pid);
if (pid == 0) {
struct landlock_ruleset_attr ruleset_attr = {
.handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR,
.quiet_access_fs = LANDLOCK_ACCESS_FS_READ_DIR,
};
struct landlock_path_beneath_attr path_beneath = {
.allowed_access = 0,
};
int ruleset_fd, fd;
ruleset_fd = landlock_create_ruleset(&ruleset_attr,
sizeof(ruleset_attr), 0);
if (ruleset_fd < 0)
_exit(1);
/* Marks /tmp quiet without granting any access. */
path_beneath.parent_fd =
open("/tmp", O_PATH | O_DIRECTORY | O_CLOEXEC);
if (path_beneath.parent_fd < 0) {
close(ruleset_fd);
_exit(1);
}
if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
&path_beneath, LANDLOCK_ADD_RULE_QUIET)) {
close(path_beneath.parent_fd);
close(ruleset_fd);
_exit(1);
}
close(path_beneath.parent_fd);
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
if (landlock_restrict_self(ruleset_fd, 0)) {
close(ruleset_fd);
_exit(1);
}
close(ruleset_fd);
/* Denied READ_DIR on the quiet /tmp: suppressed, logged=0. */
fd = open("/tmp", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd >= 0)
close(fd);
_exit(0);
}
ASSERT_EQ(pid, waitpid(pid, &status, 0));
ASSERT_TRUE(WIFEXITED(status));
EXPECT_EQ(0, WEXITSTATUS(status));
buf = tracefs_read_buf();
ASSERT_NE(NULL, buf);
/* The event fires with the suppressed verdict. */
EXPECT_LE(1, tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS_LOGGED(
TRACE_TASK, "0")));
/* The quiet rule must not leave the denial logged. */
EXPECT_EQ(0, tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS_LOGGED(
TRACE_TASK, "1")));
/*
* Quiet suppresses only the logged verdict: the rest of the denial
* event stays populated (non-zero domain, non-empty blockers).
*/
ASSERT_EQ(0, tracefs_extract_field(
buf, REGEX_DENY_ACCESS_FS_LOGGED(TRACE_TASK, "0"),
"domain", field, sizeof(field)));
EXPECT_STRNE("0", field);
ASSERT_EQ(0, tracefs_extract_field(
buf, REGEX_DENY_ACCESS_FS_LOGGED(TRACE_TASK, "0"),
"blockers", field, sizeof(field)));
EXPECT_STRNE("", field);
free(buf);
}
TEST_HARNESS_MAIN