diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c70c56..8ba21d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `NOTICE` file at repo root carrying the `Copyright 2026 Leonardo Di Donato` attribution. Apache 2.0 distinguishes the license text (canonical, verbatim, in `LICENSE`) from project-level attribution (in a `NOTICE` file that downstream consumers must propagate). The previous setup folded the copyright line into `LICENSE` itself; that conflated the two and is one of the deviations that caused licensecheck to mis-classify the file (see corresponding `### Fixed` entry). - 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. ### Breaking diff --git a/README.md b/README.md index f0f82d5..4ed8098 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Neither tells you whether your tool can **actually run**. For example, BPF LSM r | **IMA detection** (LSM list + securityfs directory) | ✗ | ✗ | ✓ | | **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) | ✗ | ✗ | ✓ | | **Process capabilities** (CAP_BPF, CAP_SYS_ADMIN, CAP_PERFMON) | ✗ | ✗ | ✓ | | **Unprivileged BPF status** | ✗ | ✓ | ✓ | | **Mount-state gates** (bpffs/tracefs/custom paths via superblock magic) | ✗ | ✗ | ✓ | @@ -333,7 +334,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, active LSM list | +| Security | BPF LSM enabled, IMA enabled, IMA any measurement active, IMA exec 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` | diff --git a/probe.go b/probe.go index fc33d25..e772648 100644 --- a/probe.go +++ b/probe.go @@ -4,10 +4,10 @@ package kfeatures import ( "errors" - "fmt" "os" "os/exec" "slices" + "strconv" "strings" "sync" @@ -441,6 +441,79 @@ func probeIMAAnyMeasurementActive() ProbeResult { return ProbeResult{Supported: false} } +// ProbeIMAExecMeasurementActive checks whether an IMA measurement rule +// covering exec (e.g., func=BPRM_CHECK) is active by executing a fresh +// temporary binary 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 exec stimulus +// increments the IMA measurement count. +func ProbeIMAExecMeasurementActive() ProbeResult { + return probeIMAExecMeasurementActive() +} + +// probeIMAExecMeasurementActive creates a fresh temporary executable (new +// inode), then takes a baseline measurement count, executes the binary, and +// re-reads the count. The measurement window is kept as narrow as possible: +// only the exec and whatever the kernel does for that exec are counted. +// A fresh inode avoids false negatives from IMA's per-inode measurement cache. +func probeIMAExecMeasurementActive() ProbeResult { + bin, cleanup, err := createFreshTempBinary() + if err != nil { + return ProbeResult{Supported: false, Error: err} + } + defer cleanup() + + before, err := readMeasurementCount() + if err != nil { + return ProbeResult{Supported: false, Error: err} + } + + if err := execTempBinary(bin); err != nil { + return ProbeResult{Supported: false, Error: err} + } + + after, err := readMeasurementCount() + if err != nil { + return ProbeResult{Supported: false, Error: err} + } + + return ProbeResult{Supported: after > before} +} + +// createFreshTempBinary copies /bin/true into a new temp directory (fresh +// inode) and returns the path, a cleanup function, and any error. The caller +// 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. +func createFreshTempBinary() (string, func(), error) { + noop := func() {} + + src, err := os.ReadFile("/bin/true") + if err != nil { + return "", noop, err + } + + dir, err := os.MkdirTemp("", "kfeatures-ima-probe-*") + if err != nil { + return "", noop, err + } + + bin := dir + "/probe" + if err := os.WriteFile(bin, src, 0700); err != nil { + os.RemoveAll(dir) + return "", noop, err + } + + return bin, func() { os.RemoveAll(dir) }, nil +} + +// execTempBinary executes the binary at the given path and waits for it +// to exit. +func execTempBinary(path string) error { + return (&exec.Cmd{Path: path}).Run() +} + // 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. @@ -461,8 +534,5 @@ func readMeasurementCountFrom(path string) (int, error) { if err != nil { return 0, err } - content := strings.TrimSpace(string(data)) - var count int - _, err = fmt.Sscanf(content, "%d", &count) - return count, err + return strconv.Atoi(strings.TrimSpace(string(data))) } diff --git a/probe_test.go b/probe_test.go index c7c8c18..edf3358 100644 --- a/probe_test.go +++ b/probe_test.go @@ -442,4 +442,69 @@ func TestReadMeasurementCountFrom(t *testing.T) { t.Error("expected error for missing file") } }) + + t.Run("trailing junk rejected", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "runtime_measurements_count") + if err := os.WriteFile(path, []byte("42abc\n"), 0644); err != nil { + t.Fatal(err) + } + + _, err := readMeasurementCountFrom(path) + if err == nil { + t.Error("expected error for trailing junk") + } + }) +} + +func TestCreateFreshTempBinary(t *testing.T) { + t.Run("creates executable path", func(t *testing.T) { + bin, cleanup, err := createFreshTempBinary() + if err != nil { + t.Fatalf("createFreshTempBinary() error = %v", err) + } + defer cleanup() + + info, err := os.Stat(bin) + if err != nil { + t.Fatalf("stat(%s) error = %v", bin, err) + } + if info.Mode()&0100 == 0 { + t.Errorf("binary not executable: mode = %v", info.Mode()) + } + }) + + t.Run("cleanup removes temp directory", func(t *testing.T) { + bin, cleanup, err := createFreshTempBinary() + if err != nil { + t.Fatalf("createFreshTempBinary() error = %v", err) + } + + dir := filepath.Dir(bin) + cleanup() + + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("temp directory still exists after cleanup: %s", dir) + } + }) +} + +func TestExecTempBinary(t *testing.T) { + t.Run("executes successfully", func(t *testing.T) { + bin, cleanup, err := createFreshTempBinary() + if err != nil { + t.Fatalf("createFreshTempBinary() error = %v", err) + } + defer cleanup() + + if err := execTempBinary(bin); err != nil { + t.Fatalf("execTempBinary() error = %v", err) + } + }) + + t.Run("nonexistent path returns error", func(t *testing.T) { + if err := execTempBinary("/nonexistent/binary"); err == nil { + t.Error("expected error for nonexistent binary") + } + }) }