diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f8914..5c70c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Releases: every artifact (per-platform tarballs and `checksums.txt`) is now signed with [cosign](https://github.com/sigstore/cosign) keyless signing backed by GitHub's OIDC token. Each artifact has a sibling `.sigstore.json` bundle containing the signature, certificate (with the workflow identity baked in), and Rekor transparency-log inclusion proof. Verifying a download is a single `cosign verify-blob --bundle ...` invocation; see the new [Verifying releases](README.md#verifying-releases) section in the README for the exact commands. Requires cosign v2.0+ on the verifier side. - `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. ### Breaking diff --git a/README.md b/README.md index 4dcedba..f0f82d5 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Neither tells you whether your tool can **actually run**. For example, BPF LSM r | **BPF LSM enabled** (config + boot params + program type) | ✗ | ✗ | ✓ | | **IMA detection** (LSM list + securityfs directory) | ✗ | ✗ | ✓ | | **IMA any measurement active** (runtime measurement activity) | ✗ | ✗ | ✓ | +| **IMA runtime measurement count** (raw count for before/after probes) | ✗ | ✗ | ✓ | | **Process capabilities** (CAP_BPF, CAP_SYS_ADMIN, CAP_PERFMON) | ✗ | ✗ | ✓ | | **Unprivileged BPF status** | ✗ | ✓ | ✓ | | **Mount-state gates** (bpffs/tracefs/custom paths via superblock magic) | ✗ | ✗ | ✓ | diff --git a/probe.go b/probe.go index f84274b..fc33d25 100644 --- a/probe.go +++ b/probe.go @@ -441,9 +441,23 @@ func probeIMAAnyMeasurementActive() ProbeResult { return ProbeResult{Supported: false} } -// readMeasurementCount reads the IMA runtime measurement count. +// 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. +func ReadIMARuntimeMeasurementsCount() (int, error) { + return readMeasurementCount() +} + +// readMeasurementCount reads the IMA runtime measurement count from the +// default path. func readMeasurementCount() (int, error) { - data, err := os.ReadFile(imaMeasurementCountPath) + return readMeasurementCountFrom(imaMeasurementCountPath) +} + +// readMeasurementCountFrom reads an IMA runtime measurement count from the +// given path. Separated from readMeasurementCount for testability. +func readMeasurementCountFrom(path string) (int, error) { + data, err := os.ReadFile(path) if err != nil { return 0, err } diff --git a/probe_test.go b/probe_test.go index c88024b..c7c8c18 100644 --- a/probe_test.go +++ b/probe_test.go @@ -360,3 +360,86 @@ func TestProbeKprobeMulti_WithConfig(t *testing.T) { } }) } + +func TestReadMeasurementCountFrom(t *testing.T) { + t.Run("valid count", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "runtime_measurements_count") + if err := os.WriteFile(path, []byte("42\n"), 0644); err != nil { + t.Fatal(err) + } + + count, err := readMeasurementCountFrom(path) + if err != nil { + t.Fatalf("readMeasurementCountFrom() error = %v", err) + } + if count != 42 { + t.Errorf("count = %d, want 42", count) + } + }) + + t.Run("zero count", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "runtime_measurements_count") + if err := os.WriteFile(path, []byte("0\n"), 0644); err != nil { + t.Fatal(err) + } + + count, err := readMeasurementCountFrom(path) + if err != nil { + t.Fatalf("readMeasurementCountFrom() error = %v", err) + } + if count != 0 { + t.Errorf("count = %d, want 0", count) + } + }) + + t.Run("whitespace around number", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "runtime_measurements_count") + if err := os.WriteFile(path, []byte(" 7 \n"), 0644); err != nil { + t.Fatal(err) + } + + count, err := readMeasurementCountFrom(path) + if err != nil { + t.Fatalf("readMeasurementCountFrom() error = %v", err) + } + if count != 7 { + t.Errorf("count = %d, want 7", count) + } + }) + + t.Run("malformed content", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "runtime_measurements_count") + if err := os.WriteFile(path, []byte("not-a-number\n"), 0644); err != nil { + t.Fatal(err) + } + + _, err := readMeasurementCountFrom(path) + if err == nil { + t.Error("expected error for malformed content") + } + }) + + t.Run("empty content", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "runtime_measurements_count") + if err := os.WriteFile(path, []byte(""), 0644); err != nil { + t.Fatal(err) + } + + _, err := readMeasurementCountFrom(path) + if err == nil { + t.Error("expected error for empty content") + } + }) + + t.Run("missing file", func(t *testing.T) { + _, err := readMeasurementCountFrom("/nonexistent/path") + if err == nil { + t.Error("expected error for missing file") + } + }) +}