From df41bffd2e57574a63b6f3f6fdcc12073108f8ad Mon Sep 17 00:00:00 2001 From: leodido <120051+leodido@users.noreply.github.com> Date: Tue, 12 May 2026 14:12:22 +0000 Subject: [PATCH 1/7] feat: add ProbeIMAFileCheckMeasurementActive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checks whether an IMA measurement rule covering file open (e.g., func=FILE_CHECK) is active by creating a fresh temporary file, rewriting it to invalidate any IMA measurement cache from the initial create, then opening it O_RDONLY and checking for a count increase. The measurement window contains only the O_RDONLY open — the canonical FILE_CHECK stimulus. No count > 1 shortcut. Co-authored-by: Ona --- probe.go | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/probe.go b/probe.go index e772648..cd3bc48 100644 --- a/probe.go +++ b/probe.go @@ -514,6 +514,76 @@ func execTempBinary(path string) error { return (&exec.Cmd{Path: path}).Run() } +// ProbeIMAFileCheckMeasurementActive checks whether an IMA measurement rule +// covering file open (e.g., func=FILE_CHECK) is active by opening a fresh +// temporary file and checking for a measurement count increase. +// +// Unlike [ProbeIMAAnyMeasurementActive], this probe does not use a count > 1 +// shortcut. It returns Supported=true only when the controlled file-open +// stimulus increments the IMA measurement count. +func ProbeIMAFileCheckMeasurementActive() ProbeResult { + return probeIMAFileCheckMeasurementActive() +} + +// probeIMAFileCheckMeasurementActive creates a fresh temporary file (new +// inode), writes content to it, then invalidates any IMA measurement cache +// by rewriting the file. The baseline count is taken after all setup I/O, +// and the measurement window contains only an O_RDONLY open — the canonical +// FILE_CHECK stimulus. +func probeIMAFileCheckMeasurementActive() ProbeResult { + path, cleanup, err := createFreshTempFile() + if err != nil { + return ProbeResult{Supported: false, Error: err} + } + defer cleanup() + + // Rewrite the file to invalidate any IMA measurement cached from the + // initial create. IMA caches per-inode; a write invalidates the cache + // so the next open triggers a fresh measurement. + if err := os.WriteFile(path, []byte("ima-probe-stimulus\n"), 0644); err != nil { + return ProbeResult{Supported: false, Error: err} + } + + before, err := readMeasurementCount() + if err != nil { + return ProbeResult{Supported: false, Error: err} + } + + // Open O_RDONLY — this is the FILE_CHECK stimulus. + f, err := os.Open(path) + if err != nil { + return ProbeResult{Supported: false, Error: err} + } + f.Close() + + after, err := readMeasurementCount() + if err != nil { + return ProbeResult{Supported: false, Error: err} + } + + return ProbeResult{Supported: after > before} +} + +// createFreshTempFile creates a regular file with initial content in a new +// temp directory (fresh inode) and returns the path, a cleanup function, and +// any error. The caller must invoke cleanup when done. +func createFreshTempFile() (string, func(), error) { + noop := func() {} + + dir, err := os.MkdirTemp("", "kfeatures-ima-probe-*") + if err != nil { + return "", noop, err + } + + path := dir + "/probe-file" + if err := os.WriteFile(path, []byte("ima-probe-init\n"), 0644); err != nil { + os.RemoveAll(dir) + return "", noop, err + } + + return path, func() { os.RemoveAll(dir) }, nil +} + // ReadIMARuntimeMeasurementsCount returns the current IMA runtime measurement // count from /sys/kernel/security/ima/runtime_measurements_count. No side // effects. Useful for diagnostics and for callers building before/after probes. From 867da97d1b3ff3f520b5e14cd5e767d709232fa0 Mon Sep 17 00:00:00 2001 From: leodido <120051+leodido@users.noreply.github.com> Date: Tue, 12 May 2026 14:12:50 +0000 Subject: [PATCH 2/7] test: add createFreshTempFile coverage Tests that createFreshTempFile creates a non-empty regular file and that cleanup removes the temp directory. Co-authored-by: Ona --- probe_test.go | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/probe_test.go b/probe_test.go index edf3358..4725101 100644 --- a/probe_test.go +++ b/probe_test.go @@ -508,3 +508,38 @@ func TestExecTempBinary(t *testing.T) { } }) } + +func TestCreateFreshTempFile(t *testing.T) { + t.Run("creates regular file with content", func(t *testing.T) { + path, cleanup, err := createFreshTempFile() + if err != nil { + t.Fatalf("createFreshTempFile() error = %v", err) + } + defer cleanup() + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat(%s) error = %v", path, err) + } + if !info.Mode().IsRegular() { + t.Errorf("expected regular file, got mode = %v", info.Mode()) + } + if info.Size() == 0 { + t.Error("expected non-empty file") + } + }) + + t.Run("cleanup removes temp directory", func(t *testing.T) { + path, cleanup, err := createFreshTempFile() + if err != nil { + t.Fatalf("createFreshTempFile() error = %v", err) + } + + dir := filepath.Dir(path) + cleanup() + + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("temp directory still exists after cleanup: %s", dir) + } + }) +} From ccbc83779f7005c3e92bd83e1b80de4a6b2daf6d Mon Sep 17 00:00:00 2001 From: leodido <120051+leodido@users.noreply.github.com> Date: Tue, 12 May 2026 14:13:20 +0000 Subject: [PATCH 3/7] docs: add ProbeIMAFileCheckMeasurementActive to CHANGELOG and README Co-authored-by: Ona --- CHANGELOG.md | 1 + README.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ba21d4..68e5d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - README License section: "Why Apache 2.0" paragraph. Documents the kernel-uABI posture (no kernel source, no cgo, no GPL deps; `/proc` and `/sys` reads fall under the kernel `COPYING` "normal syscalls" carve-out) and the Apache-2.0-over-MIT rationale (patent grant for security-adjacent probing; same-license adopter base of Cilium, Tetragon, Falco, etc.). - `ReadIMARuntimeMeasurementsCount()`: exported helper that reads `/sys/kernel/security/ima/runtime_measurements_count` and returns the current count. No side effects. Useful for diagnostics and for callers building their own before/after measurement probes. - `ProbeIMAExecMeasurementActive()`: checks whether an IMA measurement rule covering exec (e.g., `func=BPRM_CHECK`) is active by creating a fresh temporary executable (new inode), running it, and checking for a measurement count increase. No count > 1 shortcut; returns `Supported=true` only when the controlled exec stimulus increments the count. A fresh inode avoids false negatives from IMA's per-inode measurement cache. +- `ProbeIMAFileCheckMeasurementActive()`: checks whether an IMA measurement rule covering file open (e.g., `func=FILE_CHECK`) is active by creating a fresh temporary file, rewriting it to invalidate any IMA measurement cache, then opening it `O_RDONLY` and checking for a count increase. The measurement window contains only the read-open — the canonical `FILE_CHECK` stimulus. ### Breaking diff --git a/README.md b/README.md index 4ed8098..1c2340a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Neither tells you whether your tool can **actually run**. For example, BPF LSM r | **IMA any measurement active** (runtime measurement activity) | ✗ | ✗ | ✓ | | **IMA runtime measurement count** (raw count for before/after probes) | ✗ | ✗ | ✓ | | **IMA exec measurement active** (fresh-inode exec probe) | ✗ | ✗ | ✓ | +| **IMA file-check measurement active** (fresh-inode file-open probe) | ✗ | ✗ | ✓ | | **Process capabilities** (CAP_BPF, CAP_SYS_ADMIN, CAP_PERFMON) | ✗ | ✗ | ✓ | | **Unprivileged BPF status** | ✗ | ✓ | ✓ | | **Mount-state gates** (bpffs/tracefs/custom paths via superblock magic) | ✗ | ✗ | ✓ | @@ -334,7 +335,7 @@ Tools exposed: `probe`, `check`, `config`. The server stays alive across busines |---|---| | Program types | LSM, kprobe, kprobe.multi, tracepoint, fentry | | Core | BTF availability (CO-RE) | -| Security | BPF LSM enabled, IMA enabled, IMA any measurement active, IMA exec measurement active, active LSM list | +| Security | BPF LSM enabled, IMA enabled, IMA any measurement active, IMA exec measurement active, IMA file-check measurement active, active LSM list | | Capabilities and runtime gates | CAP_BPF, CAP_SYS_ADMIN, CAP_PERFMON, unprivileged BPF disabled, BPF stats enabled | | Syscalls | `bpf()`, `perf_event_open()` | | JIT | enabled, hardened, kallsyms, memory limit, `CONFIG_BPF_JIT_ALWAYS_ON` | From 6be09c3788fe685c0865da29435e5d69625cc983 Mon Sep 17 00:00:00 2001 From: leodido <120051+leodido@users.noreply.github.com> Date: Tue, 12 May 2026 14:22:03 +0000 Subject: [PATCH 4/7] fix: unique probe content and non-tmpfs temp dir for IMA probes Two correctness fixes for IMA exec and file-check probes: 1. Append unique random bytes per invocation so IMA's global hash-table deduplication does not suppress repeated measurements of the same content across probes in the same boot. 2. Prefer /var/tmp over the default temp dir to avoid tmpfs, which common IMA policies exclude before FILE_CHECK/BPRM_CHECK rules. Falls back to the default temp dir if /var/tmp is unavailable or itself on tmpfs. Co-authored-by: Ona --- probe.go | 71 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/probe.go b/probe.go index cd3bc48..2ffdc10 100644 --- a/probe.go +++ b/probe.go @@ -3,6 +3,8 @@ package kfeatures import ( + "crypto/rand" + "encoding/hex" "errors" "os" "os/exec" @@ -486,6 +488,10 @@ func probeIMAExecMeasurementActive() ProbeResult { // must invoke cleanup when done. Materializing the binary before the // measurement window avoids false positives from FILE_CHECK rules measuring // the source read or temp-file write. +// +// A unique random trailer is appended to the ELF so that IMA's global +// hash-table deduplication does not suppress the measurement. The trailer +// sits past the ELF's segment table and does not affect execution. func createFreshTempBinary() (string, func(), error) { noop := func() {} @@ -494,7 +500,14 @@ func createFreshTempBinary() (string, func(), error) { return "", noop, err } - dir, err := os.MkdirTemp("", "kfeatures-ima-probe-*") + // Append random bytes so the digest is unique per invocation. + trailer, err := uniqueTrailer() + if err != nil { + return "", noop, err + } + src = append(src, trailer...) + + dir, err := imaProbeTempDir() if err != nil { return "", noop, err } @@ -526,10 +539,10 @@ func ProbeIMAFileCheckMeasurementActive() ProbeResult { } // probeIMAFileCheckMeasurementActive creates a fresh temporary file (new -// inode), writes content to it, then invalidates any IMA measurement cache -// by rewriting the file. The baseline count is taken after all setup I/O, -// and the measurement window contains only an O_RDONLY open — the canonical -// FILE_CHECK stimulus. +// inode), rewrites it with unique content to invalidate any IMA measurement +// cache and avoid global hash-table deduplication. The baseline count is +// taken after all setup I/O, and the measurement window contains only an +// O_RDONLY open — the canonical FILE_CHECK stimulus. func probeIMAFileCheckMeasurementActive() ProbeResult { path, cleanup, err := createFreshTempFile() if err != nil { @@ -537,10 +550,14 @@ func probeIMAFileCheckMeasurementActive() ProbeResult { } defer cleanup() - // Rewrite the file to invalidate any IMA measurement cached from the - // initial create. IMA caches per-inode; a write invalidates the cache - // so the next open triggers a fresh measurement. - if err := os.WriteFile(path, []byte("ima-probe-stimulus\n"), 0644); err != nil { + // Rewrite the file with unique content to invalidate any IMA measurement + // cached from the initial create and ensure the digest has not been seen + // before in IMA's global hash table. + trailer, err := uniqueTrailer() + if err != nil { + return ProbeResult{Supported: false, Error: err} + } + if err := os.WriteFile(path, trailer, 0644); err != nil { return ProbeResult{Supported: false, Error: err} } @@ -570,7 +587,7 @@ func probeIMAFileCheckMeasurementActive() ProbeResult { func createFreshTempFile() (string, func(), error) { noop := func() {} - dir, err := os.MkdirTemp("", "kfeatures-ima-probe-*") + dir, err := imaProbeTempDir() if err != nil { return "", noop, err } @@ -584,6 +601,40 @@ func createFreshTempFile() (string, func(), error) { return path, func() { os.RemoveAll(dir) }, nil } +// imaProbeTempDir creates a temp directory on a non-tmpfs filesystem. +// Common IMA policies exclude tmpfs before FILE_CHECK/BPRM_CHECK rules, +// which would cause false negatives. Prefers /var/tmp (typically on the +// root filesystem) over the default temp dir. +func imaProbeTempDir() (string, error) { + for _, base := range []string{"/var/tmp", os.TempDir()} { + if isNonTmpfs(base) { + return os.MkdirTemp(base, "kfeatures-ima-probe-*") + } + } + // Fall back to default if nothing qualifies (better than failing). + return os.MkdirTemp("", "kfeatures-ima-probe-*") +} + +// isNonTmpfs returns true if path exists and is not on a tmpfs filesystem. +func isNonTmpfs(path string) bool { + var st unix.Statfs_t + if err := unix.Statfs(path, &st); err != nil { + return false + } + return uint32(st.Type) != unix.TMPFS_MAGIC +} + +// uniqueTrailer returns 16 random bytes hex-encoded (32 bytes) to produce +// a unique file digest per invocation. This prevents IMA's global hash-table +// deduplication from suppressing repeated measurements. +func uniqueTrailer() ([]byte, error) { + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + return nil, err + } + return []byte(hex.EncodeToString(buf[:]) + "\n"), nil +} + // ReadIMARuntimeMeasurementsCount returns the current IMA runtime measurement // count from /sys/kernel/security/ima/runtime_measurements_count. No side // effects. Useful for diagnostics and for callers building before/after probes. From 6a5ccbcec7a18aeb02ce8e3400a8040573c04037 Mon Sep 17 00:00:00 2001 From: leodido <120051+leodido@users.noreply.github.com> Date: Tue, 12 May 2026 14:22:42 +0000 Subject: [PATCH 5/7] test: add uniqueTrailer, isNonTmpfs, imaProbeTempDir coverage Tests uniqueness across calls, tmpfs detection for nonexistent paths, and that imaProbeTempDir creates a directory on non-tmpfs when /var/tmp is available. Co-authored-by: Ona --- probe_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/probe_test.go b/probe_test.go index 4725101..d5ff13d 100644 --- a/probe_test.go +++ b/probe_test.go @@ -543,3 +543,66 @@ func TestCreateFreshTempFile(t *testing.T) { } }) } + +func TestUniqueTrailer(t *testing.T) { + t.Run("returns non-empty content", func(t *testing.T) { + data, err := uniqueTrailer() + if err != nil { + t.Fatalf("uniqueTrailer() error = %v", err) + } + if len(data) == 0 { + t.Error("expected non-empty trailer") + } + }) + + t.Run("successive calls produce different content", func(t *testing.T) { + a, err := uniqueTrailer() + if err != nil { + t.Fatalf("uniqueTrailer() error = %v", err) + } + b, err := uniqueTrailer() + if err != nil { + t.Fatalf("uniqueTrailer() error = %v", err) + } + if string(a) == string(b) { + t.Error("expected different trailers on successive calls") + } + }) +} + +func TestIsNonTmpfs(t *testing.T) { + t.Run("root filesystem is non-tmpfs", func(t *testing.T) { + if !isNonTmpfs("/") { + t.Skip("root is tmpfs on this system") + } + }) + + t.Run("nonexistent path returns false", func(t *testing.T) { + if isNonTmpfs("/nonexistent/path/that/should/not/exist") { + t.Error("expected false for nonexistent path") + } + }) +} + +func TestImaProbeTempDir(t *testing.T) { + t.Run("creates directory on non-tmpfs when available", func(t *testing.T) { + dir, err := imaProbeTempDir() + if err != nil { + t.Fatalf("imaProbeTempDir() error = %v", err) + } + defer os.RemoveAll(dir) + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat(%s) error = %v", dir, err) + } + if !info.IsDir() { + t.Errorf("expected directory, got mode = %v", info.Mode()) + } + + // Verify it's not on tmpfs if /var/tmp is available and non-tmpfs. + if isNonTmpfs("/var/tmp") && !isNonTmpfs(dir) { + t.Error("expected non-tmpfs directory when /var/tmp is available") + } + }) +} From 249a15212aeb79cbde5922a17b3c6c3b6f338959 Mon Sep 17 00:00:00 2001 From: leodido <120051+leodido@users.noreply.github.com> Date: Tue, 12 May 2026 14:27:46 +0000 Subject: [PATCH 6/7] fix: fail instead of silent tmpfs fallback in imaProbeTempDir Try all candidates (/var/tmp, os.TempDir) and continue to the next on write failure. Return an error with accumulated reasons if no writable non-tmpfs directory is available, so the probe result is 'inconclusive' rather than a misleading Supported=false from a tmpfs-excluded stimulus. Co-authored-by: Ona --- probe.go | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/probe.go b/probe.go index 2ffdc10..a183153 100644 --- a/probe.go +++ b/probe.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "encoding/hex" "errors" + "fmt" "os" "os/exec" "slices" @@ -604,15 +605,32 @@ func createFreshTempFile() (string, func(), error) { // imaProbeTempDir creates a temp directory on a non-tmpfs filesystem. // Common IMA policies exclude tmpfs before FILE_CHECK/BPRM_CHECK rules, // which would cause false negatives. Prefers /var/tmp (typically on the -// root filesystem) over the default temp dir. +// root filesystem) over the default temp dir. Returns an error if no +// writable non-tmpfs candidate is available rather than silently falling +// back to tmpfs. func imaProbeTempDir() (string, error) { + var errs []error + seen := map[string]bool{} + for _, base := range []string{"/var/tmp", os.TempDir()} { - if isNonTmpfs(base) { - return os.MkdirTemp(base, "kfeatures-ima-probe-*") + if base == "" || seen[base] { + continue + } + seen[base] = true + + if !isNonTmpfs(base) { + errs = append(errs, fmt.Errorf("%s is unavailable or tmpfs", base)) + continue } + + dir, err := os.MkdirTemp(base, "kfeatures-ima-probe-*") + if err == nil { + return dir, nil + } + errs = append(errs, fmt.Errorf("create temp dir under %s: %w", base, err)) } - // Fall back to default if nothing qualifies (better than failing). - return os.MkdirTemp("", "kfeatures-ima-probe-*") + + return "", fmt.Errorf("no writable non-tmpfs temp directory for IMA probe: %v", errs) } // isNonTmpfs returns true if path exists and is not on a tmpfs filesystem. From 1447a654a6ff9086d61d8c4fa2a39f6129733369 Mon Sep 17 00:00:00 2001 From: leodido <120051+leodido@users.noreply.github.com> Date: Tue, 12 May 2026 14:29:47 +0000 Subject: [PATCH 7/7] refactor: use errors.Join in imaProbeTempDir Produces a proper error chain instead of formatting []error with %v. Co-authored-by: Ona --- probe.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/probe.go b/probe.go index a183153..35da467 100644 --- a/probe.go +++ b/probe.go @@ -630,7 +630,7 @@ func imaProbeTempDir() (string, error) { errs = append(errs, fmt.Errorf("create temp dir under %s: %w", base, err)) } - return "", fmt.Errorf("no writable non-tmpfs temp directory for IMA probe: %v", errs) + return "", fmt.Errorf("no writable non-tmpfs temp directory for IMA probe: %w", errors.Join(errs...)) } // isNonTmpfs returns true if path exists and is not on a tmpfs filesystem.