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
114 changes: 114 additions & 0 deletions internal/sensor/lsmguard/guard_helpers_linux_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
81 changes: 58 additions & 23 deletions internal/sensor/lsmguard/guard_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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()
Expand Down
Loading