diff --git a/go.mod b/go.mod index 980d3ca..8e7c948 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.4 require ( github.com/coreos/go-systemd/v22 v22.7.0 + github.com/elastic/go-libaudit/v2 v2.6.2 github.com/godbus/dbus/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/johnfercher/maroto/v2 v2.4.0 @@ -20,11 +21,13 @@ require ( github.com/boombuler/barcode v1.1.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elastic/go-licenser v0.4.1 // indirect github.com/f-amaral/go-async v0.3.0 // indirect github.com/hhrutter/lzw v1.0.0 // indirect github.com/hhrutter/pkcs7 v0.2.0 // indirect github.com/hhrutter/tiff v1.0.2 // indirect github.com/johnfercher/go-tree v1.1.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index 8e02170..9298851 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,10 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elastic/go-libaudit/v2 v2.6.2 h1:1PM6wVBTJHJQYsKl8jfA9/Aw9pFty5uUezPiUfKtOI4= +github.com/elastic/go-libaudit/v2 v2.6.2/go.mod h1:8205nkf2oSrXFlO4H5j8/cyVMoSF3Y7jt+FjgS4ubQU= +github.com/elastic/go-licenser v0.4.1 h1:1xDURsc8pL5zYT9R29425J3vkHdt4RT5TNEMeRN48x4= +github.com/elastic/go-licenser v0.4.1/go.mod h1:V56wHMpmdURfibNBggaSBfqgPxyT1Tldns1i87iTEvU= github.com/f-amaral/go-async v0.3.0 h1:h4kLsX7aKfdWaHvV0lf+/EE3OIeCzyeDYJDb/vDZUyg= github.com/f-amaral/go-async v0.3.0/go.mod h1:Hz5Qr6DAWpbTTUjytnrg1WIsDgS7NtOei5y8SipYS7U= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= @@ -35,6 +39,8 @@ github.com/johnfercher/go-tree v1.1.0/go.mod h1:DUO6QkXIFh1K7jeGBIkLCZaeUgnkdQAs github.com/johnfercher/maroto/v2 v2.4.0 h1:Nc/jA2RCZvNZESrQj41HJOgtkwmerSHd5FUbP4dRrIE= github.com/johnfercher/maroto/v2 v2.4.0/go.mod h1:Nnxa3g4f+vzdx/u/dUgx/52HnrCOCt5QBPSdeSlkFZQ= github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= @@ -63,25 +69,52 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211102192858-4dd72447c267/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/agent/auditnl/audit.go b/internal/agent/auditnl/audit.go new file mode 100644 index 0000000..d20db08 --- /dev/null +++ b/internal/agent/auditnl/audit.go @@ -0,0 +1,85 @@ +// Package auditnl holds the agent-side AUDIT_NETLINK primitives: the +// audit_rule_set handler uses them to load/unload audit rules via the +// kernel's netlink interface (the auditctl mechanism) instead of shelling +// out to augenrules, and the engine uses EmitPhaseEvent to write +// transaction-phase records into auditd. Netlink AUDIT requires +// CAP_AUDIT_CONTROL (root); when the socket cannot be opened the +// primitives return ErrAuditUnavailable so callers fall back to the shell +// path — mirroring systemd.ErrHelperNotFound. +package auditnl + +import ( + "errors" + "fmt" + "strings" + + libaudit "github.com/elastic/go-libaudit/v2" + "github.com/elastic/go-libaudit/v2/rule" + "github.com/elastic/go-libaudit/v2/rule/flags" +) + +// ErrAuditUnavailable is returned when the AUDIT netlink socket cannot be +// opened (no privilege, or audit not compiled in). A handler treats it as +// the signal to fall back to its shell path, exactly as the systemd +// handlers treat systemd.ErrHelperNotFound. +var ErrAuditUnavailable = errors.New("auditnl: audit netlink unavailable") + +// AuditClient is the subset of the go-libaudit client the handler uses, +// defined as an interface so tests can inject an in-memory fake without a +// real netlink socket. *libaudit.AuditClient satisfies it. +type AuditClient interface { + // AddRule loads a rule (in kernel wire format) into the kernel. + AddRule(rule []byte) error + // DeleteRule unloads a rule (in kernel wire format) from the kernel. + DeleteRule(rule []byte) error + // GetRules returns the currently-loaded rules in kernel wire format. + GetRules() ([][]byte, error) + // Close releases the netlink socket. + Close() error +} + +var _ AuditClient = (*libaudit.AuditClient)(nil) + +// Open opens a real AUDIT netlink client. A failure to open (the common +// non-root / no-audit case) is wrapped as ErrAuditUnavailable so callers +// can branch to their shell fallback. +func Open() (AuditClient, error) { + c, err := libaudit.NewAuditClient(nil) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrAuditUnavailable, err) + } + return c, nil +} + +// BuildRule parses one auditctl-syntax rule line (e.g. +// "-w /etc/passwd -p wa -k identity" or +// "-a always,exit -F arch=b64 -S execve -k exec") and returns its kernel +// wire format, suitable for AddRule/DeleteRule and for byte-equality +// comparison against GetRules output. The go-libaudit parser is the same +// grammar auditctl implements, so we do not reimplement it. +func BuildRule(line string) ([]byte, error) { + r, err := flags.Parse(line) + if err != nil { + return nil, fmt.Errorf("auditnl: parse %q: %w", line, err) + } + wire, err := rule.Build(r) + if err != nil { + return nil, fmt.Errorf("auditnl: build %q: %w", line, err) + } + return []byte(wire), nil +} + +// RuleLines splits a rule-set body into the individual audit-rule lines +// to load, dropping blank lines and comments (and the Kensa header). Each +// returned line is fed to BuildRule. +func RuleLines(body string) []string { + var out []string + for _, raw := range strings.Split(body, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out = append(out, line) + } + return out +} diff --git a/internal/agent/auditnl/audit_test.go b/internal/agent/auditnl/audit_test.go new file mode 100644 index 0000000..152ffd2 --- /dev/null +++ b/internal/agent/auditnl/audit_test.go @@ -0,0 +1,52 @@ +package auditnl_test + +import ( + "reflect" + "testing" + + "github.com/Hanalyx/kensa/internal/agent/auditnl" +) + +// BuildRule parses both watch and syscall auditctl syntax into non-empty +// wire format, and rejects a malformed line. +// +// @spec auditnl-rule-set +// @ac AC-01 +func TestBuildRule(t *testing.T) { + t.Run("auditnl-rule-set/AC-01", func(t *testing.T) {}) + for _, line := range []string{ + "-w /etc/passwd -p wa -k identity", + "-a always,exit -F arch=b64 -S execve -k exec", + } { + wire, err := auditnl.BuildRule(line) + if err != nil { + t.Errorf("BuildRule(%q): %v", line, err) + } + if len(wire) == 0 { + t.Errorf("BuildRule(%q): empty wire", line) + } + } + // Deterministic: same line → same wire (the equality basis for capture). + a, _ := auditnl.BuildRule("-w /etc/passwd -p wa -k identity") + b, _ := auditnl.BuildRule("-w /etc/passwd -p wa -k identity") + if !reflect.DeepEqual(a, b) { + t.Error("BuildRule is not deterministic") + } + if _, err := auditnl.BuildRule("this is not an audit rule"); err == nil { + t.Error("BuildRule should reject a malformed line") + } +} + +// RuleLines drops blanks, comments, and whitespace. +// +// @spec auditnl-rule-set +// @ac AC-01 +func TestRuleLines(t *testing.T) { + t.Run("auditnl-rule-set/AC-01", func(t *testing.T) {}) + body := "# Managed by Kensa.\n\n -w /etc/passwd -p wa -k identity \n# comment\n-w /etc/group -p wa -k identity\n" + got := auditnl.RuleLines(body) + want := []string{"-w /etc/passwd -p wa -k identity", "-w /etc/group -p wa -k identity"} + if !reflect.DeepEqual(got, want) { + t.Errorf("RuleLines = %v, want %v", got, want) + } +} diff --git a/internal/agent/auditnl/fake.go b/internal/agent/auditnl/fake.go new file mode 100644 index 0000000..c963fa3 --- /dev/null +++ b/internal/agent/auditnl/fake.go @@ -0,0 +1,73 @@ +package auditnl + +import ( + "encoding/hex" + + "github.com/Hanalyx/kensa/internal/agent/kernelio" +) + +// FakeAuditTransport is an in-memory test double implementing +// AuditTransport. It embeds kernelio.FakeSysctlTransport for the file + +// api.Transport surface and adds an in-memory kernel rule list, so a test +// can exercise a full audit Apply → Capture → Rollback round trip without +// a real netlink socket. Lives in the production package (a normal file) +// so the audit_rule_set handler tests can share it, mirroring +// servicedbus.FakeTransport / kernelio.FakeSysctlTransport. +type FakeAuditTransport struct { + *kernelio.FakeSysctlTransport + // Loaded is the in-memory kernel rule list, keyed by hex(wire). + Loaded map[string][]byte + // OpenErr, when set, is returned by AuditClient() — set it to + // ErrAuditUnavailable to exercise the shell fallback. + OpenErr error +} + +// NewFakeAudit returns a FakeAuditTransport with initialized state. +func NewFakeAudit() *FakeAuditTransport { + return &FakeAuditTransport{ + FakeSysctlTransport: kernelio.NewFakeSysctl(), + Loaded: map[string][]byte{}, + } +} + +// AuditClient returns an in-memory client over the fake's rule list, or +// OpenErr when set. +func (f *FakeAuditTransport) AuditClient() (AuditClient, error) { + if f.OpenErr != nil { + return nil, f.OpenErr + } + return &fakeAuditClient{t: f}, nil +} + +// LoadedLines is a test helper returning the count of loaded rules. +func (f *FakeAuditTransport) LoadedCount() int { return len(f.Loaded) } + +type fakeAuditClient struct{ t *FakeAuditTransport } + +func key(wire []byte) string { return hex.EncodeToString(wire) } + +func (c *fakeAuditClient) AddRule(wire []byte) error { + c.t.Loaded[key(wire)] = append([]byte(nil), wire...) + return nil +} + +func (c *fakeAuditClient) DeleteRule(wire []byte) error { + delete(c.t.Loaded, key(wire)) + return nil +} + +func (c *fakeAuditClient) GetRules() ([][]byte, error) { + out := make([][]byte, 0, len(c.t.Loaded)) + for _, w := range c.t.Loaded { + out = append(out, w) + } + return out, nil +} + +func (c *fakeAuditClient) Close() error { return nil } + +// Compile-time assertions. +var ( + _ AuditTransport = (*FakeAuditTransport)(nil) + _ AuditClient = (*fakeAuditClient)(nil) +) diff --git a/internal/agent/auditnl/transport.go b/internal/agent/auditnl/transport.go new file mode 100644 index 0000000..cd20d98 --- /dev/null +++ b/internal/agent/auditnl/transport.go @@ -0,0 +1,16 @@ +package auditnl + +import "github.com/Hanalyx/kensa/internal/agent/kernelio" + +// AuditTransport is the capability a transport implements when it can +// manage audit rules via AUDIT netlink: the FileTransport ops for the +// /etc/audit/rules.d drop-in persistence, plus AuditClient() to open a +// netlink client for the runtime rule load/unload. The audit_rule_set +// handler asserts it; AuditClient() returning ErrAuditUnavailable (or the +// assertion failing) sends the handler to its augenrules shell path. +type AuditTransport interface { + kernelio.FileTransport + // AuditClient opens a netlink client; the caller closes it. Returns a + // wrapped ErrAuditUnavailable when the socket cannot be opened. + AuditClient() (AuditClient, error) +} diff --git a/internal/agent/transport/local/local.go b/internal/agent/transport/local/local.go index ab50edd..822e097 100644 --- a/internal/agent/transport/local/local.go +++ b/internal/agent/transport/local/local.go @@ -29,6 +29,7 @@ import ( "time" "github.com/Hanalyx/kensa/api" + "github.com/Hanalyx/kensa/internal/agent/auditnl" "github.com/Hanalyx/kensa/internal/agent/fsatomic" "github.com/Hanalyx/kensa/internal/agent/kernelio" "github.com/Hanalyx/kensa/internal/agent/systemd" @@ -309,6 +310,14 @@ func (t *Transport) DeleteModule(name string) error { return kernelio.DeleteModule(name) } +// AuditClient opens an AUDIT netlink client. Satisfies +// auditnl.AuditTransport for the audit_rule_set handler's runtime rule +// load/unload. A non-root / no-audit host gets a wrapped +// auditnl.ErrAuditUnavailable, sending the handler to its shell path. +func (t *Transport) AuditClient() (auditnl.AuditClient, error) { + return auditnl.Open() +} + // Compile-time interface check. var ( _ api.Transport = (*Transport)(nil) @@ -316,4 +325,5 @@ var ( _ systemd.Transport = (*Transport)(nil) _ kernelio.SysctlTransport = (*Transport)(nil) _ kernelio.ModuleTransport = (*Transport)(nil) + _ auditnl.AuditTransport = (*Transport)(nil) ) diff --git a/internal/handlers/auditruleset/auditruleset.go b/internal/handlers/auditruleset/auditruleset.go index 069761b..3106967 100644 --- a/internal/handlers/auditruleset/auditruleset.go +++ b/internal/handlers/auditruleset/auditruleset.go @@ -1,8 +1,19 @@ // Package auditruleset implements the audit_rule_set handler: -// write an audit rule to /etc/audit/rules.d/ and reload with -// augenrules --load. Capture records whether the rule file existed and -// its prior content for rollback. -// Spec: specs/handlers/audit_rule_set.spec.yaml. +// write an audit rule to /etc/audit/rules.d/ and load it into the kernel. +// Capture records whether the rule file existed and its prior content for +// rollback. Spec: specs/handlers/audit_rule_set.spec.yaml. +// +// Dual path: when the transport implements auditnl.AuditTransport (agent +// mode with AUDIT netlink available) the handler loads each rule line into +// the running kernel via AUDIT_ADD_RULE and writes the drop-in atomically +// (fsatomic), instead of shelling out to augenrules. The netlink model is +// ADDITIVE per-rule (it loads this rule's lines into the kernel's flat +// rule list) rather than augenrules' whole-rules.d compile-and-load — so +// Capture records exactly which lines were NOT already loaded ("added_rules") +// and Rollback deletes only those, never a rule another drop-in owns. +// When netlink cannot be opened (no privilege / no audit, or an immutable +// audit config), the handler falls back to the augenrules shell path — +// which a host without netlink access behaves identically to before. package auditruleset import ( @@ -12,12 +23,18 @@ import ( "strings" "time" + "golang.org/x/sys/unix" + "github.com/Hanalyx/kensa/api" + "github.com/Hanalyx/kensa/internal/agent/auditnl" ) // mechanism is the canonical handler name. const mechanism = "audit_rule_set" +// auditFileMode is the drop-in file mode (audit rule files are sensitive). +const auditFileMode = 0o640 + // defaultRulesDir is the standard drop-in location for auditd rules. const defaultRulesDir = "/etc/audit/rules.d" @@ -84,6 +101,49 @@ func (h *Handler) Apply(ctx context.Context, transport api.Transport, params api if err != nil { return nil, err } + if at, ok := transport.(auditnl.AuditTransport); ok { + res, err := h.applyNetlink(ctx, at, p) + if !errors.Is(err, auditnl.ErrAuditUnavailable) { + return res, err + } + // Netlink unavailable (no privilege / immutable audit) → shell. + } + return h.applyShell(ctx, transport, p) +} + +// applyNetlink loads each rule line into the kernel via AUDIT_ADD_RULE and +// writes the drop-in atomically. +func (h *Handler) applyNetlink(ctx context.Context, at auditnl.AuditTransport, p *Params) (*api.StepResult, error) { + c, err := at.AuditClient() + if err != nil { + return nil, err // ErrAuditUnavailable propagates for fallback + } + defer c.Close() + + for _, line := range auditnl.RuleLines(p.Rule) { + wire, berr := auditnl.BuildRule(line) + if berr != nil { + // A malformed rule line is a non-compliant outcome, not a + // transport error. + return &api.StepResult{Success: false, Detail: fmt.Sprintf("audit_rule_set: %v", berr)}, nil + } + if aerr := c.AddRule(wire); aerr != nil && !errors.Is(aerr, unix.EEXIST) { + return &api.StepResult{Success: false, Detail: fmt.Sprintf("audit_rule_set: load rule %q: %v", line, aerr)}, nil + } + } + + content := "# Managed by Kensa.\n" + p.Rule + "\n" + if werr := at.AtomicReplace(ctx, p.RuleFile, auditFileMode, []byte(content)); werr != nil { + return nil, fmt.Errorf("audit_rule_set: persist write: %w", werr) + } + return &api.StepResult{ + Success: true, + Detail: fmt.Sprintf("audit_rule_set: loaded rules into kernel + wrote %s (netlink)", p.RuleFile), + }, nil +} + +// applyShell writes the drop-in and reloads via augenrules. +func (h *Handler) applyShell(ctx context.Context, transport api.Transport, p *Params) (*api.StepResult, error) { path := p.RuleFile content := "# Managed by Kensa.\n" + p.Rule + "\n" @@ -107,12 +167,58 @@ func (h *Handler) Apply(ctx context.Context, transport api.Transport, params api }, nil } -// Capture records whether the rule file existed and its prior content. +// Capture records whether the rule file existed and its prior content, +// plus (on the netlink path) which of the rule's lines are NOT already +// loaded in the kernel — the set Rollback will unload. func (h *Handler) Capture(ctx context.Context, transport api.Transport, params api.Params) (*api.PreState, error) { p, err := decodeParams(params) if err != nil { return nil, err } + if at, ok := transport.(auditnl.AuditTransport); ok { + pre, err := h.captureNetlink(ctx, at, p) + if !errors.Is(err, auditnl.ErrAuditUnavailable) { + return pre, err + } + // Netlink unavailable → shell capture. + } + return h.captureShell(ctx, transport, p) +} + +// captureNetlink records the file state and the added_rules set (rule +// lines not already loaded — by wire-format equality against the kernel's +// current rule list). +func (h *Handler) captureNetlink(ctx context.Context, at auditnl.AuditTransport, p *Params) (*api.PreState, error) { + c, err := at.AuditClient() + if err != nil { + return nil, err + } + defer c.Close() + + loaded, err := c.GetRules() + if err != nil { + return nil, fmt.Errorf("audit_rule_set: capture list rules: %w (%v)", api.ErrCaptureIncomplete, err) + } + var added []string + for _, line := range auditnl.RuleLines(p.Rule) { + wire, berr := auditnl.BuildRule(line) + if berr != nil { + return nil, fmt.Errorf("audit_rule_set: capture %w: %v", api.ErrCaptureIncomplete, berr) + } + if !containsWire(loaded, wire) { + added = append(added, line) // we will add it → rollback unloads it + } + } + + content, existed, err := at.ReadFileIfExists(p.RuleFile) + if err != nil { + return nil, fmt.Errorf("audit_rule_set: capture read %s: %w (%v)", p.RuleFile, api.ErrCaptureIncomplete, err) + } + return h.preState(p, existed, content, added), nil +} + +// captureShell records whether the rule file existed and its content. +func (h *Handler) captureShell(ctx context.Context, transport api.Transport, p *Params) (*api.PreState, error) { path := p.RuleFile cmd := fmt.Sprintf( "test -e %[1]s && cat %[1]s || printf '__KENSA_ABSENT__'", @@ -131,19 +237,74 @@ func (h *Handler) Capture(ctx context.Context, transport api.Transport, params a if fileExisted { priorContent = res.Stdout } + return h.preState(p, fileExisted, priorContent, nil), nil +} + +// preState builds the canonical PreState. added is the netlink-path set of +// rule lines to unload on rollback; nil/empty on the shell path. +func (h *Handler) preState(p *Params, fileExisted bool, priorContent string, added []string) *api.PreState { + data := map[string]interface{}{ + "path": p.RuleFile, + "file_existed": fileExisted, + "prior_content": priorContent, + } + if len(added) > 0 { + data["added_rules"] = added + } return &api.PreState{ Mechanism: mechanism, Capturable: true, CapturedAt: time.Now().UTC(), - Data: map[string]interface{}{ - "path": path, - "file_existed": fileExisted, - "prior_content": priorContent, - }, - }, nil + Data: data, + } +} + +// containsWire reports whether want appears in the set of wire-format rules. +func containsWire(set [][]byte, want []byte) bool { + for _, w := range set { + if bytesEqual(w, want) { + return true + } + } + return false } -// Rollback restores the prior rule file state and reloads augenrules. +// bytesEqual is a tiny dependency-free []byte compare (avoids importing +// bytes solely for this). +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// addedRules coerces the pre-state added_rules value (which round-trips +// through JSON as []interface{} from the store, or stays []string +// in-process) into a []string. +func addedRules(v interface{}) []string { + switch val := v.(type) { + case []string: + return val + case []interface{}: + out := make([]string, 0, len(val)) + for _, e := range val { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +// Rollback restores the prior rule-file state and unloads the rules the +// Apply added (netlink path) or reloads augenrules (shell path). func (h *Handler) Rollback(ctx context.Context, transport api.Transport, pre *api.PreState) (*api.RollbackResult, error) { if pre == nil || pre.Data == nil { return nil, errors.New("audit_rule_set: rollback called with nil pre-state") @@ -154,7 +315,58 @@ func (h *Handler) Rollback(ctx context.Context, transport api.Transport, pre *ap } fileExisted, _ := pre.Data["file_existed"].(bool) priorContent, _ := pre.Data["prior_content"].(string) + added := addedRules(pre.Data["added_rules"]) + + if at, ok := transport.(auditnl.AuditTransport); ok { + res, err := h.rollbackNetlink(ctx, at, path, fileExisted, priorContent, added) + if !errors.Is(err, auditnl.ErrAuditUnavailable) { + return res, err + } + // Netlink unavailable → shell rollback. + } + return h.rollbackShell(ctx, transport, path, fileExisted, priorContent) +} + +// rollbackNetlink restores the drop-in atomically and unloads exactly the +// rule lines Apply added (added_rules) — never a rule another drop-in owns. +func (h *Handler) rollbackNetlink(ctx context.Context, at auditnl.AuditTransport, path string, fileExisted bool, priorContent string, added []string) (*api.RollbackResult, error) { + // Restore persist layer first. + if fileExisted { + if err := at.AtomicReplace(ctx, path, auditFileMode, []byte(priorContent)); err != nil { + return nil, fmt.Errorf("audit_rule_set: rollback persist write: %w", err) + } + } else if err := at.AtomicRemove(ctx, path); err != nil { + return nil, fmt.Errorf("audit_rule_set: rollback persist remove: %w", err) + } + + c, err := at.AuditClient() + if err != nil { + return nil, err + } + defer c.Close() + for _, line := range added { + wire, berr := auditnl.BuildRule(line) + if berr != nil { + continue // unbuildable now but Apply built it; skip defensively + } + if derr := c.DeleteRule(wire); derr != nil && !errors.Is(derr, unix.ENOENT) { + return &api.RollbackResult{ + Success: false, + PartialRestore: true, + Detail: fmt.Sprintf("audit_rule_set: file restored but unload of %q failed: %v", line, derr), + ExecutedAt: time.Now().UTC(), + }, nil + } + } + return &api.RollbackResult{ + Success: true, + Detail: fmt.Sprintf("audit_rule_set: restored %s and unloaded %d rule(s) (netlink; file_existed=%v)", path, len(added), fileExisted), + ExecutedAt: time.Now().UTC(), + }, nil +} +// rollbackShell restores the drop-in and reloads augenrules. +func (h *Handler) rollbackShell(ctx context.Context, transport api.Transport, path string, fileExisted bool, priorContent string) (*api.RollbackResult, error) { var cmd string if fileExisted { cmd = fmt.Sprintf( diff --git a/internal/handlers/auditruleset/auditruleset_netlink_test.go b/internal/handlers/auditruleset/auditruleset_netlink_test.go new file mode 100644 index 0000000..967d8ee --- /dev/null +++ b/internal/handlers/auditruleset/auditruleset_netlink_test.go @@ -0,0 +1,171 @@ +package auditruleset_test + +import ( + "context" + "strings" + "testing" + + "github.com/Hanalyx/kensa/api" + "github.com/Hanalyx/kensa/internal/agent/auditnl" + "github.com/Hanalyx/kensa/internal/engine" + "github.com/Hanalyx/kensa/internal/handlers/auditruleset" +) + +const ruleLine = "-w /etc/passwd -p wa -k identity" +const auditPath = "/etc/audit/rules.d/99-kensa.rules" + +// Netlink Apply loads the rule into the kernel and writes the drop-in. +// +// @spec auditnl-rule-set +// @ac AC-02 +func TestApply_Netlink(t *testing.T) { + t.Run("auditnl-rule-set/AC-02", func(t *testing.T) {}) + f := auditnl.NewFakeAudit() + res, err := auditruleset.New().Apply(context.Background(), f, + api.Params{"rule": ruleLine}, nil) + if err != nil || !res.Success { + t.Fatalf("Apply: err=%v success=%v detail=%s", err, res.Success, res.Detail) + } + if f.LoadedCount() != 1 { + t.Errorf("loaded rule count = %d, want 1", f.LoadedCount()) + } + if got := f.Files[auditPath]; !strings.Contains(got, ruleLine) { + t.Errorf("drop-in = %q, want the rule", got) + } +} + +// A malformed rule line is a failed step, not a Go error, and nothing is +// loaded or persisted. +// +// @spec auditnl-rule-set +// @ac AC-02 +func TestApply_Netlink_BadRule(t *testing.T) { + t.Run("auditnl-rule-set/AC-02", func(t *testing.T) {}) + f := auditnl.NewFakeAudit() + res, err := auditruleset.New().Apply(context.Background(), f, + api.Params{"rule": "not a valid audit rule"}, nil) + if err != nil { + t.Fatalf("bad rule must not be a Go error; got %v", err) + } + if res.Success { + t.Error("want Success:false on a malformed rule") + } + if f.LoadedCount() != 0 { + t.Error("nothing should be loaded on a malformed rule") + } + if _, ok := f.Files[auditPath]; ok { + t.Error("drop-in must not be written when a rule fails to parse") + } +} + +// Netlink round trip: a rule not loaded at capture is unloaded on rollback, +// and the drop-in (absent at capture) is removed. +// +// @spec auditnl-rule-set +// @ac AC-03 +func TestRoundTrip_Netlink(t *testing.T) { + t.Run("auditnl-rule-set/AC-03", func(t *testing.T) {}) + f := auditnl.NewFakeAudit() + h := auditruleset.New() + params := api.Params{"rule": ruleLine} + + pre, err := h.Capture(context.Background(), f, params) + if err != nil { + t.Fatalf("Capture: %v", err) + } + if _, err := h.Apply(context.Background(), f, params, nil); err != nil { + t.Fatalf("Apply: %v", err) + } + if f.LoadedCount() != 1 { + t.Fatalf("post-apply loaded = %d, want 1", f.LoadedCount()) + } + rb, err := h.Rollback(context.Background(), f, pre) + if err != nil || !rb.Success { + t.Fatalf("Rollback: err=%v success=%v", err, rb.Success) + } + if f.LoadedCount() != 0 { + t.Errorf("rollback should have unloaded the rule it added; loaded=%d", f.LoadedCount()) + } + if _, ok := f.Files[auditPath]; ok { + t.Error("rollback should have removed the drop-in that did not exist at capture") + } +} + +// A rule already loaded at capture (owned by another drop-in) is NOT +// unloaded on rollback — the added_rules guard. +// +// @spec auditnl-rule-set +// @ac AC-04 +func TestRollback_Netlink_KeepsPreexisting(t *testing.T) { + t.Run("auditnl-rule-set/AC-04", func(t *testing.T) {}) + f := auditnl.NewFakeAudit() + // Pre-load the rule, as if another drop-in owns it. + c, _ := f.AuditClient() + wire, _ := auditnl.BuildRule(ruleLine) + _ = c.AddRule(wire) + _ = c.Close() + + h := auditruleset.New() + params := api.Params{"rule": ruleLine} + + pre, err := h.Capture(context.Background(), f, params) + if err != nil { + t.Fatalf("Capture: %v", err) + } + if _, err := h.Apply(context.Background(), f, params, nil); err != nil { + t.Fatalf("Apply: %v", err) + } + if _, err := h.Rollback(context.Background(), f, pre); err != nil { + t.Fatalf("Rollback: %v", err) + } + if f.LoadedCount() != 1 { + t.Errorf("a pre-existing rule must survive rollback; loaded=%d, want 1", f.LoadedCount()) + } +} + +// Fallback: AuditClient unavailable → augenrules shell path. +// +// @spec auditnl-rule-set +// @ac AC-05 +func TestApply_FallsBackWhenAuditUnavailable(t *testing.T) { + t.Run("auditnl-rule-set/AC-05", func(t *testing.T) {}) + f := auditnl.NewFakeAudit() + f.OpenErr = auditnl.ErrAuditUnavailable + res, err := auditruleset.New().Apply(context.Background(), f, + api.Params{"rule": ruleLine}, nil) + if err != nil || !res.Success { + t.Fatalf("fallback Apply: err=%v success=%v", err, res.Success) + } + var sawAugenrules bool + for _, cmd := range f.Runs { + if strings.Contains(cmd, "augenrules --load") { + sawAugenrules = true + } + } + if !sawAugenrules { + t.Errorf("expected augenrules shell fallback; Runs=%v", f.Runs) + } +} + +// Fallback: a transport without the audit capability uses the shell path. +// +// @spec auditnl-rule-set +// @ac AC-05 +func TestApply_FallsBackWhenNoCapability(t *testing.T) { + t.Run("auditnl-rule-set/AC-05", func(t *testing.T) {}) + tp := engine.NewFakeTransport() + res, err := auditruleset.New().Apply(context.Background(), tp, + api.Params{"rule": ruleLine}, nil) + if err != nil || !res.Success { + t.Fatalf("shell Apply: err=%v success=%v", err, res.Success) + } + var sawAugenrules bool + for _, cmd := range tp.Runs { + if strings.Contains(cmd, "augenrules --load") { + sawAugenrules = true + } + } + if !sawAugenrules { + t.Errorf("expected augenrules shell path; Runs=%v", tp.Runs) + } +} diff --git a/specs/handlers/auditnl-rule-set.spec.yaml b/specs/handlers/auditnl-rule-set.spec.yaml new file mode 100644 index 0000000..f970769 --- /dev/null +++ b/specs/handlers/auditnl-rule-set.spec.yaml @@ -0,0 +1,127 @@ +spec: + id: auditnl-rule-set + version: 0.1.0 + status: draft + tier: 1 + + context: + system: kensa + feature: auditnl-rule-set + description: | + The audit_rule_set handler loads audit rules into the running + kernel via AUDIT netlink (AUDIT_ADD_RULE / AUDIT_LIST_RULES / + AUDIT_DEL_RULE through github.com/elastic/go-libaudit) when + running on the target host (agent mode), and writes the + /etc/audit/rules.d drop-in atomically (fsatomic), instead of + shelling out to augenrules. The go-libaudit rule parser + (rule/flags.Parse) is the same grammar auditctl implements, so + the handler does not reimplement it. + + The netlink model is ADDITIVE per-rule: it loads this rule's + lines into the kernel's flat rule list (it does NOT replicate + augenrules' whole-rules.d compile-dedup-order-load). To keep + rollback safe, Capture records which of the rule's lines were + NOT already loaded ("added_rules"), and Rollback deletes only + those — never a rule another drop-in owns. + + AUDIT netlink needs CAP_AUDIT_CONTROL (root); when the socket + cannot be opened — or the audit config is immutable — the + handler falls back to the augenrules shell path, so a host + without netlink access behaves exactly as before. + related_specs: + - handler-audit-rule-set + - kernelio-sysctl + + objective: + summary: | + Provide the AUDIT netlink rule primitives (BuildRule + an + AuditClient) and wire audit_rule_set's Apply/Capture/Rollback to + use them in agent mode, preserving the augenrules shell + fallback, the drop-in persistence, and a rollback that unloads + only the rules this apply added. + scope: + includes: + - auditnl.BuildRule (flags.Parse + rule.Build → wire format) + - auditnl.RuleLines (split a rule body into loadable lines) + - auditnl.AuditClient interface + Open (ErrAuditUnavailable on failure) + - auditnl.AuditTransport capability (FileTransport + AuditClient()) + - audit_rule_set dual-path Apply/Capture/Rollback + excludes: + - reimplementing augenrules' whole-directory load semantics + - the engine transaction-phase event emission (separate deliverable) + - the shell path's behavior (handler-audit-rule-set) + + constraints: + - id: C-01 + description: | + BuildRule MUST parse one auditctl-syntax rule line via + go-libaudit and return its kernel wire format; the same line + MUST always yield the same bytes (the equality basis Capture + uses against AUDIT_LIST_RULES). A malformed line MUST return + an error, never a partial/zero rule. + type: technical + enforcement: error + - id: C-02 + description: | + audit_rule_set MUST select the netlink path iff the transport + implements auditnl.AuditTransport AND AuditClient() opens; an + ErrAuditUnavailable (no privilege / immutable audit) MUST send + the handler to the augenrules shell path. A malformed rule + line on the netlink path is a Success:false StepResult (and + nothing is loaded or persisted), not a returned error. + type: technical + enforcement: error + - id: C-03 + description: | + Capture MUST record added_rules = the rule lines NOT already + loaded in the kernel (by wire-format equality). Rollback MUST + unload ONLY added_rules — a rule that was already loaded at + capture (owned by another drop-in) MUST survive rollback. + AUDIT_DEL_RULE of an already-absent rule (ENOENT) is a no-op. + type: security + enforcement: error + - id: C-04 + description: | + The drop-in write MUST be atomic (fsatomic) on the netlink + path. Rollback MUST restore the prior drop-in content when it + existed at capture and remove it when it did not. + type: technical + enforcement: error + + acceptance_criteria: + - id: AC-01 + description: | + BuildRule parses watch ("-w /etc/passwd -p wa -k identity") + and syscall ("-a always,exit -F arch=b64 -S execve -k exec") + rules to non-empty, deterministic wire format and rejects a + malformed line; RuleLines drops blanks/comments. + references_constraints: [C-01] + priority: critical + - id: AC-02 + description: | + Netlink Apply loads the rule into the kernel and writes the + drop-in; a malformed rule line yields a Success:false step + with nothing loaded or persisted. + references_constraints: [C-02] + priority: critical + - id: AC-03 + description: | + Netlink Capture → Apply → Rollback unloads the rule it added + and removes a drop-in that did not exist at capture. + references_constraints: [C-03, C-04] + priority: critical + - id: AC-04 + description: | + A rule already loaded at capture (owned by another drop-in) + survives rollback — only added_rules are unloaded. + references_constraints: [C-03] + priority: critical + - id: AC-05 + description: | + The handler falls back to the augenrules shell path both when + the transport lacks the audit capability and when AuditClient() + returns ErrAuditUnavailable. + references_constraints: [C-02] + priority: critical + + tags: [handler, auditnl, audit, netlink, agent, tier-1, phase-5]