diff --git a/internal/sensor/lsmguard/guard_helpers_linux_test.go b/internal/sensor/lsmguard/guard_helpers_linux_test.go new file mode 100644 index 0000000000..c9bc6d9f1c --- /dev/null +++ b/internal/sensor/lsmguard/guard_helpers_linux_test.go @@ -0,0 +1,114 @@ +//go:build linux + +package lsmguard + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/vulnetix/cli/v4/internal/sensor/decide" +) + +func TestCheckBPFLSM(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p + } + + if err := checkBPFLSM(write("with", "lockdown,capability,landlock,yama,bpf\n")); err != nil { + t.Errorf("a list naming bpf: %v", err) + } + err := checkBPFLSM(write("without", "lockdown,capability,landlock,yama,apparmor\n")) + if err == nil || !strings.Contains(err.Error(), "add bpf to lsm=") { + t.Errorf("a list without bpf must refuse with the fix, got %v", err) + } + if err := checkBPFLSM(filepath.Join(dir, "absent")); err != nil { + t.Errorf("an unreadable list leaves the decision to the attach: %v", err) + } +} + +func TestResolveEntries(t *testing.T) { + root := t.TempDir() + for _, p := range []string{"usr/bin/nc", "usr/bin/curl"} { + full := filepath.Join(root, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + } + // A hard link is the same file, so it is the same key. + if err := os.Link(filepath.Join(root, "usr/bin/nc"), filepath.Join(root, "usr/bin/ncat")); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "usr/lib"), 0o755); err != nil { + t.Fatal(err) + } + + rule := func(id string) decide.RuleRef { return decide.RuleRef{Table: "sigma", UUID: id} } + entries := []Entry{ + {Path: "/usr/bin/nc", Rule: rule("a")}, + {Path: "/usr/bin/missing", Rule: rule("b")}, + {Path: "/usr/bin/ncat", Rule: rule("c")}, // same file as entry 0 + {Path: "/usr/lib", Rule: rule("d")}, // a directory is not a program + {Path: "/usr/bin/curl", Rule: rule("e")}, + } + want, paths, missing := resolveEntries(entries, root) + + if !reflect.DeepEqual(missing, []string{"/usr/bin/missing", "/usr/lib"}) { + t.Errorf("missing = %v", missing) + } + if len(want) != 2 || len(paths) != 2 { + t.Fatalf("want %v, paths %v: two distinct files", want, paths) + } + ncKey, ok := statKey(filepath.Join(root, "usr/bin/nc")) + if !ok { + t.Fatal("stat nc") + } + if want[ncKey] != 0 || paths[ncKey] != "/usr/bin/nc" { + t.Errorf("the first entry naming a file keeps it: rule %d, path %q", want[ncKey], paths[ncKey]) + } + curlKey, _ := statKey(filepath.Join(root, "usr/bin/curl")) + if want[curlKey] != 4 || paths[curlKey] != "/usr/bin/curl" { + t.Errorf("curl: rule %d, path %q; the index is the entry's position", want[curlKey], paths[curlKey]) + } +} + +func TestRefusalFor(t *testing.T) { + entries := []Entry{ + {Path: "/usr/bin/nc", Rule: decide.RuleRef{Table: "sigma", UUID: "a"}}, + {Path: "/usr/bin/curl", Rule: decide.RuleRef{Table: "sigma", UUID: "b"}}, + } + known := fileKey{Ino: 7, Dev: 1} + paths := map[fileKey]string{known: "/usr/bin/ncat"} + + // A synced file is named by the path synced for it, with its rule. + r := refusalFor(rawRefusal{key: known, rule: 0, pid: 42, enforced: true}, entries, paths) + if r != (Refusal{Path: "/usr/bin/ncat", Rule: entries[0].Rule, PID: 42, Enforced: true}) { + t.Errorf("known key: %+v", r) + } + + // An unknown key falls back to the rule's own path. + r = refusalFor(rawRefusal{key: fileKey{Ino: 99}, rule: 1, pid: 5}, entries, paths) + if r != (Refusal{Path: "/usr/bin/curl", Rule: entries[1].Rule, PID: 5}) { + t.Errorf("unknown key: %+v", r) + } + + // A rule index outside the current entries names no rule. + r = refusalFor(rawRefusal{key: known, rule: 9, pid: 6}, entries, paths) + if r != (Refusal{Path: "/usr/bin/ncat", PID: 6}) { + t.Errorf("out-of-range rule with a known key: %+v", r) + } + r = refusalFor(rawRefusal{key: fileKey{Ino: 99}, rule: 9}, entries, paths) + if r != (Refusal{}) { + t.Errorf("out-of-range rule and unknown key: %+v", r) + } +} diff --git a/internal/sensor/lsmguard/guard_linux.go b/internal/sensor/lsmguard/guard_linux.go index fbbafd7b1a..e85b920d1c 100644 --- a/internal/sensor/lsmguard/guard_linux.go +++ b/internal/sensor/lsmguard/guard_linux.go @@ -40,11 +40,26 @@ type Guard struct { paths map[fileKey]string // for naming a refusal } +// lsmListPath is where the kernel lists the LSMs it runs. +const lsmListPath = "/sys/kernel/security/lsm" + +// checkBPFLSM refuses early, with the fix, when the kernel's LSM list (read +// from path) does not include bpf: loading would fail later with a verifier or +// attach error that does not say why. An unreadable list is not a refusal; the +// attach itself is then the test. +func checkBPFLSM(path string) error { + if lsms, err := os.ReadFile(path); err == nil && !bytes.Contains(lsms, []byte("bpf")) { + return errors.New("the kernel does not run the bpf LSM (add bpf to lsm= on the kernel command line)") + } + + return nil +} + // Load loads and attaches the program. It needs the bpf LSM enabled in the // kernel and CAP_BPF with CAP_SYS_ADMIN (or root). func Load() (*Guard, error) { - if lsms, err := os.ReadFile("/sys/kernel/security/lsm"); err == nil && !bytes.Contains(lsms, []byte("bpf")) { - return nil, errors.New("the kernel does not run the bpf LSM (add bpf to lsm= on the kernel command line)") + if err := checkBPFLSM(lsmListPath); err != nil { + return nil, err } _ = rlimit.RemoveMemlock() spec, err := Spec() @@ -86,20 +101,7 @@ func (g *Guard) SetEnforce(on bool) error { // under root (the host filesystem as the sensor sees it). It returns the // paths it could not resolve. func (g *Guard) Sync(entries []Entry, root string) (missing []string, err error) { - want := map[fileKey]uint32{} - paths := map[fileKey]string{} - for i, e := range entries { - k, ok := statKey(filepath.Join(root, e.Path)) - if !ok { - missing = append(missing, e.Path) - - continue - } - if _, dup := want[k]; !dup { - want[k] = uint32(i) - paths[k] = e.Path - } - } + want, paths, missing := resolveEntries(entries, root) g.mu.Lock() defer g.mu.Unlock() @@ -121,6 +123,29 @@ func (g *Guard) Sync(entries []Entry, root string) (missing []string, err error) return missing, nil } +// resolveEntries is what Sync writes: each entry's file identity under root +// mapped to the entry's index (the rule number the program reports) and to +// its path. A path that does not resolve to a regular file is missing; two +// entries naming one file keep the first. +func resolveEntries(entries []Entry, root string) (want map[fileKey]uint32, paths map[fileKey]string, missing []string) { + want = map[fileKey]uint32{} + paths = map[fileKey]string{} + for i, e := range entries { + k, ok := statKey(filepath.Join(root, e.Path)) + if !ok { + missing = append(missing, e.Path) + + continue + } + if _, dup := want[k]; !dup { + want[k] = uint32(i) + paths[k] = e.Path + } + } + + return want, paths, missing +} + func statKey(path string) (fileKey, bool) { var st unix.Stat_t if unix.Stat(path, &st) != nil || st.Mode&unix.S_IFMT != unix.S_IFREG { @@ -146,18 +171,28 @@ func (g *Guard) Run(ctx context.Context, report func(Refusal)) { continue } g.mu.Lock() - r := Refusal{PID: int(raw.pid), Enforced: raw.enforced, Path: g.paths[raw.key]} - if int(raw.rule) < len(g.entries) { - r.Rule = g.entries[raw.rule].Rule - if r.Path == "" { - r.Path = g.entries[raw.rule].Path - } - } + r := refusalFor(raw, g.entries, g.paths) g.mu.Unlock() report(r) } } +// refusalFor names a record from the program: the path synced for its file, +// else the path of the rule it reports, and the rule only when the index is +// one of the current entries (a record raced against a Sync that shrank the +// list keeps its path and loses the rule rather than naming the wrong one). +func refusalFor(raw rawRefusal, entries []Entry, paths map[fileKey]string) Refusal { + r := Refusal{PID: int(raw.pid), Enforced: raw.enforced, Path: paths[raw.key]} + if int(raw.rule) < len(entries) { + r.Rule = entries[raw.rule].Rule + if r.Path == "" { + r.Path = entries[raw.rule].Path + } + } + + return r +} + // Close detaches the program; execs are no longer checked. func (g *Guard) Close() { _ = g.rd.Close()