Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | ✗ | ✗ | ✓ |
Expand Down Expand Up @@ -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` |
Expand Down
80 changes: 75 additions & 5 deletions probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ package kfeatures

import (
"errors"
"fmt"
"os"
"os/exec"
"slices"
"strconv"
"strings"
"sync"

Expand Down Expand Up @@ -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.
Expand All @@ -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)))
}
65 changes: 65 additions & 0 deletions probe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
}
Loading