diff --git a/apparmor.d/groups/_full/default-sudo b/apparmor.d/groups/_full/default-sudo index f5289fae..45391b5c 100644 --- a/apparmor.d/groups/_full/default-sudo +++ b/apparmor.d/groups/_full/default-sudo @@ -6,7 +6,7 @@ abi , include -profile default-sudo @{exec_path} { +profile default-sudo { include include diff --git a/apparmor.d/groups/_full/systemd-service b/apparmor.d/groups/_full/systemd-service index f475039e..6a6357ce 100644 --- a/apparmor.d/groups/_full/systemd-service +++ b/apparmor.d/groups/_full/systemd-service @@ -12,7 +12,7 @@ abi , include -profile systemd-service @{exec_path} flags=(attach_disconnected) { +profile systemd-service flags=(attach_disconnected) { include include include diff --git a/apparmor.d/profiles-a-f/aa-status b/apparmor.d/profiles-a-f/aa-status index 19886bd2..7b94ce35 100644 --- a/apparmor.d/profiles-a-f/aa-status +++ b/apparmor.d/profiles-a-f/aa-status @@ -14,7 +14,7 @@ profile aa-status @{exec_path} { capability dac_read_search, capability sys_ptrace, - ptrace (read), + ptrace read, @{exec_path} mr, diff --git a/cmd/aa-log/main.go b/cmd/aa-log/main.go index cbc59c9e..dde4e2ca 100644 --- a/cmd/aa-log/main.go +++ b/cmd/aa-log/main.go @@ -67,11 +67,11 @@ func aaLog(logger string, path string, profile string) error { aaLogs := logs.NewApparmorLogs(file, profile) if rules { profiles := aaLogs.ParseToProfiles() - for _, profile := range profiles { - profile.MergeRules() - profile.Sort() - profile.Format() - fmt.Print(profile.String() + "\n") + for _, p := range profiles { + p.Merge() + p.Sort() + p.Format() + fmt.Print(p.String() + "\n\n") } } else { fmt.Print(aaLogs.String()) diff --git a/pkg/aa/all.go b/pkg/aa/all.go new file mode 100644 index 00000000..ba23aa10 --- /dev/null +++ b/pkg/aa/all.go @@ -0,0 +1,37 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2024 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package aa + +const ( + ALL Kind = "all" +) + +type All struct { + RuleBase +} + +func (r *All) Validate() error { + return nil +} + +func (r *All) Less(other any) bool { + return false +} + +func (r *All) Equals(other any) bool { + return false +} + +func (r *All) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *All) Constraint() constraint { + return blockKind +} + +func (r *All) Kind() Kind { + return ALL +} diff --git a/pkg/aa/apparmor.go b/pkg/aa/apparmor.go new file mode 100644 index 00000000..afac739b --- /dev/null +++ b/pkg/aa/apparmor.go @@ -0,0 +1,105 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2023 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package aa + +import ( + "github.com/roddhjav/apparmor.d/pkg/paths" +) + +// Default Apparmor magic directory: /etc/apparmor.d/. +var MagicRoot = paths.New("/etc/apparmor.d") + +// AppArmorProfileFiles represents a full set of apparmor profiles +type AppArmorProfileFiles map[string]*AppArmorProfileFile + +// AppArmorProfileFile represents a full apparmor profile file. +// Warning: close to the BNF grammar of apparmor profile but not exactly the same (yet): +// - Some rules are not supported yet (subprofile, hat...) +// - The structure is simplified as it only aims at writing profile, not parsing it. +type AppArmorProfileFile struct { + Preamble Rules + Profiles []*Profile +} + +func NewAppArmorProfile() *AppArmorProfileFile { + return &AppArmorProfileFile{} +} + +// DefaultTunables return a minimal working profile to build the profile +// It should not be used when loading file from /etc/apparmor.d +func DefaultTunables() *AppArmorProfileFile { + return &AppArmorProfileFile{ + Preamble: Rules{ + &Variable{Name: "bin", Values: []string{"/{,usr/}{,s}bin"}, Define: true}, + &Variable{Name: "etc_ro", Values: []string{"/{,usr/}etc/"}, Define: true}, + &Variable{Name: "HOME", Values: []string{"/home/*"}, Define: true}, + &Variable{Name: "int", Values: []string{"[0-9]{[0-9],}{[0-9],}{[0-9],}{[0-9],}{[0-9],}{[0-9],}{[0-9],}{[0-9],}{[0-9],}"}, Define: true}, + &Variable{Name: "int2", Values: []string{"[0-9][0-9]"}, Define: true}, + &Variable{Name: "lib", Values: []string{"/{,usr/}lib{,exec,32,64}"}, Define: true}, + &Variable{Name: "MOUNTS", Values: []string{"/media/*/", "/run/media/*/*/", "/mnt/*/"}, Define: true}, + &Variable{Name: "multiarch", Values: []string{"*-linux-gnu*"}, Define: true}, + &Variable{Name: "run", Values: []string{"/run/", "/var/run/"}, Define: true}, + &Variable{Name: "uid", Values: []string{"{[0-9],[1-9][0-9],[1-9][0-9][0-9],[1-9][0-9][0-9][0-9],[1-9][0-9][0-9][0-9][0-9],[1-9][0-9][0-9][0-9][0-9][0-9],[1-9][0-9][0-9][0-9][0-9][0-9][0-9],[1-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9],[1-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9],[1-4][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]}"}, Define: true}, + &Variable{Name: "user_cache_dirs", Values: []string{"/home/*/.cache"}, Define: true}, + &Variable{Name: "user_config_dirs", Values: []string{"/home/*/.config"}, Define: true}, + &Variable{Name: "user_share_dirs", Values: []string{"/home/*/.local/share"}, Define: true}, + }, + } +} + +// String returns the formatted representation of a profile file as a string +func (f *AppArmorProfileFile) String() string { + return renderTemplate("apparmor", f) +} + +// Validate the profile file +func (f *AppArmorProfileFile) Validate() error { + if err := f.Preamble.Validate(); err != nil { + return err + } + for _, p := range f.Profiles { + if err := p.Validate(); err != nil { + return err + } + } + return nil +} + +// GetDefaultProfile ensure a profile is always present in the profile file and +// return it, as a default profile. +func (f *AppArmorProfileFile) GetDefaultProfile() *Profile { + if len(f.Profiles) == 0 { + f.Profiles = append(f.Profiles, &Profile{}) + } + return f.Profiles[0] +} + +// Sort the rules in the profile +// Follow: https://apparmor.pujol.io/development/guidelines/#guidelines +func (f *AppArmorProfileFile) Sort() { + for _, p := range f.Profiles { + p.Sort() + } +} + +// MergeRules merge similar rules together. +// Steps: +// - Remove identical rules +// - Merge rule access. Eg: for same path, 'r' and 'w' becomes 'rw' +// +// Note: logs.regCleanLogs helps a lot to do a first cleaning +func (f *AppArmorProfileFile) MergeRules() { + for _, p := range f.Profiles { + p.Merge() + } +} + +// Format the profile for better readability before printing it. +// Follow: https://apparmor.pujol.io/development/guidelines/#the-file-block +func (f *AppArmorProfileFile) Format() { + for _, p := range f.Profiles { + p.Format() + } +} diff --git a/pkg/aa/apparmor_test.go b/pkg/aa/apparmor_test.go new file mode 100644 index 00000000..b1a2c961 --- /dev/null +++ b/pkg/aa/apparmor_test.go @@ -0,0 +1,250 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2024 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package aa + +import ( + "reflect" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/util" +) + +var ( + testData = paths.New("../../").Join("tests") + intData = paths.New("../../").Join("apparmor.d") +) + +func TestAppArmorProfileFile_String(t *testing.T) { + tests := []struct { + name string + f *AppArmorProfileFile + want string + }{ + { + name: "empty", + f: &AppArmorProfileFile{}, + want: ``, + }, + { + name: "foo", + f: &AppArmorProfileFile{ + Preamble: Rules{ + &Comment{RuleBase: RuleBase{Comment: " Simple test profile for the AppArmorProfileFile.String() method", IsLineRule: true}}, + nil, + &Abi{IsMagic: true, Path: "abi/4.0"}, + &Alias{Path: "/mnt/usr", RewrittenPath: "/usr"}, + &Include{IsMagic: true, Path: "tunables/global"}, + &Variable{ + Name: "exec_path", Define: true, + Values: []string{"@{bin}/foo", "@{lib}/foo"}, + }, + }, + Profiles: []*Profile{{ + Header: Header{ + Name: "foo", + Attachments: []string{"@{exec_path}"}, + Attributes: map[string]string{"security.tagged": "allowed"}, + Flags: []string{"complain", "attach_disconnected"}, + }, + Rules: Rules{ + &Include{IsMagic: true, Path: "abstractions/base"}, + &Include{IsMagic: true, Path: "abstractions/nameservice-strict"}, + rlimit1, + &Capability{Names: []string{"dac_read_search"}}, + &Capability{Names: []string{"dac_override"}}, + &Network{Domain: "inet", Type: "stream"}, + &Network{Domain: "inet6", Type: "stream"}, + &Mount{ + RuleBase: RuleBase{Comment: " failed perms check"}, + MountConditions: MountConditions{ + FsType: "fuse.portal", + Options: []string{"rw", "rbind"}, + }, + Source: "@{run}/user/@{uid}/", + MountPoint: "/", + }, + &Umount{ + MountConditions: MountConditions{}, + MountPoint: "@{run}/user/@{uid}/", + }, + &Signal{ + Access: []string{"receive"}, + Set: []string{"term"}, + Peer: "at-spi-bus-launcher", + }, + &Ptrace{Access: []string{"read"}, Peer: "nautilus"}, + &Unix{ + Access: []string{"send", "receive"}, + Type: "stream", + Address: "@/tmp/.ICE-unix/1995", + PeerLabel: "gnome-shell", + PeerAddr: "none", + }, + &Dbus{Access: []string{"bind"}, Bus: "session", Name: "org.gnome.*"}, + &Dbus{ + Access: []string{"receive"}, + Bus: "system", + Path: "/org/freedesktop/DBus", + Interface: "org.freedesktop.DBus", + Member: "AddMatch", + PeerName: ":1.3", + PeerLabel: "power-profiles-daemon", + }, + &File{Path: "/opt/intel/oneapi/compiler/*/linux/lib/*.so./*", Access: []string{"r", "m"}}, + &File{Path: "@{PROC}/@{pid}/task/@{tid}/comm", Access: []string{"r", "w"}}, + &File{Path: "@{sys}/devices/@{pci}/class", Access: []string{"r"}}, + includeLocal1, + }, + }}, + }, + want: util.MustReadFile(testData.Join("string.aa")), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.f.String(); got != tt.want { + t.Errorf("AppArmorProfile.String() = |%v|, want |%v|", got, tt.want) + } + }) + } +} + +func TestAppArmorProfileFile_Sort(t *testing.T) { + tests := []struct { + name string + origin *AppArmorProfileFile + want *AppArmorProfileFile + }{ + { + name: "all", + origin: &AppArmorProfileFile{ + Profiles: []*Profile{{ + Rules: []Rule{ + file2, network1, userns1, include1, dbus2, signal1, + ptrace1, includeLocal1, rlimit3, capability1, network2, + mqueue2, iouring2, dbus1, link2, capability2, file1, + unix2, signal2, mount2, all1, umount2, mount1, remount2, + pivotroot1, changeprofile2, + }, + }}, + }, + want: &AppArmorProfileFile{ + Profiles: []*Profile{{ + Rules: []Rule{ + include1, all1, rlimit3, userns1, capability1, capability2, + network2, network1, mount2, mount1, remount2, umount2, + pivotroot1, changeprofile2, mqueue2, iouring2, signal2, + signal1, ptrace1, unix2, dbus2, dbus1, file1, file2, + link2, includeLocal1, + }, + }}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.origin + got.Sort() + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("AppArmorProfile.Sort() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAppArmorProfileFile_MergeRules(t *testing.T) { + tests := []struct { + name string + origin *AppArmorProfileFile + want *AppArmorProfileFile + }{ + { + name: "all", + origin: &AppArmorProfileFile{ + Profiles: []*Profile{{ + Rules: []Rule{capability1, capability1, network1, network1, file1, file1}, + }}, + }, + want: &AppArmorProfileFile{ + Profiles: []*Profile{{ + Rules: []Rule{capability1, network1, file1}, + }}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.origin + got.MergeRules() + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("AppArmorProfile.MergeRules() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAppArmorProfileFile_Integration(t *testing.T) { + tests := []struct { + name string + f *AppArmorProfileFile + want string + }{ + { + name: "aa-status", + f: &AppArmorProfileFile{ + Preamble: Rules{ + &Comment{RuleBase: RuleBase{Comment: " apparmor.d - Full set of apparmor profiles", IsLineRule: true}}, + &Comment{RuleBase: RuleBase{Comment: " Copyright (C) 2021-2024 Alexandre Pujol ", IsLineRule: true}}, + &Comment{RuleBase: RuleBase{Comment: " SPDX-License-Identifier: GPL-2.0-only", IsLineRule: true}}, + nil, + &Abi{IsMagic: true, Path: "abi/3.0"}, + &Include{IsMagic: true, Path: "tunables/global"}, + &Variable{ + Name: "exec_path", Define: true, + Values: []string{"@{bin}/aa-status", "@{bin}/apparmor_status"}, + }, + }, + Profiles: []*Profile{{ + Header: Header{ + Name: "aa-status", + Attachments: []string{"@{exec_path}"}, + }, + Rules: Rules{ + &Include{IfExists: true, IsMagic: true, Path: "local/aa-status"}, + &Capability{Names: []string{"dac_read_search"}}, + &File{Path: "@{exec_path}", Access: []string{"m", "r"}}, + &File{Path: "@{PROC}/@{pids}/attr/apparmor/current", Access: []string{"r"}}, + &File{Path: "@{PROC}/", Access: []string{"r"}}, + &File{Path: "@{sys}/module/apparmor/parameters/enabled", Access: []string{"r"}}, + &File{Path: "@{sys}/kernel/security/apparmor/profiles", Access: []string{"r"}}, + &File{Path: "@{PROC}/@{pids}/attr/current", Access: []string{"r"}}, + &Include{IsMagic: true, Path: "abstractions/consoles"}, + &File{Owner: true, Path: "@{PROC}/@{pid}/mounts", Access: []string{"r"}}, + &Include{IsMagic: true, Path: "abstractions/base"}, + &File{Path: "/dev/tty@{int}", Access: []string{"r", "w"}}, + &Capability{Names: []string{"sys_ptrace"}}, + &Ptrace{Access: []string{"read"}}, + }, + }}, + }, + want: util.MustReadFile(intData.Join("profiles-a-f/aa-status")), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.f.Sort() + tt.f.MergeRules() + tt.f.Format() + err := tt.f.Validate() + if err != nil { + t.Errorf("AppArmorProfile.Validate() = %v", err) + } + if got := tt.f.String(); got != tt.want { + t.Errorf("AppArmorProfile = |%v|, want |%v|", got, tt.want) + } + }) + } +} diff --git a/pkg/aa/base.go b/pkg/aa/base.go new file mode 100644 index 00000000..8fdae72c --- /dev/null +++ b/pkg/aa/base.go @@ -0,0 +1,121 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2024 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package aa + +import ( + "strings" +) + +type RuleBase struct { + IsLineRule bool + Comment string + NoNewPrivs bool + FileInherit bool + Prefix string + Padding string + Optional bool +} + +func newRule(rule []string) RuleBase { + comment := "" + fileInherit, noNewPrivs, optional := false, false, false + + idx := 0 + for idx < len(rule) { + if rule[idx] == COMMENT.Tok() { + comment = " " + strings.Join(rule[idx+1:], " ") + break + } + idx++ + } + switch { + case strings.Contains(comment, "file_inherit"): + fileInherit = true + comment = strings.Replace(comment, "file_inherit ", "", 1) + case strings.HasPrefix(comment, "no new privs"): + noNewPrivs = true + comment = strings.Replace(comment, "no new privs ", "", 1) + case strings.Contains(comment, "optional:"): + optional = true + comment = strings.Replace(comment, "optional: ", "", 1) + } + return RuleBase{ + Comment: comment, + NoNewPrivs: noNewPrivs, + FileInherit: fileInherit, + Optional: optional, + } +} + +func newRuleFromLog(log map[string]string) RuleBase { + comment := "" + fileInherit, noNewPrivs, optional := false, false, false + + if log["operation"] == "file_inherit" { + fileInherit = true + } + if log["error"] == "-1" { + if strings.Contains(log["info"], "optional:") { + optional = true + comment = strings.Replace(log["info"], "optional: ", "", 1) + } else { + noNewPrivs = true + } + } + if log["info"] != "" { + comment += " " + log["info"] + } + return RuleBase{ + IsLineRule: false, + Comment: comment, + NoNewPrivs: noNewPrivs, + FileInherit: fileInherit, + Optional: optional, + } +} + +func (r RuleBase) Less(other any) bool { + return false +} + +func (r RuleBase) Equals(other any) bool { + return false +} + +func (r RuleBase) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r RuleBase) Constraint() constraint { + return anyKind +} + +func (r RuleBase) Kind() Kind { + return COMMENT +} + +type Qualifier struct { + Audit bool + AccessType string +} + +func newQualifierFromLog(log map[string]string) Qualifier { + audit := false + if log["apparmor"] == "AUDIT" { + audit = true + } + return Qualifier{Audit: audit} +} + +func (r Qualifier) Less(other Qualifier) bool { + if r.Audit != other.Audit { + return r.Audit + } + return r.AccessType < other.AccessType +} + +func (r Qualifier) Equals(other Qualifier) bool { + return r.Audit == other.Audit && r.AccessType == other.AccessType +} diff --git a/pkg/aa/blocks.go b/pkg/aa/blocks.go new file mode 100644 index 00000000..6d1079ac --- /dev/null +++ b/pkg/aa/blocks.go @@ -0,0 +1,42 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2024 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package aa + +const ( + HAT Kind = "hat" +) + +// Hat represents a single AppArmor hat. +type Hat struct { + RuleBase + Name string + Rules Rules +} + +func (r *Hat) Validate() error { + return nil +} + +func (p *Hat) Less(other any) bool { + o, _ := other.(*Hat) + return p.Name < o.Name +} + +func (p *Hat) Equals(other any) bool { + o, _ := other.(*Hat) + return p.Name == o.Name +} + +func (p *Hat) String() string { + return renderTemplate(p.Kind(), p) +} + +func (p *Hat) Constraint() constraint { + return blockKind +} + +func (p *Hat) Kind() Kind { + return HAT +} diff --git a/pkg/aa/capability.go b/pkg/aa/capability.go index b65e8bd2..44508e0e 100644 --- a/pkg/aa/capability.go +++ b/pkg/aa/capability.go @@ -4,27 +4,72 @@ package aa -type Capability struct { - Qualifier - Name string +import ( + "fmt" + "slices" +) + +const CAPABILITY Kind = "capability" + +func init() { + requirements[CAPABILITY] = requirement{ + "name": { + "audit_control", "audit_read", "audit_write", "block_suspend", "bpf", + "checkpoint_restore", "chown", "dac_override", "dac_read_search", + "fowner", "fsetid", "ipc_lock", "ipc_owner", "kill", "lease", + "linux_immutable", "mac_admin", "mac_override", "mknod", "net_admin", + "net_bind_service", "net_broadcast", "net_raw", "perfmon", "setfcap", + "setgid", "setpcap", "setuid", "sys_admin", "sys_boot", "sys_chroot", + "sys_module", "sys_nice", "sys_pacct", "sys_ptrace", "sys_rawio", + "sys_resource", "sys_time", "sys_tty_config", "syslog", "wake_alarm", + }, + } } -func CapabilityFromLog(log map[string]string) ApparmorRule { +type Capability struct { + RuleBase + Qualifier + Names []string +} + +func newCapabilityFromLog(log map[string]string) Rule { return &Capability{ - Qualifier: NewQualifierFromLog(log), - Name: log["capname"], + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), + Names: Must(toValues(CAPABILITY, "name", log["capname"])), } } +func (r *Capability) Validate() error { + if err := validateValues(r.Kind(), "name", r.Names); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + return nil +} + func (r *Capability) Less(other any) bool { o, _ := other.(*Capability) - if r.Name == o.Name { - return r.Qualifier.Less(o.Qualifier) + for i := 0; i < len(r.Names) && i < len(o.Names); i++ { + if r.Names[i] != o.Names[i] { + return r.Names[i] < o.Names[i] + } } - return r.Name < o.Name + return r.Qualifier.Less(o.Qualifier) } func (r *Capability) Equals(other any) bool { o, _ := other.(*Capability) - return r.Name == o.Name && r.Qualifier.Equals(o.Qualifier) + return slices.Equal(r.Names, o.Names) && r.Qualifier.Equals(o.Qualifier) +} + +func (r *Capability) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Capability) Constraint() constraint { + return blockKind +} + +func (r *Capability) Kind() Kind { + return CAPABILITY } diff --git a/pkg/aa/change_profile.go b/pkg/aa/change_profile.go index 610376eb..d5cc618c 100644 --- a/pkg/aa/change_profile.go +++ b/pkg/aa/change_profile.go @@ -4,34 +4,69 @@ package aa +import "fmt" + +const CHANGEPROFILE Kind = "change_profile" + +func init() { + requirements[CHANGEPROFILE] = requirement{ + "mode": []string{"safe", "unsafe"}, + } +} + type ChangeProfile struct { + RuleBase Qualifier ExecMode string Exec string ProfileName string } -func ChangeProfileFromLog(log map[string]string) ApparmorRule { +func newChangeProfileFromLog(log map[string]string) Rule { return &ChangeProfile{ - Qualifier: NewQualifierFromLog(log), + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), ExecMode: log["mode"], Exec: log["exec"], ProfileName: log["target"], } } +func (r *ChangeProfile) Validate() error { + if err := validateValues(r.Kind(), "mode", []string{r.ExecMode}); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + return nil +} + func (r *ChangeProfile) Less(other any) bool { o, _ := other.(*ChangeProfile) - if r.ExecMode == o.ExecMode { - if r.Exec == o.Exec { - return r.ProfileName < o.ProfileName - } + if r.ExecMode != o.ExecMode { + return r.ExecMode < o.ExecMode + } + if r.Exec != o.Exec { return r.Exec < o.Exec } - return r.ExecMode < o.ExecMode + if r.ProfileName != o.ProfileName { + return r.ProfileName < o.ProfileName + } + return r.Qualifier.Less(o.Qualifier) } func (r *ChangeProfile) Equals(other any) bool { o, _ := other.(*ChangeProfile) - return r.ExecMode == o.ExecMode && r.Exec == o.Exec && r.ProfileName == o.ProfileName + return r.ExecMode == o.ExecMode && r.Exec == o.Exec && + r.ProfileName == o.ProfileName && r.Qualifier.Equals(o.Qualifier) +} + +func (r *ChangeProfile) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *ChangeProfile) Constraint() constraint { + return blockKind +} + +func (r *ChangeProfile) Kind() Kind { + return CHANGEPROFILE } diff --git a/pkg/aa/convert.go b/pkg/aa/convert.go new file mode 100644 index 00000000..b78dc00b --- /dev/null +++ b/pkg/aa/convert.go @@ -0,0 +1,120 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2024 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package aa + +import ( + "fmt" + "slices" + "strings" +) + +// Must is a helper that wraps a call to a function returning (any, error) and +// panics if the error is non-nil. +func Must[T any](v T, err error) T { + if err != nil { + panic(err) + } + return v +} + +// cmpFileAccess compares two access strings for file rules. +// It is aimed to be used in slices.SortFunc. +func cmpFileAccess(i, j string) int { + if slices.Contains(requirements[FILE]["access"], i) && + slices.Contains(requirements[FILE]["access"], j) { + return requirementsWeights[FILE]["access"][i] - requirementsWeights[FILE]["access"][j] + } + if slices.Contains(requirements[FILE]["transition"], i) && + slices.Contains(requirements[FILE]["transition"], j) { + return requirementsWeights[FILE]["transition"][i] - requirementsWeights[FILE]["transition"][j] + } + if slices.Contains(requirements[FILE]["access"], i) { + return -1 + } + return 1 +} + +func validateValues(kind Kind, key string, values []string) error { + for _, v := range values { + if v == "" { + continue + } + if !slices.Contains(requirements[kind][key], v) { + return fmt.Errorf("invalid mode '%s'", v) + } + } + return nil +} + +// Helper function to convert a string to a slice of rule values according to +// the rule requirements as defined in the requirements map. +func toValues(kind Kind, key string, input string) ([]string, error) { + req, ok := requirements[kind][key] + if !ok { + return nil, fmt.Errorf("unrecognized requirement '%s' for rule %s", key, kind) + } + + res := tokenToSlice(input) + for idx := range res { + res[idx] = strings.Trim(res[idx], `" `) + if res[idx] == "" { + res = slices.Delete(res, idx, idx+1) + continue + } + if !slices.Contains(req, res[idx]) { + return nil, fmt.Errorf("unrecognized %s: %s", key, res[idx]) + } + } + slices.SortFunc(res, func(i, j string) int { + return requirementsWeights[kind][key][i] - requirementsWeights[kind][key][j] + }) + return slices.Compact(res), nil +} + +// Helper function to convert an access string to a slice of access according to +// the rule requirements as defined in the requirements map. +func toAccess(kind Kind, input string) ([]string, error) { + var res []string + + switch kind { + case FILE: + raw := strings.Split(input, "") + trans := []string{} + for _, access := range raw { + if slices.Contains(requirements[FILE]["access"], access) { + res = append(res, access) + } else { + trans = append(trans, access) + } + } + + transition := strings.Join(trans, "") + if len(transition) > 0 { + if slices.Contains(requirements[FILE]["transition"], transition) { + res = append(res, transition) + } else { + return nil, fmt.Errorf("unrecognized transition: %s", transition) + } + } + + case FILE + "-log": + raw := strings.Split(input, "") + for _, access := range raw { + if slices.Contains(requirements[FILE]["access"], access) { + res = append(res, access) + } else if maskToAccess[access] != "" { + res = append(res, maskToAccess[access]) + } else { + return nil, fmt.Errorf("toAccess: unrecognized file access '%s' for %s", input, kind) + } + } + + default: + return toValues(kind, "access", input) + } + + slices.SortFunc(res, cmpFileAccess) + return slices.Compact(res), nil +} diff --git a/pkg/aa/convert_test.go b/pkg/aa/convert_test.go new file mode 100644 index 00000000..fc2436be --- /dev/null +++ b/pkg/aa/convert_test.go @@ -0,0 +1,90 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2024 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package aa + +import ( + "reflect" + "testing" +) + +func Test_toAccess(t *testing.T) { + tests := []struct { + name string + kind Kind + inputs []string + wants [][]string + wantsErr []bool + }{ + { + name: "empty", + kind: FILE, + inputs: []string{""}, + wants: [][]string{nil}, + wantsErr: []bool{false}, + }, + { + name: "file", + kind: FILE, + inputs: []string{ + "rPx", "rPUx", "mr", "rm", "rix", "rcx", "rCUx", "rmix", "rwlk", + "mrwkl", "", "r", "x", "w", "wr", "px", "Px", "Ux", "mrwlkPix", + }, + wants: [][]string{ + {"r", "Px"}, {"r", "PUx"}, {"m", "r"}, {"m", "r"}, {"r", "ix"}, + {"r", "cx"}, {"r", "CUx"}, {"m", "r", "ix"}, {"r", "w", "l", "k"}, + {"m", "r", "w", "l", "k"}, nil, {"r"}, {"x"}, {"w"}, {"r", "w"}, + {"px"}, {"Px"}, {"Ux"}, {"m", "r", "w", "l", "k", "Pix"}, + }, + wantsErr: []bool{ + false, false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, false, + }, + }, + { + name: "file-log", + kind: FILE + "-log", + inputs: []string{ + "mr", "rm", "x", "rwlk", "mrwkl", "r", "c", "wc", "d", "wr", + }, + wants: [][]string{ + {"m", "r"}, {"m", "r"}, {"ix"}, {"r", "w", "l", "k"}, + {"m", "r", "w", "l", "k"}, {"r"}, {"w"}, {"w"}, {"w"}, {"r", "w"}, + }, + wantsErr: []bool{ + false, false, false, false, false, false, false, false, false, false, + }, + }, + { + name: "signal", + kind: SIGNAL, + inputs: []string{"send receive rw"}, + wants: [][]string{{"rw", "send", "receive"}}, + wantsErr: []bool{false}, + }, + { + name: "ptrace", + kind: PTRACE, + inputs: []string{"readby", "tracedby", "read readby", "r w", "rw", ""}, + wants: [][]string{ + {"readby"}, {"tracedby"}, {"read", "readby"}, {"r", "w"}, {"rw"}, {}, + }, + wantsErr: []bool{false, false, false, false, false, false}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for i, input := range tt.inputs { + got, err := toAccess(tt.kind, input) + if (err != nil) != tt.wantsErr[i] { + t.Errorf("toAccess() error = %v, wantErr %v", err, tt.wantsErr[i]) + return + } + if !reflect.DeepEqual(got, tt.wants[i]) { + t.Errorf("toAccess() = %v, want %v", got, tt.wants[i]) + } + } + }) + } +} diff --git a/pkg/aa/data_test.go b/pkg/aa/data_test.go index b3d01a70..b4e24786 100644 --- a/pkg/aa/data_test.go +++ b/pkg/aa/data_test.go @@ -5,17 +5,40 @@ package aa var ( + // Comment + comment1 = &Comment{RuleBase: RuleBase{Comment: "comment", IsLineRule: true}} + comment2 = &Comment{RuleBase: RuleBase{Comment: "another comment", IsLineRule: true}} + + // Abi + abi1 = &Abi{IsMagic: true, Path: "abi/4.0"} + abi2 = &Abi{IsMagic: true, Path: "abi/3.0"} + + // Alias + alias1 = &Alias{Path: "/mnt/usr", RewrittenPath: "/usr"} + alias2 = &Alias{Path: "/mnt/var", RewrittenPath: "/var"} + // Include include1 = &Include{IsMagic: true, Path: "abstraction/base"} include2 = &Include{IsMagic: false, Path: "abstraction/base"} - include3 = &Include{IfExists: true, IsMagic: true, Path: "abstraction/base"} includeLocal1 = &Include{IfExists: true, IsMagic: true, Path: "local/foo"} + // Variable + variable1 = &Variable{Name: "bin", Values: []string{"/{,usr/}{,s}bin"}, Define: true} + variable2 = &Variable{Name: "exec_path", Values: []string{"@{bin}/foo", "@{lib}/foo"}, Define: true} + + // All + all1 = &All{} + all2 = &All{RuleBase: RuleBase{Comment: "comment"}} + // Rlimit rlimit1 = &Rlimit{Key: "nproc", Op: "<=", Value: "200"} rlimit2 = &Rlimit{Key: "cpu", Op: "<=", Value: "2"} rlimit3 = &Rlimit{Key: "nproc", Op: "<", Value: "2"} + // Userns + userns1 = &Userns{Create: true} + userns2 = &Userns{} + // Capability capability1Log = map[string]string{ "apparmor": "ALLOWED", @@ -26,8 +49,8 @@ var ( "profile": "pkexec", "comm": "pkexec", } - capability1 = &Capability{Name: "net_admin"} - capability2 = &Capability{Name: "sys_ptrace"} + capability1 = &Capability{Names: []string{"net_admin"}} + capability2 = &Capability{Names: []string{"sys_ptrace"}} // Network network1Log = map[string]string{ @@ -71,20 +94,24 @@ var ( "flags": "rw, rbind", } mount1 = &Mount{ - Qualifier: Qualifier{Comment: "failed perms check"}, + RuleBase: RuleBase{Comment: " failed perms check"}, MountConditions: MountConditions{FsType: "overlay"}, Source: "overlay", MountPoint: "/var/lib/docker/overlay2/opaque-bug-check1209538631/merged/", } mount2 = &Mount{ - Qualifier: Qualifier{Comment: "failed perms check"}, + RuleBase: RuleBase{Comment: " failed perms check"}, MountConditions: MountConditions{Options: []string{"rw", "rbind"}}, Source: "/oldroot/dev/tty", MountPoint: "/newroot/dev/tty", } + // Remount + remount1 = &Remount{MountPoint: "/"} + remount2 = &Remount{MountPoint: "/{,**}/"} + // Umount - umount1Log = map[string]string{ + umount1Log = map[string]string{ "apparmor": "ALLOWED", "class": "mount", "operation": "umount", @@ -96,7 +123,6 @@ var ( umount2 = &Umount{MountPoint: "/oldroot/"} // PivotRoot - // pivotroot1LogStr = `apparmor="ALLOWED" operation="pivotroot" class="mount" profile="systemd" name="@{run}/systemd/mount-rootfs/" comm="(ostnamed)" srcname="@{run}/systemd/mount-rootfs/"` pivotroot1Log = map[string]string{ "apparmor": "ALLOWED", "class": "mount", @@ -120,7 +146,6 @@ var ( } // Change Profile - // changeprofile1LogStr = `apparmor="ALLOWED" operation="change_onexec" class="file" profile="systemd" name="systemd-user" comm="(systemd)" target="systemd-user"` changeprofile1Log = map[string]string{ "apparmor": "ALLOWED", "class": "file", @@ -134,6 +159,14 @@ var ( changeprofile2 = &ChangeProfile{ProfileName: "brwap"} changeprofile3 = &ChangeProfile{ExecMode: "safe", Exec: "/bin/bash", ProfileName: "brwap//default"} + // Mqueue + mqueue1 = &Mqueue{Access: []string{"r"}, Type: "posix", Name: "/"} + mqueue2 = &Mqueue{Access: []string{"r"}, Type: "sysv", Name: "/"} + + // IO Uring + iouring1 = &IOUring{Access: []string{"sqpoll"}, Label: "foo"} + iouring2 = &IOUring{Access: []string{"override_creds"}} + // Signal signal1Log = map[string]string{ "apparmor": "ALLOWED", @@ -147,13 +180,13 @@ var ( "peer": "firefox//&firejail-default", } signal1 = &Signal{ - Access: "receive", - Set: "kill", + Access: []string{"receive"}, + Set: []string{"kill"}, Peer: "firefox//&firejail-default", } signal2 = &Signal{ - Access: "receive", - Set: "up", + Access: []string{"receive"}, + Set: []string{"up"}, Peer: "firefox//&firejail-default", } @@ -177,8 +210,8 @@ var ( "denied_mask": "readby", "peer": "systemd-journald", } - ptrace1 = &Ptrace{Access: "read", Peer: "nautilus"} - ptrace2 = &Ptrace{Access: "readby", Peer: "systemd-journald"} + ptrace1 = &Ptrace{Access: []string{"read"}, Peer: "nautilus"} + ptrace2 = &Ptrace{Access: []string{"readby"}, Peer: "systemd-journald"} // Unix unix1Log = map[string]string{ @@ -197,17 +230,17 @@ var ( "protocol": "0", } unix1 = &Unix{ - Access: "send receive", - Type: "stream", - Protocol: "0", - Address: "none", - Peer: "dbus-daemon", - PeerAddr: "@/tmp/dbus-AaKMpxzC4k", + Access: []string{"send", "receive"}, + Type: "stream", + Protocol: "0", + Address: "none", + PeerAddr: "@/tmp/dbus-AaKMpxzC4k", + PeerLabel: "dbus-daemon", } unix2 = &Unix{ - Qualifier: Qualifier{FileInherit: true}, - Access: "receive", - Type: "stream", + RuleBase: RuleBase{FileInherit: true}, + Access: []string{"receive"}, + Type: "stream", } // Dbus @@ -234,21 +267,21 @@ var ( "label": "evolution-source-registry", } dbus1 = &Dbus{ - Access: "receive", + Access: []string{"receive"}, Bus: "session", - Name: ":1.15", Path: "/org/gtk/vfs/metadata", Interface: "org.gtk.vfs.Metadata", Member: "Remove", - Label: "tracker-extract", + PeerName: ":1.15", + PeerLabel: "tracker-extract", } dbus2 = &Dbus{ - Access: "bind", + Access: []string{"bind"}, Bus: "session", Name: "org.gnome.evolution.dataserver.Sources5", } dbus3 = &Dbus{ - Access: "bind", + Access: []string{"bind"}, Bus: "session", Name: "org.gnome.evolution.dataserver", } @@ -283,10 +316,77 @@ var ( "OUID": "user", "error": "-1", } - file1 = &File{Path: "/usr/share/poppler/cMap/Identity-H", Access: "r"} + file1 = &File{Path: "/usr/share/poppler/cMap/Identity-H", Access: []string{"r"}} file2 = &File{ - Qualifier: Qualifier{Owner: true, NoNewPrivs: true}, - Path: "@{PROC}/4163/cgroup", - Access: "r", + RuleBase: RuleBase{NoNewPrivs: true}, + Owner: true, + Path: "@{PROC}/4163/cgroup", + Access: []string{"r"}, } + + // Link + link1Log = map[string]string{ + "apparmor": "ALLOWED", + "operation": "link", + "class": "file", + "profile": "mkinitcpio", + "name": "/tmp/mkinitcpio.QDWtza/early@{lib}/firmware/i915/dg1_dmc_ver2_02.bin.zst", + "comm": "cp", + "requested_mask": "l", + "denied_mask": "l", + "fsuid": "0", + "ouid": "0", + "target": "/tmp/mkinitcpio.QDWtza/root@{lib}/firmware/i915/dg1_dmc_ver2_02.bin.zst", + "FSUID": "root", + "OUID": "root", + } + link3Log = map[string]string{ + "apparmor": "ALLOWED", + "operation": "link", + "class": "file", + "profile": "dolphin", + "name": "@{user_config_dirs}/kiorc", + "comm": "dolphin", + "requested_mask": "l", + "denied_mask": "l", + "fsuid": "1000", + "ouid": "1000", + "target": "@{user_config_dirs}/#3954", + } + link1 = &Link{ + Path: "/tmp/mkinitcpio.QDWtza/early@{lib}/firmware/i915/dg1_dmc_ver2_02.bin.zst", + Target: "/tmp/mkinitcpio.QDWtza/root@{lib}/firmware/i915/dg1_dmc_ver2_02.bin.zst", + } + link2 = &Link{ + Owner: true, + Path: "@{user_config_dirs}/powerdevilrc{,.@{rand6}}", + Target: "@{user_config_dirs}/#@{int}", + } + link3 = &Link{ + Owner: true, + Path: "@{user_config_dirs}/kiorc", + Target: "@{user_config_dirs}/#3954", + } + + // Profile + profile1 = &Profile{ + Header: Header{ + Name: "sudo", + Attachments: []string{}, + Attributes: map[string]string{}, + Flags: []string{}, + }, + } + profile2 = &Profile{ + Header: Header{ + Name: "systemctl", + Attachments: []string{}, + Attributes: map[string]string{}, + Flags: []string{}, + }, + } + + // Hat + hat1 = &Hat{Name: "user"} + hat2 = &Hat{Name: "root"} ) diff --git a/pkg/aa/dbus.go b/pkg/aa/dbus.go index b2e32ba2..56edd797 100644 --- a/pkg/aa/dbus.go +++ b/pkg/aa/dbus.go @@ -4,59 +4,112 @@ package aa +import ( + "fmt" + "slices" +) + +const DBUS Kind = "dbus" + +func init() { + requirements[DBUS] = requirement{ + "access": []string{ + "send", "receive", "bind", "eavesdrop", "r", "read", + "w", "write", "rw", + }, + "bus": []string{"system", "session", "accessibility"}, + } +} + type Dbus struct { + RuleBase Qualifier - Access string + Access []string Bus string Name string Path string Interface string Member string - Label string + PeerName string + PeerLabel string } -func DbusFromLog(log map[string]string) ApparmorRule { +func newDbusFromLog(log map[string]string) Rule { + name := "" + peerName := "" + if log["mask"] == "bind" { + name = log["name"] + } else { + peerName = log["name"] + } return &Dbus{ - Qualifier: NewQualifierFromLog(log), - Access: log["mask"], + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), + Access: []string{log["mask"]}, Bus: log["bus"], - Name: log["name"], + Name: name, Path: log["path"], Interface: log["interface"], Member: log["member"], - Label: log["peer_label"], + PeerName: peerName, + PeerLabel: log["peer_label"], } } +func (r *Dbus) Validate() error { + if err := validateValues(r.Kind(), "access", r.Access); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + return validateValues(r.Kind(), "bus", []string{r.Bus}) +} + func (r *Dbus) Less(other any) bool { o, _ := other.(*Dbus) - if r.Qualifier.Equals(o.Qualifier) { - if r.Access == o.Access { - if r.Bus == o.Bus { - if r.Name == o.Name { - if r.Path == o.Path { - if r.Interface == o.Interface { - if r.Member == o.Member { - return r.Label < o.Label - } - return r.Member < o.Member - } - return r.Interface < o.Interface - } - return r.Path < o.Path - } - return r.Name < o.Name - } - return r.Bus < o.Bus + for i := 0; i < len(r.Access) && i < len(o.Access); i++ { + if r.Access[i] != o.Access[i] { + return r.Access[i] < o.Access[i] } - return r.Access < o.Access + } + if r.Bus != o.Bus { + return r.Bus < o.Bus + } + if r.Name != o.Name { + return r.Name < o.Name + } + if r.Path != o.Path { + return r.Path < o.Path + } + if r.Interface != o.Interface { + return r.Interface < o.Interface + } + if r.Member != o.Member { + return r.Member < o.Member + } + if r.PeerName != o.PeerName { + return r.PeerName < o.PeerName + } + if r.PeerLabel != o.PeerLabel { + return r.PeerLabel < o.PeerLabel } return r.Qualifier.Less(o.Qualifier) } func (r *Dbus) Equals(other any) bool { o, _ := other.(*Dbus) - return r.Access == o.Access && r.Bus == o.Bus && r.Name == o.Name && + return slices.Equal(r.Access, o.Access) && r.Bus == o.Bus && r.Name == o.Name && r.Path == o.Path && r.Interface == o.Interface && - r.Member == o.Member && r.Label == o.Label && r.Qualifier.Equals(o.Qualifier) + r.Member == o.Member && r.PeerName == o.PeerName && + r.PeerLabel == o.PeerLabel && r.Qualifier.Equals(o.Qualifier) +} + +func (r *Dbus) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Dbus) Constraint() constraint { + return blockKind +} + +func (r *Dbus) Kind() Kind { + return DBUS } diff --git a/pkg/aa/file.go b/pkg/aa/file.go index c83322e8..d1ea214a 100644 --- a/pkg/aa/file.go +++ b/pkg/aa/file.go @@ -4,43 +4,164 @@ package aa +import ( + "fmt" + "slices" + "strings" +) + +const ( + LINK Kind = "link" + FILE Kind = "file" + tokOWNER = "owner" + tokSUBSET = "subset" +) + +func init() { + requirements[FILE] = requirement{ + "access": {"m", "r", "w", "l", "k"}, + "transition": { + "ix", "ux", "Ux", "px", "Px", "cx", "Cx", "pix", "Pix", "cix", + "Cix", "pux", "PUx", "cux", "CUx", "x", + }, + } +} + +func isOwner(log map[string]string) bool { + fsuid, hasFsUID := log["fsuid"] + ouid, hasOuUID := log["ouid"] + isDbus := strings.Contains(log["operation"], "dbus") + if hasFsUID && hasOuUID && fsuid == ouid && ouid != "0" && !isDbus { + return true + } + return false +} + type File struct { + RuleBase Qualifier + Owner bool Path string - Access string + Access []string Target string } -func FileFromLog(log map[string]string) ApparmorRule { +func newFileFromLog(log map[string]string) Rule { + accesses, err := toAccess("file-log", log["requested_mask"]) + if err != nil { + panic(fmt.Errorf("newFileFromLog(%v): %w", log, err)) + } + if slices.Compare(accesses, []string{"l"}) == 0 { + return newLinkFromLog(log) + } return &File{ - Qualifier: NewQualifierFromLog(log), + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), + Owner: isOwner(log), Path: log["name"], - Access: toAccess(log["requested_mask"]), + Access: accesses, Target: log["target"], } } +func (r *File) Validate() error { + return nil +} + func (r *File) Less(other any) bool { o, _ := other.(*File) letterR := getLetterIn(fileAlphabet, r.Path) letterO := getLetterIn(fileAlphabet, o.Path) - if fileWeights[letterR] == fileWeights[letterO] || letterR == "" || letterO == "" { - if r.Qualifier.Equals(o.Qualifier) { - if r.Path == o.Path { - if r.Access == o.Access { - return r.Target < o.Target - } - return r.Access < o.Access - } - return r.Path < o.Path - } - return r.Qualifier.Less(o.Qualifier) + if fileWeights[letterR] != fileWeights[letterO] && letterR != "" && letterO != "" { + return fileWeights[letterR] < fileWeights[letterO] } - return fileWeights[letterR] < fileWeights[letterO] + if r.Path != o.Path { + return r.Path < o.Path + } + if o.Owner != r.Owner { + return r.Owner + } + if len(r.Access) != len(o.Access) { + return len(r.Access) < len(o.Access) + } + if r.Target != o.Target { + return r.Target < o.Target + } + return r.Qualifier.Less(o.Qualifier) } func (r *File) Equals(other any) bool { o, _ := other.(*File) - return r.Path == o.Path && r.Access == o.Access && + return r.Path == o.Path && slices.Equal(r.Access, o.Access) && r.Owner == o.Owner && r.Target == o.Target && r.Qualifier.Equals(o.Qualifier) } + +func (r *File) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *File) Constraint() constraint { + return blockKind +} + +func (r *File) Kind() Kind { + return FILE +} + +type Link struct { + RuleBase + Qualifier + Owner bool + Subset bool + Path string + Target string +} + +func newLinkFromLog(log map[string]string) Rule { + return &Link{ + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), + Owner: isOwner(log), + Path: log["name"], + Target: log["target"], + } +} + +func (r *Link) Validate() error { + return nil +} + +func (r *Link) Less(other any) bool { + o, _ := other.(*Link) + if r.Path != o.Path { + return r.Path < o.Path + } + if o.Owner != r.Owner { + return r.Owner + } + if r.Target != o.Target { + return r.Target < o.Target + } + if r.Subset != o.Subset { + return r.Subset + } + return r.Qualifier.Less(o.Qualifier) +} + +func (r *Link) Equals(other any) bool { + o, _ := other.(*Link) + return r.Subset == o.Subset && r.Owner == o.Owner && r.Path == o.Path && + r.Target == o.Target && r.Qualifier.Equals(o.Qualifier) +} + +func (r *Link) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Link) Constraint() constraint { + return blockKind +} + +func (r *Link) Kind() Kind { + return LINK +} diff --git a/pkg/aa/include.go b/pkg/aa/include.go deleted file mode 100644 index b3aec7a9..00000000 --- a/pkg/aa/include.go +++ /dev/null @@ -1,28 +0,0 @@ -// apparmor.d - Full set of apparmor profiles -// Copyright (C) 2021-2024 Alexandre Pujol -// SPDX-License-Identifier: GPL-2.0-only - -package aa - -type Include struct { - IfExists bool - Path string - IsMagic bool -} - -func (r *Include) Less(other any) bool { - o, _ := other.(*Include) - if r.Path == o.Path { - if r.IsMagic == o.IsMagic { - return r.IfExists - } - return r.IsMagic - } - return r.Path < o.Path -} - -func (r *Include) Equals(other any) bool { - o, _ := other.(*Include) - return r.Path == o.Path && r.IsMagic == o.IsMagic && - r.IfExists == o.IfExists -} diff --git a/pkg/aa/io_uring.go b/pkg/aa/io_uring.go index 62507bf7..42297a1f 100644 --- a/pkg/aa/io_uring.go +++ b/pkg/aa/io_uring.go @@ -4,24 +4,66 @@ package aa +import ( + "fmt" + "slices" +) + +const IOURING Kind = "io_uring" + +func init() { + requirements[IOURING] = requirement{ + "access": []string{"sqpoll", "override_creds"}, + } +} + type IOUring struct { + RuleBase Qualifier - Access string + Access []string Label string } +func newIOUringFromLog(log map[string]string) Rule { + return &IOUring{ + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), + Access: Must(toAccess(IOURING, log["requested"])), + Label: log["label"], + } +} + +func (r *IOUring) Validate() error { + if err := validateValues(r.Kind(), "access", r.Access); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + return nil +} + func (r *IOUring) Less(other any) bool { o, _ := other.(*IOUring) - if r.Qualifier.Equals(o.Qualifier) { - if r.Access == o.Access { - return r.Label < o.Label - } - return r.Access < o.Access + if len(r.Access) != len(o.Access) { + return len(r.Access) < len(o.Access) + } + if r.Label != o.Label { + return r.Label < o.Label } return r.Qualifier.Less(o.Qualifier) } func (r *IOUring) Equals(other any) bool { o, _ := other.(*IOUring) - return r.Access == o.Access && r.Label == o.Label && r.Qualifier.Equals(o.Qualifier) + return slices.Equal(r.Access, o.Access) && r.Label == o.Label && r.Qualifier.Equals(o.Qualifier) +} + +func (r *IOUring) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *IOUring) Constraint() constraint { + return blockKind +} + +func (r *IOUring) Kind() Kind { + return IOURING } diff --git a/pkg/aa/mount.go b/pkg/aa/mount.go index ee375860..e131e54c 100644 --- a/pkg/aa/mount.go +++ b/pkg/aa/mount.go @@ -5,30 +5,54 @@ package aa import ( + "fmt" "slices" - "strings" ) +const ( + MOUNT Kind = "mount" + REMOUNT Kind = "remount" + UMOUNT Kind = "umount" +) + +func init() { + requirements[MOUNT] = requirement{ + "flags": { + "acl", "async", "atime", "ro", "rw", "bind", "rbind", "dev", + "diratime", "dirsync", "exec", "iversion", "loud", "mand", "move", + "noacl", "noatime", "nodev", "nodiratime", "noexec", "noiversion", + "nomand", "norelatime", "nosuid", "nouser", "private", "relatime", + "remount", "rprivate", "rshared", "rslave", "runbindable", "shared", + "silent", "slave", "strictatime", "suid", "sync", "unbindable", + "user", "verbose", + }, + } +} + type MountConditions struct { FsType string Options []string } -func MountConditionsFromLog(log map[string]string) MountConditions { +func newMountConditionsFromLog(log map[string]string) MountConditions { if _, present := log["flags"]; present { return MountConditions{ FsType: log["fstype"], - Options: strings.Split(log["flags"], ", "), + Options: Must(toValues(MOUNT, "flags", log["flags"])), } } return MountConditions{FsType: log["fstype"]} } +func (m MountConditions) Validate() error { + return validateValues(MOUNT, "flags", m.Options) +} + func (m MountConditions) Less(other MountConditions) bool { - if m.FsType == other.FsType { - return len(m.Options) < len(other.Options) + if m.FsType != other.FsType { + return m.FsType < other.FsType } - return m.FsType < other.FsType + return len(m.Options) < len(other.Options) } func (m MountConditions) Equals(other MountConditions) bool { @@ -36,32 +60,41 @@ func (m MountConditions) Equals(other MountConditions) bool { } type Mount struct { + RuleBase Qualifier MountConditions Source string MountPoint string } -func MountFromLog(log map[string]string) ApparmorRule { +func newMountFromLog(log map[string]string) Rule { return &Mount{ - Qualifier: NewQualifierFromLog(log), - MountConditions: MountConditionsFromLog(log), + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), + MountConditions: newMountConditionsFromLog(log), Source: log["srcname"], MountPoint: log["name"], } } +func (r *Mount) Validate() error { + if err := r.MountConditions.Validate(); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + return nil +} + func (r *Mount) Less(other any) bool { o, _ := other.(*Mount) - if r.Qualifier.Equals(o.Qualifier) { - if r.Source == o.Source { - if r.MountPoint == o.MountPoint { - return r.MountConditions.Less(o.MountConditions) - } - return r.MountPoint < o.MountPoint - } + if r.Source != o.Source { return r.Source < o.Source } + if r.MountPoint != o.MountPoint { + return r.MountPoint < o.MountPoint + } + if r.MountConditions.Equals(o.MountConditions) { + return r.MountConditions.Less(o.MountConditions) + } return r.Qualifier.Less(o.Qualifier) } @@ -72,28 +105,49 @@ func (r *Mount) Equals(other any) bool { r.Qualifier.Equals(o.Qualifier) } +func (r *Mount) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Mount) Constraint() constraint { + return blockKind +} + +func (r *Mount) Kind() Kind { + return MOUNT +} + type Umount struct { + RuleBase Qualifier MountConditions MountPoint string } -func UmountFromLog(log map[string]string) ApparmorRule { +func newUmountFromLog(log map[string]string) Rule { return &Umount{ - Qualifier: NewQualifierFromLog(log), - MountConditions: MountConditionsFromLog(log), + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), + MountConditions: newMountConditionsFromLog(log), MountPoint: log["name"], } } +func (r *Umount) Validate() error { + if err := r.MountConditions.Validate(); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + return nil +} + func (r *Umount) Less(other any) bool { o, _ := other.(*Umount) - if r.Qualifier.Equals(o.Qualifier) { - if r.MountPoint == o.MountPoint { - return r.MountConditions.Less(o.MountConditions) - } + if r.MountPoint != o.MountPoint { return r.MountPoint < o.MountPoint } + if r.MountConditions.Equals(o.MountConditions) { + return r.MountConditions.Less(o.MountConditions) + } return r.Qualifier.Less(o.Qualifier) } @@ -104,28 +158,49 @@ func (r *Umount) Equals(other any) bool { r.Qualifier.Equals(o.Qualifier) } +func (r *Umount) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Umount) Constraint() constraint { + return blockKind +} + +func (r *Umount) Kind() Kind { + return UMOUNT +} + type Remount struct { + RuleBase Qualifier MountConditions MountPoint string } -func RemountFromLog(log map[string]string) ApparmorRule { +func newRemountFromLog(log map[string]string) Rule { return &Remount{ - Qualifier: NewQualifierFromLog(log), - MountConditions: MountConditionsFromLog(log), + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), + MountConditions: newMountConditionsFromLog(log), MountPoint: log["name"], } } +func (r *Remount) Validate() error { + if err := r.MountConditions.Validate(); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + return nil +} + func (r *Remount) Less(other any) bool { o, _ := other.(*Remount) - if r.Qualifier.Equals(o.Qualifier) { - if r.MountPoint == o.MountPoint { - return r.MountConditions.Less(o.MountConditions) - } + if r.MountPoint != o.MountPoint { return r.MountPoint < o.MountPoint } + if r.MountConditions.Equals(o.MountConditions) { + return r.MountConditions.Less(o.MountConditions) + } return r.Qualifier.Less(o.Qualifier) } @@ -135,3 +210,15 @@ func (r *Remount) Equals(other any) bool { r.MountConditions.Equals(o.MountConditions) && r.Qualifier.Equals(o.Qualifier) } + +func (r *Remount) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Remount) Constraint() constraint { + return blockKind +} + +func (r *Remount) Kind() Kind { + return REMOUNT +} diff --git a/pkg/aa/mqueue.go b/pkg/aa/mqueue.go index 03a52bdf..9fc5f260 100644 --- a/pkg/aa/mqueue.go +++ b/pkg/aa/mqueue.go @@ -4,17 +4,34 @@ package aa -import "strings" +import ( + "fmt" + "slices" + "strings" +) + +const MQUEUE Kind = "mqueue" + +func init() { + requirements[MQUEUE] = requirement{ + "access": []string{ + "r", "w", "rw", "read", "write", "create", "open", + "delete", "getattr", "setattr", + }, + "type": []string{"posix", "sysv"}, + } +} type Mqueue struct { + RuleBase Qualifier - Access string + Access []string Type string Label string Name string } -func MqueueFromLog(log map[string]string) ApparmorRule { +func newMqueueFromLog(log map[string]string) Rule { mqueueType := "posix" if strings.Contains(log["class"], "posix") { mqueueType = "posix" @@ -22,29 +39,53 @@ func MqueueFromLog(log map[string]string) ApparmorRule { mqueueType = "sysv" } return &Mqueue{ - Qualifier: NewQualifierFromLog(log), - Access: toAccess(log["requested"]), + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), + Access: Must(toAccess(MQUEUE, log["requested"])), Type: mqueueType, Label: log["label"], Name: log["name"], } } +func (r *Mqueue) Validate() error { + if err := validateValues(r.Kind(), "access", r.Access); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + if err := validateValues(r.Kind(), "type", []string{r.Type}); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + return nil +} + func (r *Mqueue) Less(other any) bool { o, _ := other.(*Mqueue) - if r.Qualifier.Equals(o.Qualifier) { - if r.Access == o.Access { - if r.Type == o.Type { - return r.Label < o.Label - } - return r.Type < o.Type - } - return r.Access < o.Access + if len(r.Access) != len(o.Access) { + return len(r.Access) < len(o.Access) + } + if r.Type != o.Type { + return r.Type < o.Type + } + if r.Label != o.Label { + return r.Label < o.Label } return r.Qualifier.Less(o.Qualifier) } func (r *Mqueue) Equals(other any) bool { o, _ := other.(*Mqueue) - return r.Access == o.Access && r.Type == o.Type && r.Label == o.Label && r.Qualifier.Equals(o.Qualifier) + return slices.Equal(r.Access, o.Access) && r.Type == o.Type && r.Label == o.Label && + r.Name == o.Name && r.Qualifier.Equals(o.Qualifier) +} + +func (r *Mqueue) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Mqueue) Constraint() constraint { + return blockKind +} + +func (r *Mqueue) Kind() Kind { + return MQUEUE } diff --git a/pkg/aa/network.go b/pkg/aa/network.go index 74b0c763..8d01b0ba 100644 --- a/pkg/aa/network.go +++ b/pkg/aa/network.go @@ -4,60 +4,110 @@ package aa +import ( + "fmt" +) + +const NETWORK Kind = "network" + +func init() { + requirements[NETWORK] = requirement{ + "access": []string{ + "create", "bind", "listen", "accept", "connect", "shutdown", + "getattr", "setattr", "getopt", "setopt", "send", "receive", + "r", "w", "rw", + }, + "domains": []string{ + "unix", "inet", "ax25", "ipx", "appletalk", "netrom", "bridge", + "atmpvc", "x25", "inet6", "rose", "netbeui", "security", "key", + "netlink", "packet", "ash", "econet", "atmsvc", "rds", "sna", "irda", + "pppox", "wanpipe", "llc", "ib", "mpls", "can", "tipc", "bluetooth", + "iucv", "rxrpc", "isdn", "phonet", "ieee802154", "caif", "alg", + "nfc", "vsock", "kcm", "qipcrtr", "smc", "xdp", "mctp", + }, + "type": []string{ + "stream", "dgram", "seqpacket", "rdm", "raw", "packet", + }, + "protocol": []string{"tcp", "udp", "icmp"}, + } +} + type AddressExpr struct { Source string Destination string Port string } +func newAddressExprFromLog(log map[string]string) AddressExpr { + return AddressExpr{ + Source: log["laddr"], + Destination: log["faddr"], + Port: log["lport"], + } +} + +func (r AddressExpr) Less(other AddressExpr) bool { + if r.Source != other.Source { + return r.Source < other.Source + } + if r.Destination != other.Destination { + return r.Destination < other.Destination + } + return r.Port < other.Port +} + func (r AddressExpr) Equals(other AddressExpr) bool { return r.Source == other.Source && r.Destination == other.Destination && r.Port == other.Port } -func (r AddressExpr) Less(other AddressExpr) bool { - if r.Source == other.Source { - if r.Destination == other.Destination { - return r.Port < other.Port - } - return r.Destination < other.Destination - } - return r.Source < other.Source -} - type Network struct { + RuleBase Qualifier + AddressExpr Domain string Type string Protocol string - AddressExpr } -func NetworkFromLog(log map[string]string) ApparmorRule { +func newNetworkFromLog(log map[string]string) Rule { return &Network{ - Qualifier: NewQualifierFromLog(log), - AddressExpr: AddressExpr{ - Source: log["laddr"], - Destination: log["faddr"], - Port: log["lport"], - }, - Domain: log["family"], - Type: log["sock_type"], - Protocol: log["protocol"], + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), + AddressExpr: newAddressExprFromLog(log), + Domain: log["family"], + Type: log["sock_type"], + Protocol: log["protocol"], } } +func (r *Network) Validate() error { + if err := validateValues(r.Kind(), "domains", []string{r.Domain}); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + if err := validateValues(r.Kind(), "type", []string{r.Type}); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + if err := validateValues(r.Kind(), "protocol", []string{r.Protocol}); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + return nil +} + func (r *Network) Less(other any) bool { o, _ := other.(*Network) - if r.Qualifier.Equals(o.Qualifier) { - if r.Domain == o.Domain { - if r.Type == o.Type { - return r.Protocol < o.Protocol - } - return r.Type < o.Type - } + if r.Domain != o.Domain { return r.Domain < o.Domain } + if r.Type != o.Type { + return r.Type < o.Type + } + if r.Protocol != o.Protocol { + return r.Protocol < o.Protocol + } + if r.AddressExpr.Less(o.AddressExpr) { + return r.AddressExpr.Less(o.AddressExpr) + } return r.Qualifier.Less(o.Qualifier) } @@ -67,3 +117,15 @@ func (r *Network) Equals(other any) bool { r.Protocol == o.Protocol && r.AddressExpr.Equals(o.AddressExpr) && r.Qualifier.Equals(o.Qualifier) } + +func (r *Network) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Network) Constraint() constraint { + return blockKind +} + +func (r *Network) Kind() Kind { + return NETWORK +} diff --git a/pkg/aa/parse.go b/pkg/aa/parse.go new file mode 100644 index 00000000..0e07ebef --- /dev/null +++ b/pkg/aa/parse.go @@ -0,0 +1,245 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2024 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package aa + +import ( + "fmt" + "slices" + "strings" +) + +const ( + tokARROW = "->" + tokEQUAL = "=" + tokLESS = "<" + tokPLUS = "+" + tokCLOSEBRACE = '}' + tokCLOSEBRACKET = ']' + tokCLOSEPAREN = ')' + tokCOLON = ',' + tokOPENBRACE = '{' + tokOPENBRACKET = '[' + tokOPENPAREN = '(' +) + +var ( + newRuleMap = map[string]func([]string) (Rule, error){ + COMMENT.Tok(): newComment, + ABI.Tok(): newAbi, + ALIAS.Tok(): newAlias, + INCLUDE.Tok(): newInclude, + } + + tok = map[Kind]string{ + COMMENT: "#", + VARIABLE: "@{", + HAT: "^", + } + openBlocks = []rune{tokOPENPAREN, tokOPENBRACE, tokOPENBRACKET} + closeBlocks = []rune{tokCLOSEPAREN, tokCLOSEBRACE, tokCLOSEBRACKET} +) + +// Split a raw input rule string into tokens by space or =, but ignore spaces +// within quotes, brakets, or parentheses. +// +// Example: +// +// `owner @{user_config_dirs}/powerdevilrc{,.@{rand6}} rwl -> @{user_config_dirs}/#@{int}` +// +// Returns: +// +// []string{"owner", "@{user_config_dirs}/powerdevilrc{,.@{rand6}}", "rwl", "->", "@{user_config_dirs}/#@{int}"} +func tokenize(str string) []string { + var currentToken strings.Builder + var isVariable bool + var quoted bool + + blockStack := []rune{} + tokens := make([]string, 0, len(str)/2) + if len(str) > 2 && str[0:2] == VARIABLE.Tok() { + isVariable = true + } + for _, r := range str { + switch { + case (r == ' ' || r == '\t') && len(blockStack) == 0 && !quoted: + // Split on space/tab if not in a block or quoted + if currentToken.Len() != 0 { + tokens = append(tokens, currentToken.String()) + currentToken.Reset() + } + + case (r == '=' || r == '+') && len(blockStack) == 0 && !quoted && isVariable: + // Handle variable assignment + if currentToken.Len() != 0 { + tokens = append(tokens, currentToken.String()) + currentToken.Reset() + } + tokens = append(tokens, string(r)) + + case r == '"' && len(blockStack) == 0: + quoted = !quoted + currentToken.WriteRune(r) + + case slices.Contains(openBlocks, r): + blockStack = append(blockStack, r) + currentToken.WriteRune(r) + + case slices.Contains(closeBlocks, r): + if len(blockStack) > 0 { + blockStack = blockStack[:len(blockStack)-1] + } else { + panic(fmt.Sprintf("Unbalanced block, missing '{' or '}' on: %s\n", str)) + } + currentToken.WriteRune(r) + + default: + currentToken.WriteRune(r) + } + } + if currentToken.Len() != 0 { + tokens = append(tokens, currentToken.String()) + } + return tokens +} + +func tokenToSlice(token string) []string { + res := []string{} + token = strings.Trim(token, "()\n") + if strings.ContainsAny(token, ", ") { + var sep string + switch { + case strings.Contains(token, ","): + sep = "," + case strings.Contains(token, " "): + sep = " " + } + for _, v := range strings.Split(token, sep) { + res = append(res, strings.Trim(v, " ")) + } + } else { + res = append(res, token) + } + return res +} + +func tokensStripComment(tokens []string) []string { + res := []string{} + for _, v := range tokens { + if v == COMMENT.Tok() { + break + } + res = append(res, v) + } + return res +} + +// Convert a slice of internal rules to a slice of ApparmorRule. +func newRules(rules [][]string) (Rules, error) { + var err error + var r Rule + res := make(Rules, 0, len(rules)) + + for _, rule := range rules { + if len(rule) == 0 { + return nil, fmt.Errorf("Empty rule") + } + + if newRule, ok := newRuleMap[rule[0]]; ok { + r, err = newRule(rule) + if err != nil { + return nil, err + } + res = append(res, r) + } else if strings.HasPrefix(rule[0], VARIABLE.Tok()) { + r, err = newVariable(rule) + if err != nil { + return nil, err + } + res = append(res, r) + } else { + return nil, fmt.Errorf("Unrecognized rule: %s", rule) + } + } + return res, nil +} + +func (f *AppArmorProfileFile) parsePreamble(input []string) error { + var err error + var r Rule + var rules Rules + + tokenizedRules := [][]string{} + for _, line := range input { + if strings.HasPrefix(line, COMMENT.Tok()) { + r, err = newComment(strings.Split(line, " ")) + if err != nil { + return err + } + rules = append(rules, r) + } else { + tokens := tokenize(line) + tokenizedRules = append(tokenizedRules, tokens) + } + } + + rr, err := newRules(tokenizedRules) + if err != nil { + return err + } + f.Preamble = append(f.Preamble, rules...) + f.Preamble = append(f.Preamble, rr...) + return nil +} + +// Parse an apparmor profile file. +// +// Only supports parsing of apparmor file preamble and profile headers. +// +// Warning: It is purposelly an uncomplete basic parser for apparmor profile, +// it is only aimed for internal tooling purpose. For "simplicity", it is not +// using antlr / participle. It is only used for experimental feature in the +// apparmor.d project. +// +// Stop at the first profile header. Does not support multiline coma rules. +// +// Current use case: +// +// - Parse include and tunables +// - Parse variable in profile preamble and in tunable files +// - Parse (sub) profiles header to edit flags +func (f *AppArmorProfileFile) Parse(input string) error { + rawHeader := "" + rawPreamble := []string{} + +done: + for _, line := range strings.Split(input, "\n") { + tmp := strings.TrimLeft(line, "\t ") + tmp = strings.TrimRight(tmp, ",") + switch { + case tmp == "": + continue + case strings.HasPrefix(tmp, PROFILE.Tok()): + rawHeader = tmp + break done + case strings.HasPrefix(tmp, HAT.String()), strings.HasPrefix(tmp, HAT.Tok()): + break done + default: + rawPreamble = append(rawPreamble, tmp) + } + } + + if err := f.parsePreamble(rawPreamble); err != nil { + return err + } + if rawHeader != "" { + header, err := newHeader(tokenize(rawHeader)) + if err != nil { + return err + } + profile := &Profile{Header: header} + f.Profiles = append(f.Profiles, profile) + } + return nil +} diff --git a/pkg/aa/parse_test.go b/pkg/aa/parse_test.go new file mode 100644 index 00000000..eae45a06 --- /dev/null +++ b/pkg/aa/parse_test.go @@ -0,0 +1,281 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2024 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package aa + +import ( + "reflect" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/util" +) + +func Test_tokenizeRule(t *testing.T) { + for _, tt := range testTokenRules { + t.Run(tt.name, func(t *testing.T) { + if got := tokenize(tt.raw); !reflect.DeepEqual(got, tt.tokens) { + t.Errorf("tokenize() = %v, want %v", got, tt.tokens) + } + }) + } +} + +func Test_AppArmorProfileFile_Parse(t *testing.T) { + for _, tt := range testBlocks { + t.Run(tt.name, func(t *testing.T) { + got := &AppArmorProfileFile{} + if err := got.Parse(tt.raw); (err != nil) != tt.wParseErr { + t.Errorf("AppArmorProfileFile.Parse() error = %v, wantErr %v", err, tt.wParseErr) + } + if !reflect.DeepEqual(got, tt.apparmor) { + t.Errorf("AppArmorProfileFile.Parse() = |%v|, want |%v|", got, tt.apparmor) + } + }) + } +} + +var ( + // Test cases for tokenize + testTokenRules = []struct { + name string + raw string + tokens []string + }{ + { + name: "empty", + raw: "", + tokens: []string{}, + }, + { + name: "abi", + raw: `abi `, + tokens: []string{"abi", ""}, + }, + { + name: "alias", + raw: `alias /mnt/usr -> /usr`, + tokens: []string{"alias", "/mnt/usr", "->", "/usr"}, + }, + { + name: "variable", + raw: `@{name} = torbrowser "tor browser"`, + tokens: []string{"@{name}", "=", "torbrowser", `"tor browser"`}, + }, + { + name: "variable-2", + raw: `@{exec_path} += @{bin}/@{name}`, + tokens: []string{"@{exec_path}", "+", "=", "@{bin}/@{name}"}, + }, + { + name: "variable-3", + raw: `@{empty}="dummy"`, + tokens: []string{"@{empty}", "=", `"dummy"`}, + }, + { + name: "variable-4", + raw: `@{XDG_PROJECTS_DIR}+="Git"`, + tokens: []string{"@{XDG_PROJECTS_DIR}", "+", "=", `"Git"`}, + }, + { + name: "header", + raw: `profile foo @{exec_path} xattrs=(security.tagged=allowed) flags=(complain attach_disconnected)`, + tokens: []string{"profile", "foo", "@{exec_path}", "xattrs=(security.tagged=allowed)", "flags=(complain attach_disconnected)"}, + }, + { + name: "include", + raw: `include `, + tokens: []string{"include", ""}, + }, + { + name: "include-if-exists", + raw: `include if exists "/etc/apparmor.d/dummy"`, + tokens: []string{"include", "if", "exists", `"/etc/apparmor.d/dummy"`}, + }, + { + name: "rlimit", + raw: `set rlimit nproc <= 200`, + tokens: []string{"set", "rlimit", "nproc", "<=", "200"}, + }, + { + name: "userns", + raw: `userns`, + tokens: []string{"userns"}, + }, + { + name: "capability", + raw: `capability dac_read_search`, + tokens: []string{"capability", "dac_read_search"}, + }, + { + name: "network", + raw: `network netlink raw`, + tokens: []string{"network", "netlink", "raw"}, + }, + { + name: "mount", + raw: `mount /{,**}`, + tokens: []string{"mount", "/{,**}"}, + }, + { + name: "mount-2", + raw: `mount options=(rw rbind) /tmp/newroot/ -> /tmp/newroot/`, + tokens: []string{"mount", "options=(rw rbind)", "/tmp/newroot/", "->", "/tmp/newroot/"}, + }, + { + name: "mount-3", + raw: `mount options=(rw silent rprivate) -> /oldroot/`, + tokens: []string{"mount", "options=(rw silent rprivate)", "->", "/oldroot/"}, + }, + { + name: "mount-4", + raw: `mount fstype=devpts options=(rw nosuid noexec) devpts -> /newroot/dev/pts/`, + tokens: []string{"mount", "fstype=devpts", "options=(rw nosuid noexec)", "devpts", "->", "/newroot/dev/pts/"}, + }, + { + name: "signal", + raw: `signal (receive) set=(cont, term,winch) peer=at-spi-bus-launcher`, + tokens: []string{"signal", "(receive)", "set=(cont, term,winch)", "peer=at-spi-bus-launcher"}, + }, + { + name: "unix", + raw: `unix (send receive) type=stream addr="@/tmp/.ICE[0-9]*-unix/19 5" peer=(label="@{p_systemd}", addr=none)`, + tokens: []string{"unix", "(send receive)", "type=stream", "addr=\"@/tmp/.ICE[0-9]*-unix/19 5\"", "peer=(label=\"@{p_systemd}\", addr=none)"}, + }, + { + name: "unix-2", + raw: ` unix (connect, receive, send) + type=stream + peer=(addr="@/tmp/ibus/dbus-????????")`, + tokens: []string{"unix", "(connect, receive, send)\n", "type=stream\n", `peer=(addr="@/tmp/ibus/dbus-????????")`}, + }, + { + name: "dbus", + raw: `dbus receive bus=system path=/org/freedesktop/DBus interface=org.freedesktop.DBus member=AddMatch peer=(name=:1.3, label=power-profiles-daemon)`, + tokens: []string{ + "dbus", "receive", "bus=system", + "path=/org/freedesktop/DBus", "interface=org.freedesktop.DBus", + "member=AddMatch", "peer=(name=:1.3, label=power-profiles-daemon)", + }, + }, + { + name: "file-1", + raw: `owner @{user_config_dirs}/powerdevilrc{,.@{rand6}} rwl -> @{user_config_dirs}/#@{int}`, + tokens: []string{"owner", "@{user_config_dirs}/powerdevilrc{,.@{rand6}}", "rwl", "->", "@{user_config_dirs}/#@{int}"}, + }, + { + name: "file-2", + raw: `@{sys}/devices/@{pci}/class r`, + tokens: []string{"@{sys}/devices/@{pci}/class", "r"}, + }, + { + name: "file-3", + raw: `owner @{PROC}/@{pid}/task/@{tid}/comm rw`, + tokens: []string{"owner", "@{PROC}/@{pid}/task/@{tid}/comm", "rw"}, + }, + { + name: "file-4", + raw: `owner /{var/,}tmp/#@{int} rw`, + tokens: []string{"owner", "/{var/,}tmp/#@{int}", "rw"}, + }, + } + + // Test cases for Parse + testBlocks = []struct { + name string + raw string + apparmor *AppArmorProfileFile + wParseErr bool + }{ + { + name: "empty", + raw: "", + apparmor: &AppArmorProfileFile{}, + wParseErr: false, + }, + { + name: "comment", + raw: ` + # IsLineRule comment + include # comment included + @{lib_dirs} = @{lib}/@{name} /opt/@{name} # comment in variable`, + apparmor: &AppArmorProfileFile{ + Preamble: Rules{ + &Comment{RuleBase: RuleBase{IsLineRule: true, Comment: " IsLineRule comment"}}, + &Include{ + RuleBase: RuleBase{Comment: " comment included"}, + Path: "tunables/global", IsMagic: true, + }, + &Variable{ + RuleBase: RuleBase{Comment: " comment in variable"}, + Name: "lib_dirs", Define: true, + Values: []string{"@{lib}/@{name}", "/opt/@{name}"}, + }, + }, + }, + wParseErr: false, + }, + { + name: "cornercases", + raw: `# Simple test + include + + # { commented block } + @{name} = {D,d}ummy + @{exec_path} = @{bin}/@{name} + alias /mnt/{,usr.sbin.}mount.cifs -> /sbin/mount.cifs, + @{coreutils} += gawk {,e,f}grep head + profile @{exec_path} { + `, + apparmor: &AppArmorProfileFile{ + Preamble: Rules{ + &Comment{RuleBase: RuleBase{IsLineRule: true, Comment: " Simple test"}}, + &Comment{RuleBase: RuleBase{IsLineRule: true, Comment: " { commented block }"}}, + &Include{IsMagic: true, Path: "tunables/global"}, + &Variable{Name: "name", Values: []string{"{D,d}ummy"}, Define: true}, + &Variable{Name: "exec_path", Values: []string{"@{bin}/@{name}"}, Define: true}, + &Alias{Path: "/mnt/{,usr.sbin.}mount.cifs", RewrittenPath: "/sbin/mount.cifs"}, + &Variable{Name: "coreutils", Values: []string{"gawk", "{,e,f}grep", "head"}, Define: false}, + }, + Profiles: []*Profile{ + { + Header: Header{ + Name: "@{exec_path}", + Attachments: []string{}, + Attributes: map[string]string{}, + Flags: []string{}, + }, + }, + }, + }, + wParseErr: false, + }, + { + name: "string.aa", + raw: util.MustReadFile(testData.Join("string.aa")), + apparmor: &AppArmorProfileFile{ + Preamble: Rules{ + &Comment{RuleBase: RuleBase{Comment: " Simple test profile for the AppArmorProfileFile.String() method", IsLineRule: true}}, + &Abi{IsMagic: true, Path: "abi/4.0"}, + &Alias{Path: "/mnt/usr", RewrittenPath: "/usr"}, + &Include{IsMagic: true, Path: "tunables/global"}, + &Variable{ + Name: "exec_path", Define: true, + Values: []string{"@{bin}/foo", "@{lib}/foo"}, + }, + }, + Profiles: []*Profile{ + { + Header: Header{ + Name: "foo", + Attachments: []string{"@{exec_path}"}, + Attributes: map[string]string{"security.tagged": "allowed"}, + Flags: []string{"complain", "attach_disconnected"}, + }, + }, + }, + }, + wParseErr: false, + }, + } +) diff --git a/pkg/aa/pivot_root.go b/pkg/aa/pivot_root.go index 05e3d0a5..178b848d 100644 --- a/pkg/aa/pivot_root.go +++ b/pkg/aa/pivot_root.go @@ -4,33 +4,41 @@ package aa +const PIVOTROOT = "pivot_root" + type PivotRoot struct { + RuleBase Qualifier OldRoot string NewRoot string TargetProfile string } -func PivotRootFromLog(log map[string]string) ApparmorRule { +func newPivotRootFromLog(log map[string]string) Rule { return &PivotRoot{ - Qualifier: NewQualifierFromLog(log), + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), OldRoot: log["srcname"], NewRoot: log["name"], TargetProfile: "", } } +func (r *PivotRoot) Validate() error { + return nil +} + func (r *PivotRoot) Less(other any) bool { o, _ := other.(*PivotRoot) - if r.Qualifier.Equals(o.Qualifier) { - if r.OldRoot == o.OldRoot { - if r.NewRoot == o.NewRoot { - return r.TargetProfile < o.TargetProfile - } - return r.NewRoot < o.NewRoot - } + if r.OldRoot != o.OldRoot { return r.OldRoot < o.OldRoot } + if r.NewRoot != o.NewRoot { + return r.NewRoot < o.NewRoot + } + if r.TargetProfile != o.TargetProfile { + return r.TargetProfile < o.TargetProfile + } return r.Qualifier.Less(o.Qualifier) } @@ -40,3 +48,15 @@ func (r *PivotRoot) Equals(other any) bool { r.TargetProfile == o.TargetProfile && r.Qualifier.Equals(o.Qualifier) } + +func (r *PivotRoot) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *PivotRoot) Constraint() constraint { + return blockKind +} + +func (r *PivotRoot) Kind() Kind { + return PIVOTROOT +} diff --git a/pkg/aa/preamble.go b/pkg/aa/preamble.go new file mode 100644 index 00000000..d8cb5813 --- /dev/null +++ b/pkg/aa/preamble.go @@ -0,0 +1,310 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2024 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package aa + +import ( + "fmt" + "slices" + "strings" +) + +const ( + ABI Kind = "abi" + ALIAS Kind = "alias" + INCLUDE Kind = "include" + VARIABLE Kind = "variable" + COMMENT Kind = "comment" + + tokIFEXISTS = "if exists" +) + +type Comment struct { + RuleBase +} + +func newComment(rule []string) (Rule, error) { + base := newRule(rule) + base.IsLineRule = true + return &Comment{RuleBase: base}, nil +} + +func (r *Comment) Validate() error { + return nil +} + +func (r *Comment) Less(other any) bool { + return false +} + +func (r *Comment) Equals(other any) bool { + return false +} + +func (r *Comment) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Comment) IsPreamble() bool { + return true +} + +func (r *Comment) Constraint() constraint { + return anyKind +} + +func (r *Comment) Kind() Kind { + return COMMENT +} + +type Abi struct { + RuleBase + Path string + IsMagic bool +} + +func newAbi(rule []string) (Rule, error) { + var magic bool + if len(rule) > 0 && rule[0] == ABI.Tok() { + rule = rule[1:] + } + if len(rule) != 1 { + return nil, fmt.Errorf("invalid abi format: %s", rule) + } + + path := rule[0] + switch { + case path[0] == '"': + magic = false + case path[0] == '<': + magic = true + default: + return nil, fmt.Errorf("invalid path %s in rule: %s", path, rule) + } + return &Abi{ + RuleBase: newRule(rule), + Path: strings.Trim(path, "\"<>"), + IsMagic: magic, + }, nil +} + +func (r *Abi) Validate() error { + return nil +} + +func (r *Abi) Less(other any) bool { + o, _ := other.(*Abi) + if r.Path != o.Path { + return r.Path < o.Path + } + return r.IsMagic == o.IsMagic +} + +func (r *Abi) Equals(other any) bool { + o, _ := other.(*Abi) + return r.Path == o.Path && r.IsMagic == o.IsMagic +} + +func (r *Abi) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Abi) Constraint() constraint { + return preambleKind +} + +func (r *Abi) Kind() Kind { + return ABI +} + +type Alias struct { + RuleBase + Path string + RewrittenPath string +} + +func newAlias(rule []string) (Rule, error) { + if len(rule) > 0 && rule[0] == ALIAS.Tok() { + rule = rule[1:] + } + if len(rule) != 3 { + return nil, fmt.Errorf("invalid alias format: %s", rule) + } + if rule[1] != tokARROW { + return nil, fmt.Errorf("invalid alias format, missing %s in: %s", tokARROW, rule) + } + return &Alias{ + RuleBase: newRule(rule), + Path: rule[0], + RewrittenPath: rule[2], + }, nil +} + +func (r *Alias) Validate() error { + return nil +} + +func (r Alias) Less(other any) bool { + o, _ := other.(*Alias) + if r.Path != o.Path { + return r.Path < o.Path + } + return r.RewrittenPath < o.RewrittenPath +} + +func (r Alias) Equals(other any) bool { + o, _ := other.(*Alias) + return r.Path == o.Path && r.RewrittenPath == o.RewrittenPath +} + +func (r *Alias) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Alias) Constraint() constraint { + return preambleKind +} + +func (r *Alias) Kind() Kind { + return ALIAS +} + +type Include struct { + RuleBase + IfExists bool + Path string + IsMagic bool +} + +func newInclude(rule []string) (Rule, error) { + var magic bool + var ifexists bool + + if len(rule) > 0 && rule[0] == INCLUDE.Tok() { + rule = rule[1:] + } + + size := len(rule) + if size == 0 { + return nil, fmt.Errorf("invalid include format: %v", rule) + } + + if size >= 3 && strings.Join(rule[:2], " ") == tokIFEXISTS { + ifexists = true + rule = rule[2:] + } + + path := rule[0] + switch { + case path[0] == '"': + magic = false + case path[0] == '<': + magic = true + default: + return nil, fmt.Errorf("invalid path format: %v", path) + } + return &Include{ + RuleBase: newRule(rule), + IfExists: ifexists, + Path: strings.Trim(path, "\"<>"), + IsMagic: magic, + }, nil +} + +func (r *Include) Validate() error { + return nil +} + +func (r *Include) Less(other any) bool { + o, _ := other.(*Include) + if r.Path == o.Path { + return r.Path < o.Path + } + if r.IsMagic != o.IsMagic { + return r.IsMagic + } + return r.IfExists +} + +func (r *Include) Equals(other any) bool { + o, _ := other.(*Include) + return r.Path == o.Path && r.IsMagic == o.IsMagic && r.IfExists == o.IfExists +} + +func (r *Include) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Include) Constraint() constraint { + return anyKind +} + +func (r *Include) Kind() Kind { + return INCLUDE +} + +type Variable struct { + RuleBase + Name string + Values []string + Define bool +} + +func newVariable(rule []string) (Rule, error) { + var define bool + var values []string + if len(rule) < 3 { + return nil, fmt.Errorf("invalid variable format: %v", rule) + } + + name := strings.Trim(rule[0], VARIABLE.Tok()+"}") + switch rule[1] { + case tokEQUAL: + define = true + values = tokensStripComment(rule[2:]) + case tokPLUS: + if rule[2] != tokEQUAL { + return nil, fmt.Errorf("invalid operator in variable: %v", rule) + } + define = false + values = tokensStripComment(rule[3:]) + default: + return nil, fmt.Errorf("invalid operator in variable: %v", rule) + } + return &Variable{ + RuleBase: newRule(rule), + Name: name, + Values: values, + Define: define, + }, nil +} + +func (r *Variable) Validate() error { + return nil +} + +func (r *Variable) Less(other any) bool { + o, _ := other.(*Variable) + if r.Name != o.Name { + return r.Name < o.Name + } + return len(r.Values) < len(o.Values) +} + +func (r *Variable) Equals(other any) bool { + o, _ := other.(*Variable) + return r.Name == o.Name && slices.Equal(r.Values, o.Values) +} + +func (r *Variable) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Variable) Constraint() constraint { + return preambleKind +} + +func (r *Variable) Kind() Kind { + return VARIABLE +} diff --git a/pkg/aa/profile.go b/pkg/aa/profile.go index b68d974c..21181378 100644 --- a/pkg/aa/profile.go +++ b/pkg/aa/profile.go @@ -5,71 +5,205 @@ package aa import ( - "bytes" - "reflect" + "fmt" + "maps" "slices" - "sort" "strings" - - "github.com/roddhjav/apparmor.d/pkg/paths" ) -// Default Apparmor magic directory: /etc/apparmor.d/. -var MagicRoot = paths.New("/etc/apparmor.d") +const ( + PROFILE Kind = "profile" -// AppArmorProfiles represents a full set of apparmor profiles -type AppArmorProfiles map[string]*AppArmorProfile + tokATTRIBUTES = "xattrs" + tokFLAGS = "flags" +) -// ApparmorProfile represents a full apparmor profile. -// Warning: close to the BNF grammar of apparmor profile but not exactly the same (yet): -// - Some rules are not supported yet (subprofile, hat...) -// - The structure is simplified as it only aims at writing profile, not parsing it. -type AppArmorProfile struct { - Preamble - Profile +func init() { + requirements[PROFILE] = requirement{ + tokFLAGS: { + "enforce", "complain", "kill", "default_allow", "unconfined", + "prompt", "audit", "mediate_deleted", "attach_disconnected", + "attach_disconneced.path=", "chroot_relative", "debug", + "interruptible", "kill", "kill.signal=", + }, + } } -// Preamble section of a profile -type Preamble struct { - Abi []Abi - Includes []Include - Aliases []Alias - Variables []Variable -} - -// Profile section of a profile +// Profile represents a single AppArmor profile. type Profile struct { + RuleBase + Header + Rules Rules +} + +// Header represents the header of a profile. +type Header struct { Name string Attachments []string Attributes map[string]string Flags []string - Rules Rules } -// ApparmorRule generic interface -type ApparmorRule interface { - Less(other any) bool - Equals(other any) bool -} - -type Rules []ApparmorRule - -func NewAppArmorProfile() *AppArmorProfile { - return &AppArmorProfile{} -} - -// String returns the formatted representation of a profile as a string -func (p *AppArmorProfile) String() string { - var res bytes.Buffer - err := tmplAppArmorProfile.Execute(&res, p) - if err != nil { - return err.Error() +func newHeader(rule []string) (Header, error) { + if len(rule) == 0 { + return Header{}, nil } - return res.String() + if rule[len(rule)-1] == "{" { + rule = rule[:len(rule)-1] + } + if rule[0] == PROFILE.Tok() { + rule = rule[1:] + } + + delete := []int{} + flags := []string{} + attributes := make(map[string]string) + for idx, token := range rule { + if item, ok := strings.CutPrefix(token, tokFLAGS+"="); ok { + flags = tokenToSlice(item) + delete = append(delete, idx) + } else if item, ok := strings.CutPrefix(token, tokATTRIBUTES+"="); ok { + for _, m := range tokenToSlice(item) { + kv := strings.SplitN(m, "=", 2) + attributes[kv[0]] = kv[1] + } + delete = append(delete, idx) + } + } + for i := len(delete) - 1; i >= 0; i-- { + rule = slices.Delete(rule, delete[i], delete[i]+1) + } + + name, attachments := "", []string{} + if len(rule) >= 1 { + name = rule[0] + if len(rule) > 1 { + attachments = rule[1:] + } + } + return Header{ + Name: name, + Attachments: attachments, + Attributes: attributes, + Flags: flags, + }, nil } -// AddRule adds a new rule to the profile from a log map -func (p *AppArmorProfile) AddRule(log map[string]string) { +func (r *Profile) Validate() error { + if err := validateValues(r.Kind(), tokFLAGS, r.Flags); err != nil { + return fmt.Errorf("profile %s: %w", r.Name, err) + } + return r.Rules.Validate() +} + +func (p *Profile) Less(other any) bool { + o, _ := other.(*Profile) + if p.Name != o.Name { + return p.Name < o.Name + } + return len(p.Attachments) < len(o.Attachments) +} + +func (p *Profile) Equals(other any) bool { + o, _ := other.(*Profile) + return p.Name == o.Name && slices.Equal(p.Attachments, o.Attachments) && + maps.Equal(p.Attributes, o.Attributes) && + slices.Equal(p.Flags, o.Flags) +} + +func (p *Profile) String() string { + return renderTemplate(p.Kind(), p) +} + +func (p *Profile) Constraint() constraint { + return blockKind +} + +func (p *Profile) Kind() Kind { + return PROFILE +} + +func (p *Profile) Merge() { + slices.Sort(p.Flags) + p.Flags = slices.Compact(p.Flags) + p.Rules = p.Rules.Merge() +} + +func (p *Profile) Sort() { + p.Rules = p.Rules.Sort() +} + +func (p *Profile) Format() { + p.Rules = p.Rules.Format() +} + +// GetAttachments return a nested attachment string +func (p *Profile) GetAttachments() string { + switch len(p.Attachments) { + case 0: + return "" + case 1: + return p.Attachments[0] + default: + res := []string{} + for _, attachment := range p.Attachments { + if strings.HasPrefix(attachment, "/") { + res = append(res, attachment[1:]) + } else { + res = append(res, attachment) + } + } + return "/{" + strings.Join(res, ",") + "}" + } +} + +var ( + newLogMap = map[string]func(log map[string]string) Rule{ + "rlimits": newRlimitFromLog, + "cap": newCapabilityFromLog, + "io_uring": newIOUringFromLog, + "signal": newSignalFromLog, + "ptrace": newPtraceFromLog, + "namespace": newUsernsFromLog, + "unix": newUnixFromLog, + "dbus": newDbusFromLog, + "posix_mqueue": newMqueueFromLog, + "sysv_mqueue": newMqueueFromLog, + "mount": func(log map[string]string) Rule { + if strings.Contains(log["flags"], "remount") { + return newRemountFromLog(log) + } + newRule := newLogMountMap[log["operation"]] + return newRule(log) + }, + "net": func(log map[string]string) Rule { + if log["family"] == "unix" { + return newUnixFromLog(log) + } else { + return newNetworkFromLog(log) + } + }, + "file": func(log map[string]string) Rule { + if log["operation"] == "change_onexec" { + return newChangeProfileFromLog(log) + } else { + return newFileFromLog(log) + } + }, + "exec": newFileFromLog, + "file_inherit": newFileFromLog, + "file_perm": newFileFromLog, + "open": newFileFromLog, + } + newLogMountMap = map[string]func(log map[string]string) Rule{ + "mount": newMountFromLog, + "umount": newUmountFromLog, + "remount": newRemountFromLog, + "pivotroot": newPivotRootFromLog, + } +) + +func (p *Profile) AddRule(log map[string]string) { // Generate profile flags and extra rules switch log["error"] { case "-2": @@ -78,134 +212,27 @@ func (p *AppArmorProfile) AddRule(log map[string]string) { } case "-13": if strings.Contains(log["info"], "namespace creation restricted") { - p.Rules = append(p.Rules, UsernsFromLog(log)) + p.Rules = append(p.Rules, newUsernsFromLog(log)) } else if strings.Contains(log["info"], "disconnected path") && !slices.Contains(p.Flags, "attach_disconnected") { p.Flags = append(p.Flags, "attach_disconnected") } default: } - switch log["class"] { - case "cap": - p.Rules = append(p.Rules, CapabilityFromLog(log)) - case "net": - if log["family"] == "unix" { - p.Rules = append(p.Rules, UnixFromLog(log)) - } else { - p.Rules = append(p.Rules, NetworkFromLog(log)) + done := false + for _, key := range []string{"class", "family", "operation"} { + if newRule, ok := newLogMap[log[key]]; ok { + p.Rules = append(p.Rules, newRule(log)) + done = true + break } - case "mount": - if strings.Contains(log["flags"], "remount") { - p.Rules = append(p.Rules, RemountFromLog(log)) - } else { - switch log["operation"] { - case "mount": - p.Rules = append(p.Rules, MountFromLog(log)) - case "umount": - p.Rules = append(p.Rules, UmountFromLog(log)) - case "remount": - p.Rules = append(p.Rules, RemountFromLog(log)) - case "pivotroot": - p.Rules = append(p.Rules, PivotRootFromLog(log)) - } - } - case "posix_mqueue", "sysv_mqueue": - p.Rules = append(p.Rules, MqueueFromLog(log)) - case "signal": - p.Rules = append(p.Rules, SignalFromLog(log)) - case "ptrace": - p.Rules = append(p.Rules, PtraceFromLog(log)) - case "namespace": - p.Rules = append(p.Rules, UsernsFromLog(log)) - case "unix": - p.Rules = append(p.Rules, UnixFromLog(log)) - case "file": - if log["operation"] == "change_onexec" { - p.Rules = append(p.Rules, ChangeProfileFromLog(log)) - } else { - p.Rules = append(p.Rules, FileFromLog(log)) - } - default: + } + + if !done { if strings.Contains(log["operation"], "dbus") { - p.Rules = append(p.Rules, DbusFromLog(log)) - } else if log["family"] == "unix" { - p.Rules = append(p.Rules, UnixFromLog(log)) - } - } -} - -// Sort the rules in the profile -// Follow: https://apparmor.pujol.io/development/guidelines/#guidelines -func (p *AppArmorProfile) Sort() { - sort.Slice(p.Rules, func(i, j int) bool { - typeOfI := reflect.TypeOf(p.Rules[i]) - typeOfJ := reflect.TypeOf(p.Rules[j]) - if typeOfI != typeOfJ { - valueOfI := typeToValue(typeOfI) - valueOfJ := typeToValue(typeOfJ) - if typeOfI == reflect.TypeOf((*Include)(nil)) && p.Rules[i].(*Include).IfExists { - valueOfI = "include_if_exists" - } - if typeOfJ == reflect.TypeOf((*Include)(nil)) && p.Rules[j].(*Include).IfExists { - valueOfJ = "include_if_exists" - } - return ruleWeights[valueOfI] < ruleWeights[valueOfJ] - } - return p.Rules[i].Less(p.Rules[j]) - }) -} - -// MergeRules merge similar rules together -// Steps: -// - Remove identical rules -// - Merge rule access. Eg: for same path, 'r' and 'w' becomes 'rw' -// -// Note: logs.regCleanLogs helps a lot to do a first cleaning -func (p *AppArmorProfile) MergeRules() { - for i := 0; i < len(p.Rules); i++ { - for j := i + 1; j < len(p.Rules); j++ { - typeOfI := reflect.TypeOf(p.Rules[i]) - typeOfJ := reflect.TypeOf(p.Rules[j]) - if typeOfI != typeOfJ { - continue - } - - // If rules are identical, merge them - if p.Rules[i].Equals(p.Rules[j]) { - p.Rules = append(p.Rules[:j], p.Rules[j+1:]...) - j-- - } - } - } -} - -// Format the profile for better readability before printing it -// Follow: https://apparmor.pujol.io/development/guidelines/#the-file-block -func (p *AppArmorProfile) Format() { - const prefixOwner = " " - hasOwnerRule := false - for i := len(p.Rules) - 1; i > 0; i-- { - j := i - 1 - typeOfI := reflect.TypeOf(p.Rules[i]) - typeOfJ := reflect.TypeOf(p.Rules[j]) - - // File rule - if typeOfI == reflect.TypeOf((*File)(nil)) && typeOfJ == reflect.TypeOf((*File)(nil)) { - letterI := getLetterIn(fileAlphabet, p.Rules[i].(*File).Path) - letterJ := getLetterIn(fileAlphabet, p.Rules[j].(*File).Path) - - // Add prefix before rule path to align with other rule - if p.Rules[i].(*File).Owner { - hasOwnerRule = true - } else if hasOwnerRule { - p.Rules[i].(*File).Prefix = prefixOwner - } - - if letterI != letterJ { - // Add a new empty line between Files rule of different type - hasOwnerRule = false - p.Rules = append(p.Rules[:i], append([]ApparmorRule{&Rule{}}, p.Rules[i:]...)...) - } + p.Rules = append(p.Rules, newDbusFromLog(log)) + } else { + fmt.Printf("unknown log type: %s", log) } } } diff --git a/pkg/aa/profile_test.go b/pkg/aa/profile_test.go index 094c8e5c..c2edd52c 100644 --- a/pkg/aa/profile_test.go +++ b/pkg/aa/profile_test.go @@ -6,328 +6,128 @@ package aa import ( "reflect" - "strings" "testing" - - "github.com/roddhjav/apparmor.d/pkg/paths" ) -func readprofile(path string) string { - file := paths.New("../../").Join(path) - lines, err := file.ReadFileAsLines() - if err != nil { - panic(err) - } - res := "" - for _, line := range lines { - if strings.HasPrefix(line, "#") { - continue - } - res += line + "\n" - } - return res[:len(res)-1] -} - -func TestAppArmorProfile_String(t *testing.T) { - tests := []struct { - name string - p *AppArmorProfile - want string - }{ - { - name: "empty", - p: &AppArmorProfile{}, - want: ``, - }, - { - name: "foo", - p: &AppArmorProfile{ - Preamble: Preamble{ - Abi: []Abi{{IsMagic: true, Path: "abi/4.0"}}, - Includes: []Include{{IsMagic: true, Path: "tunables/global"}}, - Aliases: []Alias{{Path: "/mnt/usr", RewrittenPath: "/usr"}}, - Variables: []Variable{{ - Name: "exec_path", - Values: []string{"@{bin}/foo", "@{lib}/foo"}, - }}, - }, - Profile: Profile{ - Name: "foo", - Attachments: []string{"@{exec_path}"}, - Attributes: map[string]string{"security.tagged": "allowed"}, - Flags: []string{"complain", "attach_disconnected"}, - Rules: []ApparmorRule{ - &Include{IsMagic: true, Path: "abstractions/base"}, - &Include{IsMagic: true, Path: "abstractions/nameservice-strict"}, - rlimit1, - &Capability{Name: "dac_read_search"}, - &Capability{Name: "dac_override"}, - &Network{Domain: "inet", Type: "stream"}, - &Network{Domain: "inet6", Type: "stream"}, - &Mount{ - MountConditions: MountConditions{ - FsType: "fuse.portal", - Options: []string{"rw", "rbind"}, - }, - Source: "@{run}/user/@{uid}/ ", - MountPoint: "/", - }, - &Umount{ - MountConditions: MountConditions{}, - MountPoint: "@{run}/user/@{uid}/", - }, - &Signal{ - Access: "receive", - Set: "term", - Peer: "at-spi-bus-launcher", - }, - &Ptrace{Access: "read", Peer: "nautilus"}, - &Unix{ - Access: "send receive", - Type: "stream", - Address: "@/tmp/.ICE-unix/1995", - Peer: "gnome-shell", - PeerAddr: "none", - }, - &Dbus{ - Access: "bind", - Bus: "session", - Name: "org.gnome.*", - }, - &Dbus{ - Access: "receive", - Bus: "system", - Name: ":1.3", - Path: "/org/freedesktop/DBus", - Interface: "org.freedesktop.DBus", - Member: "AddMatch", - Label: "power-profiles-daemon", - }, - &File{Path: "/opt/intel/oneapi/compiler/*/linux/lib/*.so./*", Access: "rm"}, - &File{Path: "@{PROC}/@{pid}/task/@{tid}/comm", Access: "rw"}, - &File{Path: "@{sys}/devices/@{pci}/class", Access: "r"}, - includeLocal1, - }, - }, - }, - want: readprofile("tests/string.aa"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.p.String(); got != tt.want { - t.Errorf("AppArmorProfile.String() = |%v|, want |%v|", got, tt.want) - } - }) - } -} - -func TestAppArmorProfile_AddRule(t *testing.T) { +func TestProfile_AddRule(t *testing.T) { tests := []struct { name string log map[string]string - want *AppArmorProfile + want *Profile }{ { name: "capability", log: capability1Log, - want: &AppArmorProfile{ - Profile: Profile{ - Rules: []ApparmorRule{capability1}, - }, + want: &Profile{ + Rules: Rules{capability1}, }, }, { name: "network", log: network1Log, - want: &AppArmorProfile{ - Profile: Profile{ - Rules: []ApparmorRule{network1}, - }, + want: &Profile{ + Rules: Rules{network1}, }, }, { name: "mount", log: mount2Log, - want: &AppArmorProfile{ - Profile: Profile{ - Rules: []ApparmorRule{mount2}, - }, + want: &Profile{ + Rules: Rules{mount2}, }, }, { name: "signal", log: signal1Log, - want: &AppArmorProfile{ - Profile: Profile{ - Rules: []ApparmorRule{signal1}, - }, + want: &Profile{ + Rules: Rules{signal1}, }, }, { name: "ptrace", log: ptrace2Log, - want: &AppArmorProfile{ - Profile: Profile{ - Rules: []ApparmorRule{ptrace2}, - }, + want: &Profile{ + Rules: Rules{ptrace2}, }, }, { name: "unix", log: unix1Log, - want: &AppArmorProfile{ - Profile: Profile{ - Rules: []ApparmorRule{unix1}, - }, + want: &Profile{ + Rules: Rules{unix1}, }, }, { name: "dbus", log: dbus2Log, - want: &AppArmorProfile{ - Profile: Profile{ - Rules: []ApparmorRule{dbus2}, - }, + want: &Profile{ + Rules: Rules{dbus2}, }, }, { name: "file", log: file2Log, - want: &AppArmorProfile{ - Profile: Profile{ - Rules: []ApparmorRule{file2}, - }, + want: &Profile{ + Rules: Rules{file2}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := NewAppArmorProfile() + got := &Profile{} got.AddRule(tt.log) if !reflect.DeepEqual(got, tt.want) { - t.Errorf("AppArmorProfile.AddRule() = %v, want %v", got, tt.want) + t.Errorf("Profile.AddRule() = |%v|, want |%v|", got, tt.want) } }) } } -func TestAppArmorProfile_Sort(t *testing.T) { +func TestProfile_GetAttachments(t *testing.T) { tests := []struct { - name string - origin *AppArmorProfile - want *AppArmorProfile + name string + Attachments []string + want string }{ { - name: "all", - origin: &AppArmorProfile{ - Profile: Profile{ - Rules: []ApparmorRule{ - file2, network1, includeLocal1, dbus2, signal1, ptrace1, - capability2, file1, dbus1, unix2, signal2, mount2, - }, - }, + name: "firefox", + Attachments: []string{ + "/{usr/,}bin/firefox{,-esr,-bin}", + "/{usr/,}lib{,32,64}/firefox{,-esr,-bin}/firefox{,-esr,-bin}", + "/opt/firefox{,-esr,-bin}/firefox{,-esr,-bin}", }, - want: &AppArmorProfile{ - Profile: Profile{ - Rules: []ApparmorRule{ - capability2, network1, mount2, signal1, signal2, ptrace1, - unix2, dbus2, dbus1, file1, file2, includeLocal1, - }, - }, + want: "/{{usr/,}bin/firefox{,-esr,-bin},{usr/,}lib{,32,64}/firefox{,-esr,-bin}/firefox{,-esr,-bin},opt/firefox{,-esr,-bin}/firefox{,-esr,-bin}}", + }, + { + name: "geoclue", + Attachments: []string{ + "/{usr/,}libexec/geoclue", + "/{usr/,}libexec/geoclue-2.0/demos/agent", }, + want: "/{{usr/,}libexec/geoclue,{usr/,}libexec/geoclue-2.0/demos/agent}", + }, + { + name: "null", + Attachments: []string{}, + want: "", + }, + { + name: "empty", + Attachments: []string{""}, + want: "", + }, + { + name: "not valid aare", + Attachments: []string{"/file", "relative"}, + want: "/{file,relative}", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := tt.origin - got.Sort() - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("AppArmorProfile.Sort() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestAppArmorProfile_MergeRules(t *testing.T) { - tests := []struct { - name string - origin *AppArmorProfile - want *AppArmorProfile - }{ - { - name: "all", - origin: &AppArmorProfile{ - Profile: Profile{ - Rules: []ApparmorRule{capability1, capability1, network1, network1, file1, file1}, - }, - }, - want: &AppArmorProfile{ - Profile: Profile{ - Rules: []ApparmorRule{capability1, network1, file1}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.origin - got.MergeRules() - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("AppArmorProfile.MergeRules() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestAppArmorProfile_Integration(t *testing.T) { - tests := []struct { - name string - p *AppArmorProfile - want string - }{ - { - name: "aa-status", - p: &AppArmorProfile{ - Preamble: Preamble{ - Abi: []Abi{{IsMagic: true, Path: "abi/3.0"}}, - Includes: []Include{{IsMagic: true, Path: "tunables/global"}}, - Variables: []Variable{{ - Name: "exec_path", - Values: []string{"@{bin}/aa-status", "@{bin}/apparmor_status"}, - }}, - }, - Profile: Profile{ - Name: "aa-status", - Attachments: []string{"@{exec_path}"}, - Rules: Rules{ - &Include{IfExists: true, IsMagic: true, Path: "local/aa-status"}, - &Capability{Name: "dac_read_search"}, - &File{Path: "@{exec_path}", Access: "mr"}, - &File{Path: "@{PROC}/@{pids}/attr/apparmor/current", Access: "r"}, - &File{Path: "@{PROC}/", Access: "r"}, - &File{Path: "@{sys}/module/apparmor/parameters/enabled", Access: "r"}, - &File{Path: "@{sys}/kernel/security/apparmor/profiles", Access: "r"}, - &File{Path: "@{PROC}/@{pids}/attr/current", Access: "r"}, - &Include{IsMagic: true, Path: "abstractions/consoles"}, - &File{Qualifier: Qualifier{Owner: true}, Path: "@{PROC}/@{pid}/mounts", Access: "r"}, - &Include{IsMagic: true, Path: "abstractions/base"}, - &File{Path: "/dev/tty@{int}", Access: "rw"}, - &Capability{Name: "sys_ptrace"}, - &Ptrace{Access: "read"}, - }, - }, - }, - want: readprofile("apparmor.d/profiles-a-f/aa-status"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.p.Sort() - tt.p.MergeRules() - tt.p.Format() - if got := tt.p.String(); "\n"+got != tt.want { - t.Errorf("AppArmorProfile = |%v|, want |%v|", "\n"+got, tt.want) + p := &Profile{} + p.Attachments = tt.Attachments + if got := p.GetAttachments(); got != tt.want { + t.Errorf("Profile.GetAttachments() = %v, want %v", got, tt.want) } }) } diff --git a/pkg/aa/ptrace.go b/pkg/aa/ptrace.go index 5603a24b..00eca588 100644 --- a/pkg/aa/ptrace.go +++ b/pkg/aa/ptrace.go @@ -4,33 +4,69 @@ package aa +import ( + "fmt" + "slices" +) + +const PTRACE Kind = "ptrace" + +func init() { + requirements[PTRACE] = requirement{ + "access": []string{ + "r", "w", "rw", "read", "readby", "trace", "tracedby", + }, + } +} + type Ptrace struct { + RuleBase Qualifier - Access string + Access []string Peer string } -func PtraceFromLog(log map[string]string) ApparmorRule { +func newPtraceFromLog(log map[string]string) Rule { return &Ptrace{ - Qualifier: NewQualifierFromLog(log), - Access: toAccess(log["requested_mask"]), + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), + Access: Must(toAccess(PTRACE, log["requested_mask"])), Peer: log["peer"], } } +func (r *Ptrace) Validate() error { + if err := validateValues(r.Kind(), "access", r.Access); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + return nil +} + func (r *Ptrace) Less(other any) bool { o, _ := other.(*Ptrace) - if r.Qualifier.Equals(o.Qualifier) { - if r.Access == o.Access { - return r.Peer == o.Peer - } - return r.Access < o.Access + if len(r.Access) != len(o.Access) { + return len(r.Access) < len(o.Access) + } + if r.Peer != o.Peer { + return r.Peer == o.Peer } return r.Qualifier.Less(o.Qualifier) } func (r *Ptrace) Equals(other any) bool { o, _ := other.(*Ptrace) - return r.Access == o.Access && r.Peer == o.Peer && + return slices.Equal(r.Access, o.Access) && r.Peer == o.Peer && r.Qualifier.Equals(o.Qualifier) } + +func (r *Ptrace) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Ptrace) Constraint() constraint { + return blockKind +} + +func (r *Ptrace) Kind() Kind { + return PTRACE +} diff --git a/pkg/aa/resolve.go b/pkg/aa/resolve.go new file mode 100644 index 00000000..ea7abf0d --- /dev/null +++ b/pkg/aa/resolve.go @@ -0,0 +1,180 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2024 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package aa + +import ( + "fmt" + "regexp" + "strings" + + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/util" +) + +var ( + includeCache map[*Include]*AppArmorProfileFile = make(map[*Include]*AppArmorProfileFile) + + regVariableReference = regexp.MustCompile(`@{([^{}]+)}`) +) + +// Resolve resolves variables and includes definied in the profile preamble +func (f *AppArmorProfileFile) Resolve() error { + // Resolve preamble includes + // for _, include := range f.Preamble.GetIncludes() { + // err := f.resolveInclude(include) + // if err != nil { + // return err + // } + // } + + // Append value to variable + seen := map[string]*Variable{} + for idx, variable := range f.Preamble.GetVariables() { + if _, ok := seen[variable.Name]; ok { + if variable.Define { + return fmt.Errorf("variable %s already defined", variable.Name) + } + seen[variable.Name].Values = append(seen[variable.Name].Values, variable.Values...) + f.Preamble = f.Preamble.Delete(idx) + } + if variable.Define { + seen[variable.Name] = variable + } + } + + // Resolve variables + for _, variable := range f.Preamble.GetVariables() { + newValues := []string{} + for _, value := range variable.Values { + vars, err := f.resolveValues(value) + if err != nil { + return err + } + newValues = append(newValues, vars...) + } + variable.Values = newValues + } + + // Resolve variables in attachements + for _, profile := range f.Profiles { + attachments := []string{} + for _, att := range profile.Attachments { + vars, err := f.resolveValues(att) + if err != nil { + return err + } + attachments = append(attachments, vars...) + } + profile.Attachments = attachments + } + + return nil +} + +func (f *AppArmorProfileFile) resolveValues(input string) ([]string, error) { + if !strings.Contains(input, VARIABLE.Tok()) { + return []string{input}, nil + } + + values := []string{} + match := regVariableReference.FindStringSubmatch(input) + if len(match) == 0 { + return nil, fmt.Errorf("Invalid variable reference: %s", input) + } + + variable := match[0] + varname := match[1] + found := false + for _, vrbl := range f.Preamble.GetVariables() { + if vrbl.Name == varname { + found = true + for _, v := range vrbl.Values { + if strings.Contains(v, VARIABLE.Tok()+varname+"}") { + return nil, fmt.Errorf("recursive variable found in: %s", varname) + } + newValues := strings.ReplaceAll(input, variable, v) + newValues = strings.ReplaceAll(newValues, "//", "/") + res, err := f.resolveValues(newValues) + if err != nil { + return nil, err + } + values = append(values, res...) + } + } + } + + if !found { + return nil, fmt.Errorf("Variable %s not defined", varname) + } + return values, nil +} + +// resolveInclude resolves all includes defined in the profile preamble +func (f *AppArmorProfileFile) resolveInclude(include *Include) error { + if include == nil || include.Path == "" { + return fmt.Errorf("Invalid include: %v", include) + } + + _, isCached := includeCache[include] + if !isCached { + var files paths.PathList + var err error + + path := MagicRoot.Join(include.Path) + if !include.IsMagic { + path = paths.New(include.Path) + } + + if path.IsDir() { + files, err = path.ReadDir(paths.FilterOutDirectories()) + if err != nil { + if include.IfExists { + return nil + } + return fmt.Errorf("File %s not found: %v", path, err) + } + + } else if path.Exist() { + files = append(files, path) + + } else { + if include.IfExists { + return nil + } + return fmt.Errorf("File %s not found", path) + + } + + iFile := &AppArmorProfileFile{} + for _, file := range files { + raw, err := util.ReadFile(file) + if err != nil { + return err + } + if err := iFile.Parse(raw); err != nil { + return err + } + } + if err := iFile.Validate(); err != nil { + return err + } + for _, inc := range iFile.Preamble.GetIncludes() { + if err := iFile.resolveInclude(inc); err != nil { + return err + } + } + + // Remove all includes in iFile + iFile.Preamble = iFile.Preamble.DeleteKind(INCLUDE) + + // Cache the included file + includeCache[include] = iFile + } + + // Insert iFile in the place of include in the current file + index := f.Preamble.Index(include) + f.Preamble = f.Preamble.Replace(index, includeCache[include].Preamble...) + return nil +} diff --git a/pkg/aa/resolve_test.go b/pkg/aa/resolve_test.go new file mode 100644 index 00000000..52168cc7 --- /dev/null +++ b/pkg/aa/resolve_test.go @@ -0,0 +1,273 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2024 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package aa + +import ( + "reflect" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" +) + +func TestAppArmorProfileFile_resolveInclude(t *testing.T) { + tests := []struct { + name string + include *Include + want *AppArmorProfileFile + wantErr bool + }{ + { + name: "empty", + include: &Include{Path: "", IsMagic: true}, + want: &AppArmorProfileFile{Preamble: Rules{&Include{Path: "", IsMagic: true}}}, + wantErr: true, + }, + { + name: "tunables", + include: &Include{Path: "tunables/global", IsMagic: true}, + want: &AppArmorProfileFile{ + Preamble: Rules{ + &Alias{Path: "/usr/", RewrittenPath: "/User/"}, + &Alias{Path: "/lib/", RewrittenPath: "/Libraries/"}, + &Comment{RuleBase: RuleBase{IsLineRule: true, Comment: " variable declarations for inclusion"}}, + &Variable{ + Name: "FOO", Define: true, + Values: []string{ + "/foo", "/bar", "/baz", "/biff", "/lib", "/tmp", + }, + }, + }, + }, + wantErr: false, + }, + } + MagicRoot = paths.New("../../tests/testdata") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := &AppArmorProfileFile{} + got.Preamble = append(got.Preamble, tt.include) + if err := got.resolveInclude(tt.include); (err != nil) != tt.wantErr { + t.Errorf("AppArmorProfileFile.resolveInclude() error = %v, wantErr %v", err, tt.wantErr) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("AppArmorProfileFile.resolveValues() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAppArmorProfileFile_resolveValues(t *testing.T) { + tests := []struct { + name string + input string + want []string + wantErr bool + }{ + { + name: "not-defined", + input: "@{newvar}", + want: nil, + wantErr: true, + }, + { + name: "no-name", + input: "@{}", + want: nil, + wantErr: true, + }, + { + name: "default", + input: "@{etc_ro}", + want: []string{"/{,usr/}etc/"}, + }, + { + name: "simple", + input: "@{bin}/foo", + want: []string{"/{,usr/}{,s}bin/foo"}, + }, + { + name: "double", + input: "@{lib}/@{multiarch}", + want: []string{"/{,usr/}lib{,exec,32,64}/*-linux-gnu*"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := DefaultTunables() + got, err := f.resolveValues(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("AppArmorProfileFile.resolveValues() error = %v, wantErr %v", err, tt.wantErr) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("AppArmorProfileFile.resolveValues() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAppArmorProfileFile_Resolve(t *testing.T) { + tests := []struct { + name string + preamble Rules + attachements []string + want *AppArmorProfileFile + wantErr bool + }{ + { + name: "variables/append", + preamble: Rules{ + &Variable{Name: "lib", Values: []string{"/{usr/,}lib"}, Define: true}, + &Variable{Name: "multiarch", Values: []string{"*-linux-gnu*"}, Define: true}, + &Variable{Name: "exec_path", Values: []string{"@{lib}/DiscoverNotifier"}, Define: true}, + &Variable{Name: "exec_path", Values: []string{"@{lib}/@{multiarch}/{,libexec/}DiscoverNotifier"}, Define: false}, + }, + want: &AppArmorProfileFile{ + Preamble: Rules{ + &Variable{Name: "lib", Values: []string{"/{usr/,}lib"}, Define: true}, + &Variable{Name: "multiarch", Values: []string{"*-linux-gnu*"}, Define: true}, + &Variable{ + Name: "exec_path", Define: true, + Values: []string{ + "/{usr/,}lib/DiscoverNotifier", + "/{usr/,}lib/*-linux-gnu*/{,libexec/}DiscoverNotifier", + }, + }, + }, + }, + wantErr: false, + }, + { + name: "attachment/firefox", + preamble: Rules{ + &Variable{Name: "firefox_name", Values: []string{"firefox{,-esr,-bin}"}, Define: true}, + &Variable{Name: "firefox_lib_dirs", Values: []string{"/{usr/,}/lib{,32,64}/@{firefox_name}", "/opt/@{firefox_name}"}, Define: true}, + &Variable{Name: "exec_path", Values: []string{"/{usr/,}bin/@{firefox_name}", "@{firefox_lib_dirs}/@{firefox_name}"}, Define: true}, + }, + attachements: []string{"@{exec_path}"}, + want: &AppArmorProfileFile{ + Preamble: Rules{ + &Variable{Name: "firefox_name", Values: []string{"firefox{,-esr,-bin}"}, Define: true}, + &Variable{ + Name: "firefox_lib_dirs", Define: true, + Values: []string{ + "/{usr/,}/lib{,32,64}/firefox{,-esr,-bin}", + "/opt/firefox{,-esr,-bin}", + }, + }, + &Variable{ + Name: "exec_path", Define: true, + Values: []string{ + "/{usr/,}bin/firefox{,-esr,-bin}", + "/{usr/,}/lib{,32,64}/firefox{,-esr,-bin}/firefox{,-esr,-bin}", + "/opt/firefox{,-esr,-bin}/firefox{,-esr,-bin}", + }, + }, + }, + Profiles: []*Profile{ + {Header: Header{ + Attachments: []string{ + "/{usr/,}bin/firefox{,-esr,-bin}", + "/{usr/,}/lib{,32,64}/firefox{,-esr,-bin}/firefox{,-esr,-bin}", + "/opt/firefox{,-esr,-bin}/firefox{,-esr,-bin}", + }, + }}, + }, + }, + wantErr: false, + }, + { + name: "attachment/chromium", + preamble: Rules{ + &Variable{Name: "name", Values: []string{"chromium"}, Define: true}, + &Variable{Name: "lib_dirs", Values: []string{"/{usr/,}lib/@{name}"}, Define: true}, + &Variable{Name: "path", Values: []string{"@{lib_dirs}/@{name}"}, Define: true}, + }, + attachements: []string{"@{path}/pass"}, + want: &AppArmorProfileFile{ + Preamble: Rules{ + &Variable{Name: "name", Values: []string{"chromium"}, Define: true}, + &Variable{Name: "lib_dirs", Values: []string{"/{usr/,}lib/chromium"}, Define: true}, + &Variable{Name: "path", Values: []string{"/{usr/,}lib/chromium/chromium"}, Define: true}, + }, + Profiles: []*Profile{ + {Header: Header{ + Attachments: []string{"/{usr/,}lib/chromium/chromium/pass"}, + }}, + }, + }, + wantErr: false, + }, + { + name: "attachment/geoclue", + preamble: Rules{ + &Variable{Name: "libexec", Values: []string{"/{usr/,}libexec"}, Define: true}, + &Variable{Name: "exec_path", Values: []string{"@{libexec}/geoclue", "@{libexec}/geoclue-2.0/demos/agent"}, Define: true}, + }, + attachements: []string{"@{exec_path}"}, + want: &AppArmorProfileFile{ + Preamble: Rules{ + &Variable{Name: "libexec", Values: []string{"/{usr/,}libexec"}, Define: true}, + &Variable{ + Name: "exec_path", Define: true, + Values: []string{ + "/{usr/,}libexec/geoclue", + "/{usr/,}libexec/geoclue-2.0/demos/agent", + }, + }, + }, + Profiles: []*Profile{ + {Header: Header{ + Attachments: []string{ + "/{usr/,}libexec/geoclue", + "/{usr/,}libexec/geoclue-2.0/demos/agent", + }, + }}, + }, + }, + wantErr: false, + }, + { + name: "attachment/opera", + preamble: Rules{ + &Variable{Name: "multiarch", Values: []string{"*-linux-gnu*"}, Define: true}, + &Variable{Name: "name", Values: []string{"opera{,-beta,-developer}"}, Define: true}, + &Variable{Name: "lib_dirs", Values: []string{"/{usr/,}lib/@{multiarch}/@{name}"}, Define: true}, + &Variable{Name: "exec_path", Values: []string{"@{lib_dirs}/@{name}"}, Define: true}, + }, + attachements: []string{"@{exec_path}"}, + want: &AppArmorProfileFile{ + Preamble: Rules{ + &Variable{Name: "multiarch", Values: []string{"*-linux-gnu*"}, Define: true}, + &Variable{Name: "name", Values: []string{"opera{,-beta,-developer}"}, Define: true}, + &Variable{Name: "lib_dirs", Values: []string{"/{usr/,}lib/*-linux-gnu*/opera{,-beta,-developer}"}, Define: true}, + &Variable{Name: "exec_path", Values: []string{"/{usr/,}lib/*-linux-gnu*/opera{,-beta,-developer}/opera{,-beta,-developer}"}, Define: true}, + }, + Profiles: []*Profile{ + {Header: Header{ + Attachments: []string{ + "/{usr/,}lib/*-linux-gnu*/opera{,-beta,-developer}/opera{,-beta,-developer}", + }, + }}, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := &AppArmorProfileFile{Preamble: tt.preamble} + if tt.attachements != nil { + got.Profiles = append(got.Profiles, &Profile{Header: Header{Attachments: tt.attachements}}) + } + + if err := got.Resolve(); (err != nil) != tt.wantErr { + t.Errorf("AppArmorProfileFile.Resolve() error = %v, wantErr %v", err, tt.wantErr) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("AppArmorProfile.Resolve() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/aa/rlimit.go b/pkg/aa/rlimit.go index 7c2d1231..ddb70710 100644 --- a/pkg/aa/rlimit.go +++ b/pkg/aa/rlimit.go @@ -4,24 +4,69 @@ package aa +import "fmt" + +const ( + RLIMIT Kind = "rlimit" +) + +func init() { + requirements[RLIMIT] = requirement{ + "keys": { + "cpu", "fsize", "data", "stack", "core", "rss", "nofile", "ofile", + "as", "nproc", "memlock", "locks", "sigpending", "msgqueue", "nice", + "rtprio", "rttime", + }, + } +} + type Rlimit struct { + RuleBase Key string Op string Value string } +func newRlimitFromLog(log map[string]string) Rule { + return &Rlimit{ + RuleBase: newRuleFromLog(log), + Key: log["key"], + Op: log["op"], + Value: log["value"], + } +} + +func (r *Rlimit) Validate() error { + if err := validateValues(r.Kind(), "keys", []string{r.Key}); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + return nil +} + func (r *Rlimit) Less(other any) bool { o, _ := other.(*Rlimit) - if r.Key == o.Key { - if r.Op == o.Op { - return r.Value < o.Value - } + if r.Key != o.Key { + return r.Key < o.Key + } + if r.Op != o.Op { return r.Op < o.Op } - return r.Key < o.Key + return r.Value < o.Value } func (r *Rlimit) Equals(other any) bool { o, _ := other.(*Rlimit) return r.Key == o.Key && r.Op == o.Op && r.Value == o.Value } + +func (r *Rlimit) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Rlimit) Constraint() constraint { + return blockKind +} + +func (r *Rlimit) Kind() Kind { + return RLIMIT +} diff --git a/pkg/aa/rules.go b/pkg/aa/rules.go index c2702148..7aeb9752 100644 --- a/pkg/aa/rules.go +++ b/pkg/aa/rules.go @@ -5,133 +5,233 @@ package aa import ( - "strings" + "slices" ) -type Rule struct { - Comment string - NoNewPrivs bool - FileInherit bool +type requirement map[string][]string + +type constraint uint + +const ( + anyKind constraint = iota // The rule can be found in either preamble or profile + preambleKind // The rule can only be found in the preamble + blockKind // The rule can only be found in a profile +) + +// Kind represents an AppArmor rule kind. +type Kind string + +func (k Kind) String() string { + return string(k) } -func (r *Rule) Less(other any) bool { - return false -} - -func (r *Rule) Equals(other any) bool { - return false -} - -// Qualifier to apply extra settings to a rule -type Qualifier struct { - Audit bool - AccessType string - Owner bool - NoNewPrivs bool - FileInherit bool - Optional bool - Comment string - Prefix string - Padding string -} - -func NewQualifierFromLog(log map[string]string) Qualifier { - owner := false - fsuid, hasFsUID := log["fsuid"] - ouid, hasOuUID := log["ouid"] - isDbus := strings.Contains(log["operation"], "dbus") - if hasFsUID && hasOuUID && fsuid == ouid && ouid != "0" && !isDbus { - owner = true +func (k Kind) Tok() string { + if t, ok := tok[k]; ok { + return t } + return string(k) +} - audit := false - if log["apparmor"] == "AUDIT" { - audit = true - } +// Rule generic interface for all AppArmor rules +type Rule interface { + Validate() error + Less(other any) bool + Equals(other any) bool + String() string + Constraint() constraint + Kind() Kind +} - fileInherit := false - if log["operation"] == "file_inherit" { - fileInherit = true - } +type Rules []Rule - noNewPrivs := false - optional := false - msg := "" - switch log["error"] { - case "-1": - if strings.Contains(log["info"], "optional:") { - optional = true - msg = strings.Replace(log["info"], "optional: ", "", 1) - } else { - noNewPrivs = true +func (r Rules) Validate() error { + for _, rule := range r { + if rule == nil { + continue } - case "-13": - ignoreProfileInfo := []string{"namespace", "disconnected path"} - for _, info := range ignoreProfileInfo { - if strings.Contains(log["info"], info) { - break + if err := rule.Validate(); err != nil { + return err + } + } + return nil +} + +func (r Rules) String() string { + return renderTemplate("rules", r) +} + +// Index returns the index of the first occurrence of rule rin r, or -1 if not present. +func (r Rules) Index(item Rule) int { + for idx, rule := range r { + if rule == nil { + continue + } + if rule.Kind() == item.Kind() && rule.Equals(item) { + return idx + } + } + return -1 +} + +// Replace replaces the elements r[i] by the given rules, and returns the +// modified slice. +func (r Rules) Replace(i int, rules ...Rule) Rules { + return append(r[:i], append(rules, r[i+1:]...)...) +} + +// Insert inserts the rules into r at index i, returning the modified slice. +func (r Rules) Insert(i int, rules ...Rule) Rules { + return append(r[:i], append(rules, r[i:]...)...) +} + +// Delete removes the elements r[i] from r, returning the modified slice. +func (r Rules) Delete(i int) Rules { + return append(r[:i], r[i+1:]...) +} + +func (r Rules) DeleteKind(kind Kind) Rules { + res := make(Rules, 0) + for _, rule := range r { + if rule == nil { + continue + } + if rule.Kind() != kind { + res = append(res, rule) + } + } + return res +} + +func (r Rules) Filter(filter Kind) Rules { + res := make(Rules, 0) + for _, rule := range r { + if rule == nil { + continue + } + if rule.Kind() != filter { + res = append(res, rule) + } + } + return res +} + +func (r Rules) GetVariables() []*Variable { + res := make([]*Variable, 0) + for _, rule := range r { + switch rule := rule.(type) { + case *Variable: + res = append(res, rule) + } + } + return res +} + +func (r Rules) GetIncludes() []*Include { + res := make([]*Include, 0) + for _, rule := range r { + switch rule := rule.(type) { + case *Include: + res = append(res, rule) + } + } + return res +} + +// Merge merge similar rules together. +// Steps: +// - Remove identical rules +// - Merge rule access. Eg: for same path, 'r' and 'w' becomes 'rw' +// +// Note: logs.regCleanLogs helps a lot to do a first cleaning +func (r Rules) Merge() Rules { + for i := 0; i < len(r); i++ { + for j := i + 1; j < len(r); j++ { + typeOfI := r[i].Kind() + typeOfJ := r[j].Kind() + if typeOfI != typeOfJ { + continue + } + + // If rules are identical, merge them + if r[i].Equals(r[j]) { + r = r.Delete(j) + j-- + continue + } + + // File rule + if typeOfI == FILE && typeOfJ == FILE { + // Merge access + fileI := r[i].(*File) + fileJ := r[j].(*File) + if fileI.Path == fileJ.Path { + fileI.Access = append(fileI.Access, fileJ.Access...) + slices.SortFunc(fileI.Access, cmpFileAccess) + fileI.Access = slices.Compact(fileI.Access) + r = r.Delete(j) + j-- + } } } - msg = log["info"] - default: - } - - return Qualifier{ - Audit: audit, - Owner: owner, - NoNewPrivs: noNewPrivs, - FileInherit: fileInherit, - Optional: optional, - Comment: msg, } + return r } -func (r Qualifier) Less(other Qualifier) bool { - if r.Owner == other.Owner { - if r.Audit == other.Audit { - return r.AccessType < other.AccessType +// Sort the rules according to the guidelines: +// https://apparmor.pujol.io/development/guidelines/#guidelines +func (r Rules) Sort() Rules { + slices.SortFunc(r, func(a, b Rule) int { + kindOfA := a.Kind() + kindOfB := b.Kind() + if kindOfA != kindOfB { + if kindOfA == INCLUDE && a.(*Include).IfExists { + kindOfA = "include_if_exists" + } + if kindOfB == INCLUDE && b.(*Include).IfExists { + kindOfB = "include_if_exists" + } + return ruleWeights[kindOfA] - ruleWeights[kindOfB] + } + if a.Equals(b) { + return 0 + } + if a.Less(b) { + return -1 + } + return 1 + }) + return r +} + +// Format the rules for better readability before printing it. +// Follow: https://apparmor.pujol.io/development/guidelines/#the-file-block +func (r Rules) Format() Rules { + const prefixOwner = " " + + hasOwnerRule := false + for i := len(r) - 1; i > 0; i-- { + j := i - 1 + typeOfI := r[i].Kind() + typeOfJ := r[j].Kind() + + // File rule + if typeOfI == FILE && typeOfJ == FILE { + letterI := getLetterIn(fileAlphabet, r[i].(*File).Path) + letterJ := getLetterIn(fileAlphabet, r[j].(*File).Path) + + // Add prefix before rule path to align with other rule + if r[i].(*File).Owner { + hasOwnerRule = true + } else if hasOwnerRule { + r[i].(*File).Prefix = prefixOwner + } + + if letterI != letterJ { + // Add a new empty line between Files rule of different type + hasOwnerRule = false + r = r.Insert(i, nil) + } } - return r.Audit } - return other.Owner -} - -func (r Qualifier) Equals(other Qualifier) bool { - return r.Audit == other.Audit && r.AccessType == other.AccessType && - r.Owner == other.Owner && r.NoNewPrivs == other.NoNewPrivs && - r.FileInherit == other.FileInherit -} - -// Preamble specific rules - -type Abi struct { - Path string - IsMagic bool -} - -func (r Abi) Less(other Abi) bool { - if r.Path == other.Path { - return r.IsMagic == other.IsMagic - } - return r.Path < other.Path -} - -func (r Abi) Equals(other Abi) bool { - return r.Path == other.Path && r.IsMagic == other.IsMagic -} - -type Alias struct { - Path string - RewrittenPath string -} - -func (r Alias) Less(other Alias) bool { - if r.Path == other.Path { - return r.RewrittenPath < other.RewrittenPath - } - return r.Path < other.Path -} - -func (r Alias) Equals(other Alias) bool { - return r.Path == other.Path && r.RewrittenPath == other.RewrittenPath + return r } diff --git a/pkg/aa/rules_test.go b/pkg/aa/rules_test.go index 0699f123..2b944005 100644 --- a/pkg/aa/rules_test.go +++ b/pkg/aa/rules_test.go @@ -9,361 +9,455 @@ import ( "testing" ) -func TestRule_FromLog(t *testing.T) { - tests := []struct { - name string - fromLog func(map[string]string) ApparmorRule - log map[string]string - want ApparmorRule - }{ - { - name: "capbability", - fromLog: CapabilityFromLog, - log: capability1Log, - want: capability1, - }, - { - name: "network", - fromLog: NetworkFromLog, - log: network1Log, - want: network1, - }, - { - name: "mount", - fromLog: MountFromLog, - log: mount1Log, - want: mount1, - }, - { - name: "umount", - fromLog: UmountFromLog, - log: umount1Log, - want: umount1, - }, - { - name: "pivotroot", - fromLog: PivotRootFromLog, - log: pivotroot1Log, - want: pivotroot1, - }, - { - name: "changeprofile", - fromLog: ChangeProfileFromLog, - log: changeprofile1Log, - want: changeprofile1, - }, - { - name: "signal", - fromLog: SignalFromLog, - log: signal1Log, - want: signal1, - }, - { - name: "ptrace/xdg-document-portal", - fromLog: PtraceFromLog, - log: ptrace1Log, - want: ptrace1, - }, - { - name: "ptrace/snap-update-ns.firefox", - fromLog: PtraceFromLog, - log: ptrace2Log, - want: ptrace2, - }, - { - name: "unix", - fromLog: UnixFromLog, - log: unix1Log, - want: unix1, - }, - { - name: "dbus", - fromLog: DbusFromLog, - log: dbus1Log, - want: dbus1, - }, - { - name: "file", - fromLog: FileFromLog, - log: file1Log, - want: file1, - }, - } - for _, tt := range tests { +func TestRules_FromLog(t *testing.T) { + for _, tt := range testRule { + if tt.fromLog == nil { + continue + } t.Run(tt.name, func(t *testing.T) { - if got := tt.fromLog(tt.log); !reflect.DeepEqual(got, tt.want) { - t.Errorf("RuleFromLog() = %v, want %v", got, tt.want) + if got := tt.fromLog(tt.log); !reflect.DeepEqual(got, tt.rule) { + t.Errorf("RuleFromLog() = %v, want %v", got, tt.rule) } }) } } -func TestRule_Less(t *testing.T) { - tests := []struct { - name string - rule ApparmorRule - other ApparmorRule - want bool - }{ - { - name: "include1", - rule: include1, - other: includeLocal1, - want: true, - }, - { - name: "include2", - rule: include1, - other: include2, - want: true, - }, - { - name: "include3", - rule: include1, - other: include3, - want: false, - }, - { - name: "rlimit", - rule: rlimit1, - other: rlimit2, - want: false, - }, - { - name: "rlimit2", - rule: rlimit2, - other: rlimit2, - want: false, - }, - { - name: "rlimit3", - rule: rlimit1, - other: rlimit3, - want: false, - }, - { - name: "capability", - rule: capability1, - other: capability2, - want: true, - }, - { - name: "network", - rule: network1, - other: network2, - want: false, - }, - { - name: "mount", - rule: mount1, - other: mount2, - want: false, - }, - { - name: "umount", - rule: umount1, - other: umount2, - want: true, - }, - { - name: "pivot_root1", - rule: pivotroot2, - other: pivotroot1, - want: true, - }, - { - name: "pivot_root2", - rule: pivotroot1, - other: pivotroot3, - want: false, - }, - { - name: "change_profile1", - rule: changeprofile1, - other: changeprofile2, - want: false, - }, - { - name: "change_profile2", - rule: changeprofile1, - other: changeprofile3, - want: true, - }, - { - name: "signal", - rule: signal1, - other: signal2, - want: true, - }, - { - name: "ptrace/less", - rule: ptrace1, - other: ptrace2, - want: true, - }, - { - name: "ptrace/more", - rule: ptrace2, - other: ptrace1, - want: false, - }, - { - name: "unix", - rule: unix1, - other: unix1, - want: false, - }, - { - name: "dbus", - rule: dbus1, - other: dbus1, - want: false, - }, - { - name: "dbus2", - rule: dbus2, - other: dbus3, - want: false, - }, - { - name: "file", - rule: file1, - other: file2, - want: true, - }, - { - name: "file/empty", - rule: &File{}, - other: &File{}, - want: false, - }, - { - name: "file/equal", - rule: &File{Path: "/usr/share/poppler/cMap/Identity-H"}, - other: &File{Path: "/usr/share/poppler/cMap/Identity-H"}, - want: false, - }, - { - name: "file/owner", - rule: &File{Path: "/usr/share/poppler/cMap/Identity-H", Qualifier: Qualifier{Owner: true}}, - other: &File{Path: "/usr/share/poppler/cMap/Identity-H"}, - want: false, - }, - { - name: "file/access", - rule: &File{Path: "/usr/share/poppler/cMap/Identity-H", Access: "r"}, - other: &File{Path: "/usr/share/poppler/cMap/Identity-H", Access: "w"}, - want: true, - }, - { - name: "file/close", - rule: &File{Path: "/usr/share/poppler/cMap/"}, - other: &File{Path: "/usr/share/poppler/cMap/Identity-H"}, - want: true, - }, - } - for _, tt := range tests { +func TestRules_Validate(t *testing.T) { + for _, tt := range testRule { t.Run(tt.name, func(t *testing.T) { - r := tt.rule - if got := r.Less(tt.other); got != tt.want { - t.Errorf("Rule.Less() = %v, want %v", got, tt.want) + if err := tt.rule.Validate(); (err != nil) != tt.wValidErr { + t.Errorf("Rules.Validate() error = %v, wantErr %v", err, tt.wValidErr) } }) } } -func TestRule_Equals(t *testing.T) { - tests := []struct { - name string - rule ApparmorRule - other ApparmorRule - want bool - }{ - { - name: "include1", - rule: include1, - other: includeLocal1, - want: false, - }, - { - name: "rlimit", - rule: rlimit1, - other: rlimit1, - want: true, - }, - { - name: "capability/equal", - rule: capability1, - other: capability1, - want: true, - }, - { - name: "network/equal", - rule: network1, - other: network1, - want: true, - }, - { - name: "mount", - rule: mount1, - other: mount1, - want: true, - }, - { - name: "pivot_root", - rule: pivotroot1, - other: pivotroot2, - want: false, - }, - { - name: "change_profile", - rule: changeprofile1, - other: changeprofile2, - want: false, - }, - { - name: "signal1/equal", - rule: signal1, - other: signal1, - want: true, - }, - { - name: "ptrace/equal", - rule: ptrace1, - other: ptrace1, - want: true, - }, - { - name: "ptrace/not_equal", - rule: ptrace1, - other: ptrace2, - want: false, - }, - { - name: "unix", - rule: unix1, - other: unix1, - want: true, - }, - { - name: "dbus", - rule: dbus1, - other: dbus2, - want: false, - }, - { - name: "file", - rule: file2, - other: file2, - want: true, - }, - } - for _, tt := range tests { +func TestRules_Less(t *testing.T) { + for _, tt := range testRule { + if tt.oLess == nil { + continue + } t.Run(tt.name, func(t *testing.T) { - r := tt.rule - if got := r.Equals(tt.other); got != tt.want { - t.Errorf("Rule.Equals() = %v, want %v", got, tt.want) + if got := tt.rule.Less(tt.oLess); got != tt.wLessErr { + t.Errorf("Rule.Less() = %v, want %v", got, tt.wLessErr) } }) } } + +func TestRules_Equals(t *testing.T) { + for _, tt := range testRule { + if tt.oEqual == nil { + continue + } + t.Run(tt.name, func(t *testing.T) { + r := tt.rule + if got := r.Equals(tt.oEqual); got != tt.wEqualErr { + t.Errorf("Rule.Equals() = %v, want %v", got, tt.wEqualErr) + } + }) + } +} + +func TestRules_String(t *testing.T) { + for _, tt := range testRule { + t.Run(tt.name, func(t *testing.T) { + if got := tt.rule.String(); got != tt.wString { + t.Errorf("Rule.String() = %v, want %v", got, tt.wString) + } + }) + } +} + +var ( + // Test cases for the Rule interface + testRule = []struct { + name string + fromLog func(map[string]string) Rule + log map[string]string + rule Rule + wValidErr bool + oLess Rule + wLessErr bool + oEqual Rule + wEqualErr bool + wString string + }{ + { + name: "comment", + rule: comment1, + oLess: comment2, + wLessErr: false, + oEqual: comment2, + wEqualErr: false, + wString: "#comment", + }, + { + name: "abi", + rule: abi1, + oLess: abi2, + wLessErr: false, + oEqual: abi1, + wEqualErr: true, + wString: "abi ,", + }, + { + name: "alias", + rule: alias1, + oLess: alias2, + wLessErr: true, + oEqual: alias2, + wEqualErr: false, + wString: "alias /mnt/usr -> /usr,", + }, + { + name: "include1", + rule: include1, + oLess: includeLocal1, + wLessErr: false, + oEqual: includeLocal1, + wEqualErr: false, + wString: "include ", + }, + { + name: "include2", + rule: include1, + oLess: include2, + wLessErr: false, + wString: "include ", + }, + { + name: "include-local", + rule: includeLocal1, + oLess: include1, + wLessErr: true, + wString: "include if exists ", + }, + { + name: "include/abs", + rule: &Include{Path: "/usr/share/apparmor.d/", IsMagic: false}, + wString: `include "/usr/share/apparmor.d/"`, + }, + { + name: "variable", + rule: variable1, + oLess: variable2, + wLessErr: true, + oEqual: variable1, + wEqualErr: true, + wString: "@{bin} = /{,usr/}{,s}bin", + }, + { + name: "all", + rule: all1, + oLess: all2, + wLessErr: false, + oEqual: all2, + wEqualErr: false, + wString: "all,", + }, + { + name: "rlimit", + rule: rlimit1, + oLess: rlimit2, + wLessErr: false, + oEqual: rlimit1, + wEqualErr: true, + wString: "set rlimit nproc <= 200,", + }, + { + name: "rlimit2", + rule: rlimit2, + oLess: rlimit2, + wLessErr: false, + wString: "set rlimit cpu <= 2,", + }, + { + name: "rlimit3", + rule: rlimit3, + oLess: rlimit1, + wLessErr: true, + + wString: "set rlimit nproc < 2,", + }, + { + name: "userns", + rule: userns1, + oLess: userns2, + wLessErr: true, + oEqual: userns1, + wEqualErr: true, + wString: "userns,", + }, + { + name: "capbability", + fromLog: newCapabilityFromLog, + log: capability1Log, + rule: capability1, + oLess: capability2, + wLessErr: true, + oEqual: capability1, + wEqualErr: true, + wString: "capability net_admin,", + }, + { + name: "capability/multi", + rule: &Capability{Names: []string{"dac_override", "dac_read_search"}}, + wString: "capability dac_override dac_read_search,", + }, + { + name: "capability/all", + rule: &Capability{}, + wString: "capability,", + }, + { + name: "network", + fromLog: newNetworkFromLog, + log: network1Log, + rule: network1, + wValidErr: true, + oLess: network2, + wLessErr: false, + oEqual: network1, + wEqualErr: true, + wString: "network netlink raw,", + }, + { + name: "mount", + fromLog: newMountFromLog, + log: mount1Log, + rule: mount1, + oEqual: mount2, + wEqualErr: false, + wString: "mount fstype=overlay overlay -> /var/lib/docker/overlay2/opaque-bug-check1209538631/merged/, # failed perms check", + }, + { + name: "remount", + rule: remount1, + oLess: remount2, + wLessErr: true, + oEqual: remount1, + wEqualErr: true, + wString: "remount /,", + }, + { + name: "umount", + fromLog: newUmountFromLog, + log: umount1Log, + rule: umount1, + oLess: umount2, + wLessErr: true, + oEqual: umount1, + wEqualErr: true, + wString: "umount /,", + }, + { + name: "pivot_root1", + fromLog: newPivotRootFromLog, + log: pivotroot1Log, + rule: pivotroot1, + oLess: pivotroot2, + wLessErr: false, + oEqual: pivotroot2, + wEqualErr: false, + wString: "pivot_root oldroot=@{run}/systemd/mount-rootfs/ @{run}/systemd/mount-rootfs/,", + }, + { + name: "pivot_root2", + rule: pivotroot1, + oLess: pivotroot3, + wLessErr: false, + wString: "pivot_root oldroot=@{run}/systemd/mount-rootfs/ @{run}/systemd/mount-rootfs/,", + }, + { + name: "change_profile1", + fromLog: newChangeProfileFromLog, + log: changeprofile1Log, + rule: changeprofile1, + oLess: changeprofile2, + wLessErr: false, + wString: "change_profile -> systemd-user,", + }, + { + name: "change_profile2", + rule: changeprofile2, + oLess: changeprofile3, + wLessErr: true, + oEqual: changeprofile1, + wEqualErr: false, + wString: "change_profile -> brwap,", + }, + { + name: "mqueue", + rule: mqueue1, + oLess: mqueue2, + wLessErr: true, + oEqual: mqueue1, + wEqualErr: true, + wString: "mqueue r type=posix /,", + }, + { + name: "iouring", + rule: iouring1, + oLess: iouring2, + wLessErr: false, + oEqual: iouring2, + wEqualErr: false, + wString: "io_uring sqpoll label=foo,", + }, + { + name: "signal", + fromLog: newSignalFromLog, + log: signal1Log, + rule: signal1, + oLess: signal2, + wLessErr: false, + oEqual: signal1, + wEqualErr: true, + wString: "signal receive set=kill peer=firefox//&firejail-default,", + }, + { + name: "ptrace/xdg-document-portal", + fromLog: newPtraceFromLog, + log: ptrace1Log, + rule: ptrace1, + oLess: ptrace2, + wLessErr: false, + oEqual: ptrace1, + wEqualErr: true, + wString: "ptrace read peer=nautilus,", + }, + { + name: "ptrace/snap-update-ns.firefox", + fromLog: newPtraceFromLog, + log: ptrace2Log, + rule: ptrace2, + oLess: ptrace1, + wLessErr: false, + oEqual: ptrace1, + wEqualErr: false, + wString: "ptrace readby peer=systemd-journald,", + }, + { + name: "unix", + fromLog: newUnixFromLog, + log: unix1Log, + rule: unix1, + oLess: unix1, + wLessErr: false, + oEqual: unix1, + wEqualErr: true, + wString: "unix (send receive) type=stream protocol=0 addr=none peer=(label=dbus-daemon, addr=@/tmp/dbus-AaKMpxzC4k),", + }, + { + name: "dbus", + fromLog: newDbusFromLog, + log: dbus1Log, + rule: dbus1, + oLess: dbus1, + wLessErr: false, + oEqual: dbus2, + wEqualErr: false, + wString: "dbus receive bus=session path=/org/gtk/vfs/metadata\n interface=org.gtk.vfs.Metadata\n member=Remove\n peer=(name=:1.15, label=tracker-extract),", + }, + { + name: "dbus2", + rule: dbus2, + oLess: dbus3, + wLessErr: false, + wString: "dbus bind bus=session name=org.gnome.evolution.dataserver.Sources5,", + }, + { + name: "dbus/bind", + rule: &Dbus{Access: []string{"bind"}, Bus: "session", Name: "org.gnome.*"}, + wString: `dbus bind bus=session name=org.gnome.*,`, + }, + { + name: "dbus/full", + rule: &Dbus{Bus: "accessibility"}, + wString: `dbus bus=accessibility,`, + }, + { + name: "file", + fromLog: newFileFromLog, + log: file1Log, + rule: file1, + oLess: file2, + wLessErr: true, + oEqual: file2, + wEqualErr: false, + wString: "/usr/share/poppler/cMap/Identity-H r,", + }, + { + name: "file/empty", + rule: &File{}, + oLess: &File{}, + wLessErr: false, + wString: " ,", + }, + { + name: "file/equal", + rule: &File{Path: "/usr/share/poppler/cMap/Identity-H"}, + oLess: &File{Path: "/usr/share/poppler/cMap/Identity-H"}, + wLessErr: false, + wString: "/usr/share/poppler/cMap/Identity-H ,", + }, + { + name: "file/owner", + rule: &File{Path: "/usr/share/poppler/cMap/Identity-H", Owner: true}, + oLess: &File{Path: "/usr/share/poppler/cMap/Identity-H"}, + wLessErr: true, + wString: "owner /usr/share/poppler/cMap/Identity-H ,", + }, + { + name: "file/access", + rule: &File{Path: "/usr/share/poppler/cMap/Identity-H", Access: []string{"r"}}, + oLess: &File{Path: "/usr/share/poppler/cMap/Identity-H", Access: []string{"w"}}, + wLessErr: false, + wString: "/usr/share/poppler/cMap/Identity-H r,", + }, + { + name: "file/close", + rule: &File{Path: "/usr/share/poppler/cMap/"}, + oLess: &File{Path: "/usr/share/poppler/cMap/Identity-H"}, + wLessErr: true, + wString: "/usr/share/poppler/cMap/ ,", + }, + { + name: "link", + fromLog: newLinkFromLog, + log: link1Log, + rule: link1, + oLess: link2, + wLessErr: true, + oEqual: link3, + wEqualErr: false, + wString: "link /tmp/mkinitcpio.QDWtza/early@{lib}/firmware/i915/dg1_dmc_ver2_02.bin.zst -> /tmp/mkinitcpio.QDWtza/root@{lib}/firmware/i915/dg1_dmc_ver2_02.bin.zst,", + }, + { + name: "link", + fromLog: newFileFromLog, + log: link3Log, + rule: link3, + wString: "owner link @{user_config_dirs}/kiorc -> @{user_config_dirs}/#3954,", + }, + { + name: "profile", + rule: profile1, + oLess: profile2, + wLessErr: true, + oEqual: profile1, + wEqualErr: true, + wString: "profile sudo {\n}", + }, + { + name: "hat", + rule: hat1, + oLess: hat2, + wLessErr: false, + oEqual: hat1, + wEqualErr: true, + wString: "hat user {\n}", + }, + } +) diff --git a/pkg/aa/signal.go b/pkg/aa/signal.go index 3dbf9e16..4e7ce91c 100644 --- a/pkg/aa/signal.go +++ b/pkg/aa/signal.go @@ -4,38 +4,90 @@ package aa +import ( + "fmt" + "slices" +) + +const SIGNAL Kind = "signal" + +func init() { + requirements[SIGNAL] = requirement{ + "access": { + "r", "w", "rw", "read", "write", "send", "receive", + }, + "set": { + "hup", "int", "quit", "ill", "trap", "abrt", "bus", "fpe", + "kill", "usr1", "segv", "usr2", "pipe", "alrm", "term", "stkflt", + "chld", "cont", "stop", "stp", "ttin", "ttou", "urg", "xcpu", + "xfsz", "vtalrm", "prof", "winch", "io", "pwr", "sys", "emt", + "exists", "rtmin+0", "rtmin+1", "rtmin+2", "rtmin+3", "rtmin+4", + "rtmin+5", "rtmin+6", "rtmin+7", "rtmin+8", "rtmin+9", "rtmin+10", + "rtmin+11", "rtmin+12", "rtmin+13", "rtmin+14", "rtmin+15", + "rtmin+16", "rtmin+17", "rtmin+18", "rtmin+19", "rtmin+20", + "rtmin+21", "rtmin+22", "rtmin+23", "rtmin+24", "rtmin+25", + "rtmin+26", "rtmin+27", "rtmin+28", "rtmin+29", "rtmin+30", + "rtmin+31", "rtmin+32", + }, + } +} + type Signal struct { + RuleBase Qualifier - Access string - Set string + Access []string + Set []string Peer string } -func SignalFromLog(log map[string]string) ApparmorRule { +func newSignalFromLog(log map[string]string) Rule { return &Signal{ - Qualifier: NewQualifierFromLog(log), - Access: toAccess(log["requested_mask"]), - Set: log["signal"], + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), + Access: Must(toAccess(SIGNAL, log["requested_mask"])), + Set: []string{log["signal"]}, Peer: log["peer"], } } +func (r *Signal) Validate() error { + if err := validateValues(r.Kind(), "access", r.Access); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + if err := validateValues(r.Kind(), "set", r.Set); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + return nil +} + func (r *Signal) Less(other any) bool { o, _ := other.(*Signal) - if r.Qualifier.Equals(o.Qualifier) { - if r.Access == o.Access { - if r.Set == o.Set { - return r.Peer < o.Peer - } - return r.Set < o.Set - } - return r.Access < o.Access + if len(r.Access) != len(o.Access) { + return len(r.Access) < len(o.Access) + } + if len(r.Set) != len(o.Set) { + return len(r.Set) < len(o.Set) + } + if r.Peer != o.Peer { + return r.Peer < o.Peer } return r.Qualifier.Less(o.Qualifier) } func (r *Signal) Equals(other any) bool { o, _ := other.(*Signal) - return r.Access == o.Access && r.Set == o.Set && + return slices.Equal(r.Access, o.Access) && slices.Equal(r.Set, o.Set) && r.Peer == o.Peer && r.Qualifier.Equals(o.Qualifier) } + +func (r *Signal) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Signal) Constraint() constraint { + return blockKind +} + +func (r *Signal) Kind() Kind { + return SIGNAL +} diff --git a/pkg/aa/template.go b/pkg/aa/template.go index b5600286..aed36f37 100644 --- a/pkg/aa/template.go +++ b/pkg/aa/template.go @@ -6,67 +6,90 @@ package aa import ( "embed" + "fmt" "reflect" "strings" "text/template" ) -// Default indentation for apparmor profile (2 spaces) -const indentation = " " - var ( + // Default indentation for apparmor profile (2 spaces) + Indentation = " " + + // The current indentation level + IndentationLevel = 0 + //go:embed templates/*.j2 + //go:embed templates/rule/*.j2 tmplFiles embed.FS // The functions available in the template tmplFunctionMap = template.FuncMap{ - "typeof": typeOf, + "kindof": kindOf, "join": join, + "cjoin": cjoin, "indent": indent, "overindent": indentDbus, + "setindent": setindent, } - // The apparmor profile template - tmplAppArmorProfile = generateTemplate() + // The apparmor templates + tmpl = generateTemplates([]Kind{ + // Global templates + "apparmor", + PROFILE, + HAT, + "rules", + + // Preamble templates + ABI, + ALIAS, + INCLUDE, + VARIABLE, + COMMENT, + + // Rules templates + ALL, RLIMIT, USERNS, CAPABILITY, NETWORK, + MOUNT, REMOUNT, UMOUNT, PIVOTROOT, CHANGEPROFILE, + MQUEUE, IOURING, UNIX, PTRACE, SIGNAL, DBUS, + FILE, LINK, + }) // convert apparmor requested mask to apparmor access mode - requestedMaskToAccess = map[string]string{ - "a": "w", - "ac": "w", - "c": "w", - "d": "w", - "m": "rm", - "ra": "rw", - "wc": "w", - "wd": "w", - "wr": "rw", - "wrc": "rw", - "wrd": "rw", - "x": "rix", + maskToAccess = map[string]string{ + "a": "w", + "c": "w", + "d": "w", + "wc": "w", + "x": "ix", } // The order the apparmor rules should be sorted - ruleAlphabet = []string{ - "include", - "rlimit", - "capability", - "network", - "mount", - "remount", - "umount", - "pivotroot", - "changeprofile", - "mqueue", - "signal", - "ptrace", - "unix", - "userns", - "iouring", - "dbus", - "file", + ruleAlphabet = []Kind{ + INCLUDE, + ALL, + RLIMIT, + USERNS, + CAPABILITY, + NETWORK, + MOUNT, + REMOUNT, + UMOUNT, + PIVOTROOT, + CHANGEPROFILE, + MQUEUE, + IOURING, + SIGNAL, + PTRACE, + UNIX, + DBUS, + FILE, + LINK, + PROFILE, + HAT, "include_if_exists", } - ruleWeights = map[string]int{} + ruleWeights = generateWeights(ruleAlphabet) // The order the apparmor file rules should be sorted fileAlphabet = []string{ @@ -91,23 +114,65 @@ var ( "@{PROC}", // 10. Proc files "/dev", // 11. Dev files "deny", // 12. Deny rules + "profile", // 13. Subprofiles } - fileWeights = map[string]int{} + fileWeights = generateWeights(fileAlphabet) + + // The order the rule values (access, type, domains, etc) should be sorted + requirements = map[Kind]requirement{} + requirementsWeights map[Kind]map[string]map[string]int ) -func generateTemplate() *template.Template { - res := template.New("profile.j2").Funcs(tmplFunctionMap) - res = template.Must(res.ParseFS(tmplFiles, "templates/*.j2")) +func init() { + requirementsWeights = generateRequirementsWeights(requirements) +} + +func generateTemplates(names []Kind) map[Kind]*template.Template { + res := make(map[Kind]*template.Template, len(names)) + base := template.New("").Funcs(tmplFunctionMap) + base = template.Must(base.ParseFS(tmplFiles, + "templates/*.j2", "templates/rule/*.j2", + )) + for _, name := range names { + t := template.Must(base.Clone()) + t = template.Must(t.Parse( + fmt.Sprintf(`{{- template "%s" . -}}`, name), + )) + res[name] = t + } return res } -func init() { - for i, r := range fileAlphabet { - fileWeights[r] = i +func renderTemplate(name Kind, data any) string { + var res strings.Builder + template, ok := tmpl[name] + if !ok { + panic("template '" + name.String() + "' not found") } - for i, r := range ruleAlphabet { - ruleWeights[r] = i + err := template.Execute(&res, data) + if err != nil { + panic(err) } + return res.String() +} + +func generateWeights[T Kind | string](alphabet []T) map[T]int { + res := make(map[T]int, len(alphabet)) + for i, r := range alphabet { + res[r] = i + } + return res +} + +func generateRequirementsWeights(requirements map[Kind]requirement) map[Kind]map[string]map[string]int { + res := make(map[Kind]map[string]map[string]int, len(requirements)) + for rule, req := range requirements { + res[rule] = make(map[string]map[string]int, len(req)) + for key, values := range req { + res[rule][key] = generateWeights(values) + } + } + return res } func join(i any) string { @@ -125,20 +190,48 @@ func join(i any) string { } } -func typeOf(i any) string { - return strings.TrimPrefix(reflect.TypeOf(i).String(), "*aa.") +func cjoin(i any) string { + switch reflect.TypeOf(i).Kind() { + case reflect.Slice: + s := i.([]string) + if len(s) == 1 { + return s[0] + } + return "(" + strings.Join(s, " ") + ")" + case reflect.Map: + res := []string{} + for k, v := range i.(map[string]string) { + res = append(res, k+"="+v) + } + return "(" + strings.Join(res, " ") + ")" + default: + return i.(string) + } } -func typeToValue(i reflect.Type) string { - return strings.ToLower(strings.TrimPrefix(i.String(), "*aa.")) +func kindOf(i any) string { + if i == nil { + return "" + } + return i.(Rule).Kind().String() +} + +func setindent(i string) string { + switch i { + case "++": + IndentationLevel++ + case "--": + IndentationLevel-- + } + return "" } func indent(s string) string { - return indentation + s + return strings.Repeat(Indentation, IndentationLevel) + s } func indentDbus(s string) string { - return indentation + " " + s + return strings.Join([]string{Indentation, s}, " ") } func getLetterIn(alphabet []string, in string) string { @@ -149,10 +242,3 @@ func getLetterIn(alphabet []string, in string) string { } return "" } - -func toAccess(mask string) string { - if requestedMaskToAccess[mask] != "" { - return requestedMaskToAccess[mask] - } - return mask -} diff --git a/pkg/aa/templates/apparmor.j2 b/pkg/aa/templates/apparmor.j2 new file mode 100644 index 00000000..75a0026f --- /dev/null +++ b/pkg/aa/templates/apparmor.j2 @@ -0,0 +1,14 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "apparmor" -}} + + {{- template "rules" .Preamble -}} + + {{- range .Profiles -}} + {{- template "profile" . -}} + {{- "\n" -}} + {{- end -}} + +{{- end -}} diff --git a/pkg/aa/templates/comment.j2 b/pkg/aa/templates/comment.j2 deleted file mode 100644 index ce7c30b9..00000000 --- a/pkg/aa/templates/comment.j2 +++ /dev/null @@ -1,17 +0,0 @@ -{{- define "comment" -}} - {{- if or .FileInherit .NoNewPrivs .Optional .Comment -}} - {{- " #" -}} - {{- end -}} - {{- if .FileInherit -}} - {{- " file_inherit" -}} - {{- end -}} - {{- if .NoNewPrivs -}} - {{- " no new privs" -}} - {{- end -}} - {{- if .Optional -}} - {{- " optional:" -}} - {{- end -}} - {{- with .Comment -}} - {{ " " }}{{ . }} - {{- end -}} -{{- end -}} diff --git a/pkg/aa/templates/hat.j2 b/pkg/aa/templates/hat.j2 new file mode 100644 index 00000000..694c3acc --- /dev/null +++ b/pkg/aa/templates/hat.j2 @@ -0,0 +1,18 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "hat" -}} + + {{- "hat" -}} + {{- with .Name -}} + {{ " " }}{{ . }} + {{- end -}} + + {{- " {\n" -}} + {{- setindent "++" -}} + {{- template "rules" .Rules -}} + {{- setindent "--" -}} + {{- indent "}" -}} + +{{- end -}} diff --git a/pkg/aa/templates/profile.j2 b/pkg/aa/templates/profile.j2 index da406e86..f2df9069 100644 --- a/pkg/aa/templates/profile.j2 +++ b/pkg/aa/templates/profile.j2 @@ -2,27 +2,8 @@ {{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} {{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} -{{- range .Abi -}} - {{- if .IsMagic -}} - {{ "abi <" }}{{ .Path }}{{ ">,\n" }} - {{- else -}} - {{ "abi \"" }}{{ .Path }}{{ "\",\n" }} - {{- end }} -{{ end -}} +{{- define "profile" -}} -{{- range .Aliases -}} - {{ "alias " }}{{ .Path }}{{ " -> " }}{{ .RewrittenPath }}{{ ",\n" }} -{{ end -}} - -{{- range .Includes -}} - {{ template "include" . }}{{ "\n" }} -{{ end -}} - -{{- range .Variables -}} - {{ "@{" }}{{ .Name }}{{ "} = " }}{{ join .Values }} -{{ end -}} - -{{- if or .Name .Attachments .Attributes .Flags -}} {{- "profile" -}} {{- with .Name -}} {{ " " }}{{ . }} @@ -36,260 +17,11 @@ {{- with .Flags -}} {{ " flags=(" }}{{ join . }}{{ ")" }} {{- end -}} - {{ " {\n" }} -{{- end -}} - -{{- $oldtype := "" -}} -{{- range .Rules -}} - {{- $type := typeof . -}} - {{- if eq $type "Rule" -}} - {{- "\n" -}} - {{- continue -}} - {{- end -}} - {{- if and (ne $type $oldtype) (ne $oldtype "") -}} - {{- "\n" -}} - {{- end -}} - {{- indent "" -}} - - {{- if eq $type "Include" -}} - {{ template "include" . }} - {{- end -}} - - {{- if eq $type "Rlimit" -}} - {{ "set rlimit " }}{{ .Key }} {{ .Op }} {{ .Value }}{{ "," }} - {{- end -}} - - {{- if eq $type "Capability" -}} - {{ template "qualifier" . }}{{ "capability " }}{{ .Name }}{{ "," }}{{ template "comment" . }} - {{- end -}} - - {{- if eq $type "Network" -}} - {{- template "qualifier" . -}} - {{ "network" }} - {{- with .Domain -}} - {{ " " }}{{ . }} - {{- end -}} - {{- with .Type -}} - {{ " " }}{{ . }} - {{- else -}} - {{- with .Protocol -}} - {{ " " }}{{ . }} - {{- end -}} - {{- end -}} - {{- "," -}} - {{- template "comment" . -}} - {{- end -}} - - {{- if eq $type "Mount" -}} - {{- template "qualifier" . -}} - {{- "mount" -}} - {{- with .FsType -}} - {{ " fstype=" }}{{ . }} - {{- end -}} - {{- with .Options -}} - {{ " options=(" }}{{ join . }}{{ ")" }} - {{- end -}} - {{- with .Source -}} - {{ " " }}{{ . }} - {{- end -}} - {{- with .MountPoint -}} - {{ " -> " }}{{ . }} - {{- end -}} - {{- "," -}} - {{- template "comment" . -}} - {{- end -}} - - {{- if eq $type "Umount" -}} - {{- template "qualifier" . -}} - {{- "umount" -}} - {{- with .FsType -}} - {{ " fstype=" }}{{ . }} - {{- end -}} - {{- with .Options -}} - {{ " options=(" }}{{ join . }}{{ ")" }} - {{- end -}} - {{- with .MountPoint -}} - {{ " " }}{{ . }} - {{- end -}} - {{- "," -}} - {{- template "comment" . -}} - {{- end -}} - - {{- if eq $type "Remount" -}} - {{- template "qualifier" . -}} - {{- "remount" -}} - {{- with .FsType -}} - {{ " fstype=" }}{{ . }} - {{- end -}} - {{- with .Options -}} - {{ " options=(" }}{{ join . }}{{ ")" }} - {{- end -}} - {{- with .MountPoint -}} - {{ " " }}{{ . }} - {{- end -}} - {{- "," -}} - {{- template "comment" . -}} - {{- end -}} - - {{- if eq $type "PivotRoot" -}} - {{- template "qualifier" . -}} - {{- "pivot_root" -}} - {{- with .OldRoot -}} - {{ " oldroot=" }}{{ . }} - {{- end -}} - {{- with .NewRoot -}} - {{ " " }}{{ . }} - {{- end -}} - {{- with .TargetProfile -}} - {{ " -> " }}{{ . }} - {{- end -}} - {{- "," -}} - {{- template "comment" . -}} - {{- end -}} - - {{- if eq $type "ChangeProfile" -}} - {{- template "qualifier" . -}} - {{- "change_profile" -}} - {{- with .ExecMode -}} - {{ " " }}{{ . }} - {{- end -}} - {{- with .Exec -}} - {{ " " }}{{ . }} - {{- end -}} - {{- with .ProfileName -}} - {{ " -> " }}{{ . }} - {{- end -}} - {{- "," -}} - {{- template "comment" . -}} - {{- end -}} - - {{- if eq $type "Mqueue" -}} - {{- template "qualifier" . -}} - {{- "mqueue" -}} - {{- with .Access -}} - {{ " " }}{{ . }} - {{- end -}} - {{- with .Type -}} - {{ " type=" }}{{ . }} - {{- end -}} - {{- with .Label -}} - {{ " label=" }}{{ . }} - {{- end -}} - {{- with .Name -}} - {{ " " }}{{ . }} - {{- end -}} - {{- "," -}} - {{- template "comment" . -}} - {{- end -}} - - {{- if eq $type "Unix" -}} - {{- template "qualifier" . -}} - {{- "unix" -}} - {{- with .Access -}} - {{ " (" }}{{ . }}{{ ")" }} - {{- end -}} - {{- with .Type -}} - {{ " type=" }}{{ . }} - {{- end -}} - {{- with .Address -}} - {{ " addr=" }}{{ . }} - {{- end -}} - {{- if .Peer -}} - {{ " peer=(label=" }}{{ .Peer }} - {{- with .PeerAddr -}} - {{ ", addr="}}{{ . }} - {{- end -}} - {{- ")" -}} - {{- end -}} - {{- "," -}} - {{- template "comment" . -}} - {{- end -}} - - {{- if eq $type "Ptrace" -}} - {{- template "qualifier" . -}} - {{- "ptrace" -}} - {{- with .Access -}} - {{ " (" }}{{ . }}{{ ")" }} - {{- end -}} - {{- with .Peer -}} - {{ " peer=" }}{{ . }} - {{- end -}} - {{- "," -}} - {{- template "comment" . -}} - {{- end -}} - - {{- if eq $type "Signal" -}} - {{- template "qualifier" . -}} - {{- "signal" -}} - {{- with .Access -}} - {{ " (" }}{{ . }}{{ ")" }} - {{- end -}} - {{- with .Set -}} - {{ " set=(" }}{{ . }}{{ ")" }} - {{- end -}} - {{- with .Peer -}} - {{ " peer=" }}{{ . }} - {{- end -}} - {{- "," -}} - {{- template "comment" . -}} - {{- end -}} - - {{- if eq $type "Dbus" -}} - {{- template "qualifier" . -}} - {{- "dbus" -}} - {{- if eq .Access "bind" -}} - {{ " bind bus=" }}{{ .Bus }}{{ " name=" }}{{ .Name }} - {{- else -}} - {{- with .Access -}} - {{ " " }}{{ . }} - {{- end -}} - {{- with .Bus -}} - {{ " bus=" }}{{ . }} - {{- end -}} - {{- with .Path -}} - {{ " path=" }}{{ . }} - {{- end -}} - {{ "\n" }} - {{- with .Interface -}} - {{ overindent "interface=" }}{{ . }}{{ "\n" }} - {{- end -}} - {{- with .Member -}} - {{ overindent "member=" }}{{ . }}{{ "\n" }} - {{- end -}} - {{- if and .Name .Label -}} - {{ overindent "peer=(name=" }}{{ .Name }}{{ ", label="}}{{ .Label }}{{ ")" }} - {{- else -}} - {{- with .Name -}} - {{ overindent "peer=(name=" }}{{ . }}{{ ")" }} - {{- end -}} - {{- with .Label -}} - {{ overindent "peer=(label=" }}{{ . }}{{ ")" }} - {{- end -}} - {{- end -}} - {{- end -}} - {{- "," -}} - {{- template "comment" . -}} - {{- end -}} - - {{- if eq $type "File" -}} - {{- template "qualifier" . -}} - {{- .Path -}} - {{- " " -}} - {{- with .Padding -}} - {{ . }} - {{- end -}} - {{- .Access -}} - {{- with .Target -}} - {{ " -> " }}{{ . }} - {{- end -}} - {{- "," -}} - {{- template "comment" . -}} - {{- end -}} - - {{- "\n" -}} - {{- $oldtype = $type -}} -{{- end -}} - -{{- if or .Name .Attachments .Attributes .Flags -}} - {{- "}\n" -}} + + {{- " {\n" -}} + {{- setindent "++" -}} + {{- template "rules" .Rules -}} + {{- setindent "--" -}} + {{- indent "}" -}} + {{- end -}} diff --git a/pkg/aa/templates/rule/abi.j2 b/pkg/aa/templates/rule/abi.j2 new file mode 100644 index 00000000..09840a8d --- /dev/null +++ b/pkg/aa/templates/rule/abi.j2 @@ -0,0 +1,14 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "abi" -}} + {{- "abi" -}} + {{- if .IsMagic -}} + {{ " <" }}{{ .Path }}{{ ">" }} + {{- else -}} + {{ " \"" }}{{ .Path }}{{ "\"" }} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} diff --git a/pkg/aa/templates/rule/alias.j2 b/pkg/aa/templates/rule/alias.j2 new file mode 100644 index 00000000..2912d334 --- /dev/null +++ b/pkg/aa/templates/rule/alias.j2 @@ -0,0 +1,12 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "alias" -}} + {{- "alias " -}} + {{- .Path -}} + {{- " -> " -}} + {{- .RewrittenPath -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} diff --git a/pkg/aa/templates/rule/all.j2 b/pkg/aa/templates/rule/all.j2 new file mode 100644 index 00000000..645d0ff5 --- /dev/null +++ b/pkg/aa/templates/rule/all.j2 @@ -0,0 +1,9 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "all" -}} + {{- "all" -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} diff --git a/pkg/aa/templates/rule/capability.j2 b/pkg/aa/templates/rule/capability.j2 new file mode 100644 index 00000000..5b46e73a --- /dev/null +++ b/pkg/aa/templates/rule/capability.j2 @@ -0,0 +1,13 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "capability" -}} + {{- template "qualifier" . -}} + {{- "capability" -}} + {{- range .Names -}} + {{ " " }}{{ . }} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} diff --git a/pkg/aa/templates/rule/change_profile.j2 b/pkg/aa/templates/rule/change_profile.j2 new file mode 100644 index 00000000..a5e4e75f --- /dev/null +++ b/pkg/aa/templates/rule/change_profile.j2 @@ -0,0 +1,19 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "change_profile" -}} + {{- template "qualifier" . -}} + {{- "change_profile" -}} + {{- with .ExecMode -}} + {{ " " }}{{ . }} + {{- end -}} + {{- with .Exec -}} + {{ " " }}{{ . }} + {{- end -}} + {{- with .ProfileName -}} + {{ " -> " }}{{ . }} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} \ No newline at end of file diff --git a/pkg/aa/templates/rule/comment.j2 b/pkg/aa/templates/rule/comment.j2 new file mode 100644 index 00000000..abe41963 --- /dev/null +++ b/pkg/aa/templates/rule/comment.j2 @@ -0,0 +1,25 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "comment" -}} + {{- if or .FileInherit .NoNewPrivs .Optional .Comment -}} + {{- if .IsLineRule }} + {{- "#" -}} + {{- else -}} + {{- " #" -}} + {{- end -}} + {{- if .FileInherit -}} + {{- " file_inherit" -}} + {{- end -}} + {{- if .NoNewPrivs -}} + {{- " no new privs" -}} + {{- end -}} + {{- if .Optional -}} + {{- " optional:" -}} + {{- end -}} + {{- with .Comment -}} + {{ . }} + {{- end -}} + {{- end -}} +{{- end -}} diff --git a/pkg/aa/templates/rule/dbus.j2 b/pkg/aa/templates/rule/dbus.j2 new file mode 100644 index 00000000..f3227ad7 --- /dev/null +++ b/pkg/aa/templates/rule/dbus.j2 @@ -0,0 +1,43 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "dbus" -}} + {{- template "qualifier" . -}} + {{- "dbus" -}} + {{- $access := "" -}} + {{- if .Access -}} + {{- $access = index .Access 0 -}} + {{- end -}} + {{- if eq $access "bind" -}} + {{ " bind bus=" }}{{ .Bus }}{{ " name=" }}{{ .Name }} + {{- else -}} + {{- with .Access -}} + {{ " " }}{{ cjoin . }} + {{- end -}} + {{- with .Bus -}} + {{ " bus=" }}{{ . }} + {{- end -}} + {{- with .Path -}} + {{ " path=" }}{{ . }} + {{- end -}} + {{- with .Interface -}} + {{ "\n" }}{{ overindent "interface=" }}{{ . }} + {{- end -}} + {{- with .Member -}} + {{ "\n" }}{{ overindent "member=" }}{{ . }} + {{- end -}} + {{- if and .PeerName .PeerLabel -}} + {{ "\n" }}{{ overindent "peer=(name=" }}{{ .PeerName }}{{ ", label="}}{{ .PeerLabel }}{{ ")" }} + {{- else -}} + {{- with .PeerName -}} + {{ "\n" }}{{ overindent "peer=(name=" }}{{ . }}{{ ")" }} + {{- end -}} + {{- with .PeerLabel -}} + {{ "\n" }}{{ overindent "peer=(label=" }}{{ . }}{{ ")" }} + {{- end -}} + {{- end -}} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} diff --git a/pkg/aa/templates/rule/file.j2 b/pkg/aa/templates/rule/file.j2 new file mode 100644 index 00000000..566e7442 --- /dev/null +++ b/pkg/aa/templates/rule/file.j2 @@ -0,0 +1,41 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "file" -}} + {{- template "qualifier" . -}} + {{- if .Owner -}} + {{- "owner " -}} + {{- end -}} + {{- .Path -}} + {{- " " -}} + {{- with .Padding -}} + {{ . }} + {{- end -}} + {{- range .Access -}} + {{- . -}} + {{- end -}} + {{- with .Target -}} + {{ " -> " }}{{ . }} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} + +{{- define "link" -}} + {{- template "qualifier" . -}} + {{- if .Owner -}} + {{- "owner " -}} + {{- end -}} + {{- "link " -}} + {{- if .Subset -}} + {{- "subset " -}} + {{- end -}} + {{- .Path -}} + {{- " " -}} + {{- with .Target -}} + {{ "-> " }}{{ . }} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} diff --git a/pkg/aa/templates/include.j2 b/pkg/aa/templates/rule/include.j2 similarity index 54% rename from pkg/aa/templates/include.j2 rename to pkg/aa/templates/rule/include.j2 index 8a39a8c3..b2dcb110 100644 --- a/pkg/aa/templates/include.j2 +++ b/pkg/aa/templates/rule/include.j2 @@ -1,3 +1,7 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + {{- define "include" -}} {{- "include" -}} {{- if .IfExists -}} @@ -8,4 +12,5 @@ {{- else -}} {{ " \"" }}{{ .Path }}{{ "\"" }} {{- end -}} + {{- template "comment" . -}} {{- end -}} diff --git a/pkg/aa/templates/rule/io_uring.j2 b/pkg/aa/templates/rule/io_uring.j2 new file mode 100644 index 00000000..78e1aa17 --- /dev/null +++ b/pkg/aa/templates/rule/io_uring.j2 @@ -0,0 +1,16 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "io_uring" -}} + {{- template "qualifier" . -}} + {{- "io_uring" -}} + {{- range .Access -}} + {{ " " }}{{ . }} + {{- end -}} + {{- with .Label -}} + {{ " label=" }}{{ . }} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} diff --git a/pkg/aa/templates/rule/mount.j2 b/pkg/aa/templates/rule/mount.j2 new file mode 100644 index 00000000..c97ead10 --- /dev/null +++ b/pkg/aa/templates/rule/mount.j2 @@ -0,0 +1,54 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "mount" -}} + {{- template "qualifier" . -}} + {{- "mount" -}} + {{- with .FsType -}} + {{ " fstype=" }}{{ . }} + {{- end -}} + {{- with .Options -}} + {{ " options=" }}{{ cjoin . }} + {{- end -}} + {{- with .Source -}} + {{ " " }}{{ . }} + {{- end -}} + {{- with .MountPoint -}} + {{ " -> " }}{{ . }} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} + +{{- define "remount" -}} + {{- template "qualifier" . -}} + {{- "remount" -}} + {{- with .FsType -}} + {{ " fstype=" }}{{ . }} + {{- end -}} + {{- with .Options -}} + {{ " options=" }}{{ cjoin . }} + {{- end -}} + {{- with .MountPoint -}} + {{ " " }}{{ . }} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} + +{{- define "umount" -}} + {{- template "qualifier" . -}} + {{- "umount" -}} + {{- with .FsType -}} + {{ " fstype=" }}{{ . }} + {{- end -}} + {{- with .Options -}} + {{ " options=" }}{{ cjoin . }} + {{- end -}} + {{- with .MountPoint -}} + {{ " " }}{{ . }} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} diff --git a/pkg/aa/templates/rule/mqueue.j2 b/pkg/aa/templates/rule/mqueue.j2 new file mode 100644 index 00000000..e2df2756 --- /dev/null +++ b/pkg/aa/templates/rule/mqueue.j2 @@ -0,0 +1,22 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "mqueue" -}} + {{- template "qualifier" . -}} + {{- "mqueue" -}} + {{- with .Access -}} + {{ " " }}{{ cjoin . }} + {{- end -}} + {{- with .Type -}} + {{ " type=" }}{{ . }} + {{- end -}} + {{- with .Label -}} + {{ " label=" }}{{ . }} + {{- end -}} + {{- with .Name -}} + {{ " " }}{{ . }} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} diff --git a/pkg/aa/templates/rule/network.j2 b/pkg/aa/templates/rule/network.j2 new file mode 100644 index 00000000..6f2503a8 --- /dev/null +++ b/pkg/aa/templates/rule/network.j2 @@ -0,0 +1,20 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "network" -}} + {{- template "qualifier" . -}} + {{ "network" }} + {{- with .Domain -}} + {{ " " }}{{ . }} + {{- end -}} + {{- with .Type -}} + {{ " " }}{{ . }} + {{- else -}} + {{- with .Protocol -}} + {{ " " }}{{ . }} + {{- end -}} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} \ No newline at end of file diff --git a/pkg/aa/templates/rule/pivot_root.j2 b/pkg/aa/templates/rule/pivot_root.j2 new file mode 100644 index 00000000..d779e2c1 --- /dev/null +++ b/pkg/aa/templates/rule/pivot_root.j2 @@ -0,0 +1,19 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "pivot_root" -}} + {{- template "qualifier" . -}} + {{- "pivot_root" -}} + {{- with .OldRoot -}} + {{ " oldroot=" }}{{ . }} + {{- end -}} + {{- with .NewRoot -}} + {{ " " }}{{ . }} + {{- end -}} + {{- with .TargetProfile -}} + {{ " -> " }}{{ . }} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} \ No newline at end of file diff --git a/pkg/aa/templates/rule/ptrace.j2 b/pkg/aa/templates/rule/ptrace.j2 new file mode 100644 index 00000000..c499890b --- /dev/null +++ b/pkg/aa/templates/rule/ptrace.j2 @@ -0,0 +1,16 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "ptrace" -}} + {{- template "qualifier" . -}} + {{- "ptrace" -}} + {{- with .Access -}} + {{ " " }}{{ cjoin . }} + {{- end -}} + {{- with .Peer -}} + {{ " peer=" }}{{ . }} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} \ No newline at end of file diff --git a/pkg/aa/templates/qualifier.j2 b/pkg/aa/templates/rule/qualifier.j2 similarity index 56% rename from pkg/aa/templates/qualifier.j2 rename to pkg/aa/templates/rule/qualifier.j2 index 929cc8ed..ed89f63e 100644 --- a/pkg/aa/templates/qualifier.j2 +++ b/pkg/aa/templates/rule/qualifier.j2 @@ -1,10 +1,11 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + {{- define "qualifier" -}} {{- with .Prefix -}} {{ . }} {{- end -}} - {{- if .Owner -}} - {{- "owner " -}} - {{- end -}} {{- if .Audit -}} {{- "audit " -}} {{- end -}} diff --git a/pkg/aa/templates/rule/rlimit.j2 b/pkg/aa/templates/rule/rlimit.j2 new file mode 100644 index 00000000..5061c1c4 --- /dev/null +++ b/pkg/aa/templates/rule/rlimit.j2 @@ -0,0 +1,7 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "rlimit" -}} + {{ "set rlimit " }}{{ .Key }} {{ .Op }} {{ .Value }}{{ "," }}{{ template "comment" . }} +{{- end -}} \ No newline at end of file diff --git a/pkg/aa/templates/rule/signal.j2 b/pkg/aa/templates/rule/signal.j2 new file mode 100644 index 00000000..b56085d8 --- /dev/null +++ b/pkg/aa/templates/rule/signal.j2 @@ -0,0 +1,19 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "signal" -}} + {{- template "qualifier" . -}} + {{- "signal" -}} + {{- with .Access -}} + {{ " " }}{{ cjoin . }} + {{- end -}} + {{- with .Set -}} + {{ " set=" }}{{ cjoin . }} + {{- end -}} + {{- with .Peer -}} + {{ " peer=" }}{{ . }} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} \ No newline at end of file diff --git a/pkg/aa/templates/rule/unix.j2 b/pkg/aa/templates/rule/unix.j2 new file mode 100644 index 00000000..531eaaf9 --- /dev/null +++ b/pkg/aa/templates/rule/unix.j2 @@ -0,0 +1,35 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "unix" -}} + {{- template "qualifier" . -}} + {{- "unix" -}} + {{- with .Access -}} + {{ " " }}{{ cjoin . }} + {{- end -}} + {{- with .Type -}} + {{ " type=" }}{{ . }} + {{- end -}} + {{- with .Protocol -}} + {{ " protocol=" }}{{ . }} + {{- end -}} + {{- with .Address -}} + {{ " addr=" }}{{ . }} + {{- end -}} + {{- with .Label -}} + {{ " label=" }}{{ . }} + {{- end -}} + {{- if and .PeerLabel .PeerAddr -}} + {{ " peer=(label=" }}{{ .PeerLabel }}{{ ", addr="}}{{ .PeerAddr }}{{ ")" }} + {{- else -}} + {{- with .PeerLabel -}} + {{ overindent "peer=(label=" }}{{ . }}{{ ")" }} + {{- end -}} + {{- with .PeerAddr -}} + {{ overindent "peer=(addr=" }}{{ . }}{{ ")" }} + {{- end -}} + {{- end -}} + {{- "," -}} + {{- template "comment" . -}} +{{- end -}} \ No newline at end of file diff --git a/pkg/aa/templates/rule/userns.j2 b/pkg/aa/templates/rule/userns.j2 new file mode 100644 index 00000000..771a5e2f --- /dev/null +++ b/pkg/aa/templates/rule/userns.j2 @@ -0,0 +1,9 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "userns" -}} + {{- if .Create -}} + {{ template "qualifier" . }}{{ "userns," }}{{ template "comment" . }} + {{- end -}} +{{- end -}} \ No newline at end of file diff --git a/pkg/aa/templates/rule/variable.j2 b/pkg/aa/templates/rule/variable.j2 new file mode 100644 index 00000000..f27e01cc --- /dev/null +++ b/pkg/aa/templates/rule/variable.j2 @@ -0,0 +1,14 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "variable" -}} + {{- "@{" -}}{{- .Name -}}{{- "}" -}} + {{- if .Define }} + {{- " = " -}} + {{- else -}} + {{- " += " -}} + {{- end -}} + {{- join .Values -}} + {{- template "comment" . -}} +{{- end -}} diff --git a/pkg/aa/templates/rules.j2 b/pkg/aa/templates/rules.j2 new file mode 100644 index 00000000..efc057e0 --- /dev/null +++ b/pkg/aa/templates/rules.j2 @@ -0,0 +1,125 @@ +{{- /* apparmor.d - Full set of apparmor profiles */ -}} +{{- /* Copyright (C) 2021-2024 Alexandre Pujol */ -}} +{{- /* SPDX-License-Identifier: GPL-2.0-only */ -}} + +{{- define "rules" -}} + + {{- $oldkind := "" -}} + {{- range . -}} + {{- $kind := kindof . -}} + {{- if eq $kind "" -}} + {{- "\n" -}} + {{- continue -}} + {{- end -}} + {{- if eq $kind "comment" -}} + {{- template "comment" . -}} + {{- "\n" -}} + {{- continue -}} + {{- end -}} + + {{- if and (ne $kind $oldkind) (ne $oldkind "") -}} + {{- "\n" -}} + {{- end -}} + {{- indent "" -}} + + {{- if eq $kind "abi" -}} + {{- template "abi" . -}} + {{- end -}} + + {{- if eq $kind "alias" -}} + {{- template "alias" . -}} + {{- end -}} + + {{- if eq $kind "include" -}} + {{- template "include" . -}} + {{- end -}} + + {{- if eq $kind "variable" -}} + {{- template "variable" . -}} + {{- end -}} + + {{- if eq $kind "all" -}} + {{- template "all" . -}} + {{- end -}} + + {{- if eq $kind "rlimit" -}} + {{- template "rlimit" . -}} + {{- end -}} + + {{- if eq $kind "userns" -}} + {{- template "userns" . -}} + {{- end -}} + + {{- if eq $kind "capability" -}} + {{- template "capability" . -}} + {{- end -}} + + {{- if eq $kind "network" -}} + {{- template "network" . -}} + {{- end -}} + + {{- if eq $kind "mount" -}} + {{- template "mount" . -}} + {{- end -}} + + {{- if eq $kind "remount" -}} + {{- template "remount" . -}} + {{- end -}} + + {{- if eq $kind "umount" -}} + {{- template "umount" . -}} + {{- end -}} + + {{- if eq $kind "pivot_root" -}} + {{- template "pivot_root" . -}} + {{- end -}} + + {{- if eq $kind "change_profile" -}} + {{- template "change_profile" . -}} + {{- end -}} + + {{- if eq $kind "mqueue" -}} + {{- template "mqueue" . -}} + {{- end -}} + + {{- if eq $kind "io_uring" -}} + {{- template "io_uring" . -}} + {{- end -}} + + {{- if eq $kind "unix" -}} + {{- template "unix" . -}} + {{- end -}} + + {{- if eq $kind "ptrace" -}} + {{- template "ptrace" . -}} + {{- end -}} + + {{- if eq $kind "signal" -}} + {{- template "signal" . -}} + {{- end -}} + + {{- if eq $kind "dbus" -}} + {{- template "dbus" . -}} + {{- end -}} + + {{- if eq $kind "file" -}} + {{- template "file" . -}} + {{- end -}} + + {{- if eq $kind "link" -}} + {{- template "link" . -}} + {{- end -}} + + {{- if eq $kind "profile" -}} + {{- template "profile" . -}} + {{- end -}} + + {{- if eq $kind "hat" -}} + {{- template "hat" . -}} + {{- end -}} + + {{- "\n" -}} + {{- $oldkind = $kind -}} + {{- end -}} + +{{- end -}} diff --git a/pkg/aa/unix.go b/pkg/aa/unix.go index 3c353579..b868459b 100644 --- a/pkg/aa/unix.go +++ b/pkg/aa/unix.go @@ -4,70 +4,109 @@ package aa -type Unix struct { - Qualifier - Access string - Type string - Protocol string - Address string - Label string - Attr string - Opt string - Peer string - PeerAddr string +import ( + "fmt" + "slices" +) + +const UNIX Kind = "unix" + +func init() { + requirements[UNIX] = requirement{ + "access": []string{ + "create", "bind", "listen", "accept", "connect", "shutdown", + "getattr", "setattr", "getopt", "setopt", "send", "receive", + "r", "w", "rw", + }, + } } -func UnixFromLog(log map[string]string) ApparmorRule { +type Unix struct { + RuleBase + Qualifier + Access []string + Type string + Protocol string + Address string + Label string + Attr string + Opt string + PeerLabel string + PeerAddr string +} + +func newUnixFromLog(log map[string]string) Rule { return &Unix{ - Qualifier: NewQualifierFromLog(log), - Access: toAccess(log["requested_mask"]), + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), + Access: Must(toAccess(UNIX, log["requested_mask"])), Type: log["sock_type"], Protocol: log["protocol"], Address: log["addr"], - Label: log["peer_label"], + Label: log["label"], Attr: log["attr"], Opt: log["opt"], - Peer: log["peer"], + PeerLabel: log["peer"], PeerAddr: log["peer_addr"], } } +func (r *Unix) Validate() error { + if err := validateValues(r.Kind(), "access", r.Access); err != nil { + return fmt.Errorf("%s: %w", r, err) + } + return nil +} + func (r *Unix) Less(other any) bool { o, _ := other.(*Unix) - if r.Qualifier.Equals(o.Qualifier) { - if r.Access == o.Access { - if r.Type == o.Type { - if r.Protocol == o.Protocol { - if r.Address == o.Address { - if r.Label == o.Label { - if r.Attr == o.Attr { - if r.Opt == o.Opt { - if r.Peer == o.Peer { - return r.PeerAddr < o.PeerAddr - } - return r.Peer < o.Peer - } - return r.Opt < o.Opt - } - return r.Attr < o.Attr - } - return r.Label < o.Label - } - return r.Address < o.Address - } - return r.Protocol < o.Protocol - } - return r.Type < o.Type - } - return r.Access < o.Access + if len(r.Access) != len(o.Access) { + return len(r.Access) < len(o.Access) + } + if r.Type != o.Type { + return r.Type < o.Type + } + if r.Protocol != o.Protocol { + return r.Protocol < o.Protocol + } + if r.Address != o.Address { + return r.Address < o.Address + } + if r.Label != o.Label { + return r.Label < o.Label + } + if r.Attr != o.Attr { + return r.Attr < o.Attr + } + if r.Opt != o.Opt { + return r.Opt < o.Opt + } + if r.PeerLabel != o.PeerLabel { + return r.PeerLabel < o.PeerLabel + } + if r.PeerAddr != o.PeerAddr { + return r.PeerAddr < o.PeerAddr } return r.Qualifier.Less(o.Qualifier) } func (r *Unix) Equals(other any) bool { o, _ := other.(*Unix) - return r.Access == o.Access && r.Type == o.Type && + return slices.Equal(r.Access, o.Access) && r.Type == o.Type && r.Protocol == o.Protocol && r.Address == o.Address && r.Label == o.Label && r.Attr == o.Attr && r.Opt == o.Opt && - r.Peer == o.Peer && r.PeerAddr == o.PeerAddr && r.Qualifier.Equals(o.Qualifier) + r.PeerLabel == o.PeerLabel && r.PeerAddr == o.PeerAddr && + r.Qualifier.Equals(o.Qualifier) +} + +func (r *Unix) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Unix) Constraint() constraint { + return blockKind +} + +func (r *Unix) Kind() Kind { + return UNIX } diff --git a/pkg/aa/userns.go b/pkg/aa/userns.go index 446b130d..4c678f3d 100644 --- a/pkg/aa/userns.go +++ b/pkg/aa/userns.go @@ -4,21 +4,29 @@ package aa +const USERNS Kind = "userns" + type Userns struct { + RuleBase Qualifier Create bool } -func UsernsFromLog(log map[string]string) ApparmorRule { +func newUsernsFromLog(log map[string]string) Rule { return &Userns{ - Qualifier: NewQualifierFromLog(log), + RuleBase: newRuleFromLog(log), + Qualifier: newQualifierFromLog(log), Create: true, } } +func (r *Userns) Validate() error { + return nil +} + func (r *Userns) Less(other any) bool { o, _ := other.(*Userns) - if r.Qualifier.Equals(o.Qualifier) { + if r.Create != o.Create { return r.Create } return r.Qualifier.Less(o.Qualifier) @@ -28,3 +36,15 @@ func (r *Userns) Equals(other any) bool { o, _ := other.(*Userns) return r.Create == o.Create && r.Qualifier.Equals(o.Qualifier) } + +func (r *Userns) String() string { + return renderTemplate(r.Kind(), r) +} + +func (r *Userns) Constraint() constraint { + return blockKind +} + +func (r *Userns) Kind() Kind { + return USERNS +} diff --git a/pkg/aa/variables.go b/pkg/aa/variables.go deleted file mode 100644 index 0e114d18..00000000 --- a/pkg/aa/variables.go +++ /dev/null @@ -1,135 +0,0 @@ -// apparmor.d - Full set of apparmor profiles -// Copyright (C) 2021-2024 Alexandre Pujol -// SPDX-License-Identifier: GPL-2.0-only - -// Warning: this is purposely not using a Yacc parser. Its only aim is to -// extract variables and attachments for apparmor.d profile - -package aa - -import ( - "regexp" - "slices" - "strings" -) - -var ( - regVariablesDef = regexp.MustCompile(`@{(.*)}\s*[+=]+\s*(.*)`) - regVariablesRef = regexp.MustCompile(`@{([^{}]+)}`) -) - -type Variable struct { - Name string - Values []string -} - -func (r Variable) Less(other Variable) bool { - if r.Name == other.Name { - return len(r.Values) < len(other.Values) - } - return r.Name < other.Name -} - -func (r Variable) Equals(other Variable) bool { - return r.Name == other.Name && slices.Equal(r.Values, other.Values) -} - -// DefaultTunables return a minimal working profile to build the profile -// It should not be used when loading file from /etc/apparmor.d -func DefaultTunables() *AppArmorProfile { - return &AppArmorProfile{ - Preamble: Preamble{ - Variables: []Variable{ - {"int2", []string{"[0-9][0-9]"}}, - {"bin", []string{"/{,usr/}{,s}bin"}}, - {"lib", []string{"/{,usr/}lib{,exec,32,64}"}}, - {"multiarch", []string{"*-linux-gnu*"}}, - {"HOME", []string{"/home/*"}}, - {"user_share_dirs", []string{"/home/*/.local/share"}}, - {"etc_ro", []string{"/{,usr/}etc/"}}, - {"int", []string{"[0-9]{[0-9],}{[0-9],}{[0-9],}{[0-9],}{[0-9],}{[0-9],}{[0-9],}{[0-9],}{[0-9],}"}}, - }, - }, - } -} - -// ParseVariables extract all variables from the profile -func (p *AppArmorProfile) ParseVariables(content string) { - matches := regVariablesDef.FindAllStringSubmatch(content, -1) - for _, match := range matches { - if len(match) > 2 { - key := match[1] - values := strings.Split(match[2], " ") - found := false - for idx, variable := range p.Variables { - if variable.Name == key { - p.Variables[idx].Values = append(p.Variables[idx].Values, values...) - found = true - break - } - } - if !found { - variable := Variable{Name: key, Values: values} - p.Variables = append(p.Variables, variable) - } - } - } -} - -// resolve recursively resolves all variables references -func (p *AppArmorProfile) resolve(str string) []string { - if strings.Contains(str, "@{") { - vars := []string{} - match := regVariablesRef.FindStringSubmatch(str) - if len(match) > 1 { - variable := match[0] - varname := match[1] - for _, vrbl := range p.Variables { - if vrbl.Name == varname { - for _, value := range vrbl.Values { - newVar := strings.ReplaceAll(str, variable, value) - vars = append(vars, p.resolve(newVar)...) - } - } - } - } else { - vars = append(vars, str) - } - return vars - } - return []string{str} -} - -// ResolveAttachments resolve profile attachments defined in exec_path -func (p *AppArmorProfile) ResolveAttachments() { - for _, variable := range p.Variables { - if variable.Name == "exec_path" { - for _, value := range variable.Values { - attachments := p.resolve(value) - if len(attachments) == 0 { - panic("Variable not defined in: " + value) - } - p.Attachments = append(p.Attachments, attachments...) - } - } - } -} - -// NestAttachments return a nested attachment string -func (p *AppArmorProfile) NestAttachments() string { - if len(p.Attachments) == 0 { - return "" - } else if len(p.Attachments) == 1 { - return p.Attachments[0] - } else { - res := []string{} - for _, attachment := range p.Attachments { - if strings.HasPrefix(attachment, "/") { - res = append(res, attachment[1:]) - } else { - res = append(res, attachment) - } - } - return "/{" + strings.Join(res, ",") + "}" - } -} diff --git a/pkg/aa/variables_test.go b/pkg/aa/variables_test.go deleted file mode 100644 index 24c6529c..00000000 --- a/pkg/aa/variables_test.go +++ /dev/null @@ -1,206 +0,0 @@ -// apparmor.d - Full set of apparmor profiles -// Copyright (C) 2023-2024 Alexandre Pujol -// SPDX-License-Identifier: GPL-2.0-only - -package aa - -import ( - "reflect" - "testing" -) - -func TestAppArmorProfile_ParseVariables(t *testing.T) { - tests := []struct { - name string - content string - want []Variable - }{ - { - name: "firefox", - content: `@{firefox_name} = firefox{,-esr,-bin} - @{firefox_lib_dirs} = /{usr/,}lib{,32,64}/@{firefox_name} /opt/@{firefox_name} - @{firefox_config_dirs} = @{HOME}/.mozilla/ - @{firefox_cache_dirs} = @{user_cache_dirs}/mozilla/ - @{exec_path} = /{usr/,}bin/@{firefox_name} @{firefox_lib_dirs}/@{firefox_name} - `, - want: []Variable{ - {"firefox_name", []string{"firefox{,-esr,-bin}"}}, - {"firefox_lib_dirs", []string{"/{usr/,}lib{,32,64}/@{firefox_name}", "/opt/@{firefox_name}"}}, - {"firefox_config_dirs", []string{"@{HOME}/.mozilla/"}}, - {"firefox_cache_dirs", []string{"@{user_cache_dirs}/mozilla/"}}, - {"exec_path", []string{"/{usr/,}bin/@{firefox_name}", "@{firefox_lib_dirs}/@{firefox_name}"}}, - }, - }, - { - name: "xorg", - content: `@{exec_path} = /{usr/,}bin/X - @{exec_path} += /{usr/,}bin/Xorg{,.bin} - @{exec_path} += /{usr/,}lib/Xorg{,.wrap} - @{exec_path} += /{usr/,}lib/xorg/Xorg{,.wrap}`, - want: []Variable{ - {"exec_path", []string{ - "/{usr/,}bin/X", - "/{usr/,}bin/Xorg{,.bin}", - "/{usr/,}lib/Xorg{,.wrap}", - "/{usr/,}lib/xorg/Xorg{,.wrap}"}, - }, - }, - }, - { - name: "snapd", - content: `@{lib_dirs} = @{lib}/ /snap/snapd/@{int}@{lib} - @{exec_path} = @{lib_dirs}/snapd/snapd`, - want: []Variable{ - {"lib_dirs", []string{"@{lib}/", "/snap/snapd/@{int}@{lib}"}}, - {"exec_path", []string{"@{lib_dirs}/snapd/snapd"}}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := NewAppArmorProfile() - p.ParseVariables(tt.content) - if !reflect.DeepEqual(p.Variables, tt.want) { - t.Errorf("AppArmorProfile.ParseVariables() = %v, want %v", p.Variables, tt.want) - } - }) - } -} - -func TestAppArmorProfile_resolve(t *testing.T) { - tests := []struct { - name string - input string - want []string - }{ - { - name: "empty", - input: "@{}", - want: []string{"@{}"}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := DefaultTunables() - if got := p.resolve(tt.input); !reflect.DeepEqual(got, tt.want) { - t.Errorf("AppArmorProfile.resolve() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestAppArmorProfile_ResolveAttachments(t *testing.T) { - tests := []struct { - name string - variables []Variable - want []string - }{ - { - name: "firefox", - variables: []Variable{ - {"firefox_name", []string{"firefox{,-esr,-bin}"}}, - {"firefox_lib_dirs", []string{"/{usr/,}/lib{,32,64}/@{firefox_name}", "/opt/@{firefox_name}"}}, - {"exec_path", []string{"/{usr/,}bin/@{firefox_name}", "@{firefox_lib_dirs}/@{firefox_name}"}}, - }, - want: []string{ - "/{usr/,}bin/firefox{,-esr,-bin}", - "/{usr/,}/lib{,32,64}/firefox{,-esr,-bin}/firefox{,-esr,-bin}", - "/opt/firefox{,-esr,-bin}/firefox{,-esr,-bin}", - }, - }, - { - name: "chromium", - variables: []Variable{ - {"name", []string{"chromium"}}, - {"lib_dirs", []string{"/{usr/,}lib/@{name}"}}, - {"exec_path", []string{"@{lib_dirs}/@{name}"}}, - }, - want: []string{ - "/{usr/,}lib/chromium/chromium", - }, - }, - { - name: "geoclue", - variables: []Variable{ - {"libexec", []string{"/{usr/,}libexec"}}, - {"exec_path", []string{"@{libexec}/geoclue", "@{libexec}/geoclue-2.0/demos/agent"}}, - }, - want: []string{ - "/{usr/,}libexec/geoclue", - "/{usr/,}libexec/geoclue-2.0/demos/agent", - }, - }, - { - name: "opera", - variables: []Variable{ - {"multiarch", []string{"*-linux-gnu*"}}, - {"name", []string{"opera{,-beta,-developer}"}}, - {"lib_dirs", []string{"/{usr/,}lib/@{multiarch}/@{name}"}}, - {"exec_path", []string{"@{lib_dirs}/@{name}"}}, - }, - want: []string{ - "/{usr/,}lib/*-linux-gnu*/opera{,-beta,-developer}/opera{,-beta,-developer}", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := NewAppArmorProfile() - p.Variables = tt.variables - p.ResolveAttachments() - if !reflect.DeepEqual(p.Attachments, tt.want) { - t.Errorf("AppArmorProfile.ResolveAttachments() = %v, want %v", p.Attachments, tt.want) - } - }) - } -} - -func TestAppArmorProfile_NestAttachments(t *testing.T) { - tests := []struct { - name string - Attachments []string - want string - }{ - { - name: "firefox", - Attachments: []string{ - "/{usr/,}bin/firefox{,-esr,-bin}", - "/{usr/,}lib{,32,64}/firefox{,-esr,-bin}/firefox{,-esr,-bin}", - "/opt/firefox{,-esr,-bin}/firefox{,-esr,-bin}", - }, - want: "/{{usr/,}bin/firefox{,-esr,-bin},{usr/,}lib{,32,64}/firefox{,-esr,-bin}/firefox{,-esr,-bin},opt/firefox{,-esr,-bin}/firefox{,-esr,-bin}}", - }, - { - name: "geoclue", - Attachments: []string{ - "/{usr/,}libexec/geoclue", - "/{usr/,}libexec/geoclue-2.0/demos/agent", - }, - want: "/{{usr/,}libexec/geoclue,{usr/,}libexec/geoclue-2.0/demos/agent}", - }, - { - name: "null", - Attachments: []string{}, - want: "", - }, - { - name: "empty", - Attachments: []string{""}, - want: "", - }, - { - name: "not valid aare", - Attachments: []string{"/file", "relative"}, - want: "/{file,relative}", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := NewAppArmorProfile() - p.Attachments = tt.Attachments - if got := p.NestAttachments(); got != tt.want { - t.Errorf("AppArmorProfile.NestAttachments() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/pkg/logs/logs.go b/pkg/logs/logs.go index 913f1172..bc95dd03 100644 --- a/pkg/logs/logs.go +++ b/pkg/logs/logs.go @@ -197,8 +197,8 @@ func (aaLogs AppArmorLogs) String() string { } // ParseToProfiles convert the log data into a new AppArmorProfiles -func (aaLogs AppArmorLogs) ParseToProfiles() aa.AppArmorProfiles { - profiles := make(aa.AppArmorProfiles, 0) +func (aaLogs AppArmorLogs) ParseToProfiles() map[string]*aa.Profile { + profiles := make(map[string]*aa.Profile, 0) for _, log := range aaLogs { name := "" if strings.Contains(log["operation"], "dbus") { @@ -208,8 +208,7 @@ func (aaLogs AppArmorLogs) ParseToProfiles() aa.AppArmorProfiles { } if _, ok := profiles[name]; !ok { - profile := &aa.AppArmorProfile{} - profile.Name = name + profile := &aa.Profile{Header: aa.Header{Name: name}} profile.AddRule(log) profiles[name] = profile } else { diff --git a/pkg/logs/logs_test.go b/pkg/logs/logs_test.go index 4bfd5d90..44dc565f 100644 --- a/pkg/logs/logs_test.go +++ b/pkg/logs/logs_test.go @@ -292,44 +292,40 @@ func TestAppArmorLogs_ParseToProfiles(t *testing.T) { tests := []struct { name string aaLogs AppArmorLogs - want aa.AppArmorProfiles + want map[string]*aa.Profile }{ { name: "", aaLogs: append(append(refKmod, refPowerProfiles...), refKmod...), - want: aa.AppArmorProfiles{ - "kmod": &aa.AppArmorProfile{ - Profile: aa.Profile{ - Name: "kmod", - Rules: aa.Rules{ - &aa.Unix{ - Qualifier: aa.Qualifier{FileInherit: true}, - Access: "send receive", - Type: "stream", - Protocol: "0", - }, - &aa.Unix{ - Qualifier: aa.Qualifier{FileInherit: true}, - Access: "send receive", - Type: "stream", - Protocol: "0", - }, + want: map[string]*aa.Profile{ + "kmod": { + Header: aa.Header{Name: "kmod"}, + Rules: aa.Rules{ + &aa.Unix{ + RuleBase: aa.RuleBase{FileInherit: true}, + Access: []string{"send", "receive"}, + Type: "stream", + Protocol: "0", + }, + &aa.Unix{ + RuleBase: aa.RuleBase{FileInherit: true}, + Access: []string{"send", "receive"}, + Type: "stream", + Protocol: "0", }, }, }, - "power-profiles-daemon": &aa.AppArmorProfile{ - Profile: aa.Profile{ - Name: "power-profiles-daemon", - Rules: aa.Rules{ - &aa.Dbus{ - Access: "send", - Bus: "system", - Name: "org.freedesktop.DBus", - Path: "/org/freedesktop/DBus", - Interface: "org.freedesktop.DBus", - Member: "AddMatch", - Label: "dbus-daemon", - }, + "power-profiles-daemon": { + Header: aa.Header{Name: "power-profiles-daemon"}, + Rules: aa.Rules{ + &aa.Dbus{ + Access: []string{"send"}, + Bus: "system", + Path: "/org/freedesktop/DBus", + Interface: "org.freedesktop.DBus", + Member: "AddMatch", + PeerName: "org.freedesktop.DBus", + PeerLabel: "dbus-daemon", }, }, }, diff --git a/pkg/prebuild/builder/abi.go b/pkg/prebuild/builder/abi.go index 4790ba4c..72b3943d 100644 --- a/pkg/prebuild/builder/abi.go +++ b/pkg/prebuild/builder/abi.go @@ -30,6 +30,6 @@ func init() { }) } -func (b ABI3) Apply(profile string) string { - return regAbi4To3.Replace(profile) +func (b ABI3) Apply(opt *Option, profile string) (string, error) { + return regAbi4To3.Replace(profile), nil } diff --git a/pkg/prebuild/builder/complain.go b/pkg/prebuild/builder/complain.go index 3970e6df..e0f9f26b 100644 --- a/pkg/prebuild/builder/complain.go +++ b/pkg/prebuild/builder/complain.go @@ -30,13 +30,13 @@ func init() { }) } -func (b Complain) Apply(profile string) string { +func (b Complain) Apply(opt *Option, profile string) (string, error) { flags := []string{} matches := regFlags.FindStringSubmatch(profile) if len(matches) != 0 { flags = strings.Split(matches[1], ",") if slices.Contains(flags, "complain") { - return profile + return profile, nil } } flags = append(flags, "complain") @@ -44,5 +44,5 @@ func (b Complain) Apply(profile string) string { // Remove all flags definition, then set manifest' flags profile = regFlags.ReplaceAllLiteralString(profile, "") - return regProfileHeader.ReplaceAllLiteralString(profile, strFlags) + return regProfileHeader.ReplaceAllLiteralString(profile, strFlags), nil } diff --git a/pkg/prebuild/builder/core.go b/pkg/prebuild/builder/core.go index b8dbcbc8..64046721 100644 --- a/pkg/prebuild/builder/core.go +++ b/pkg/prebuild/builder/core.go @@ -7,6 +7,7 @@ package builder import ( "fmt" + "github.com/roddhjav/apparmor.d/pkg/paths" "github.com/roddhjav/apparmor.d/pkg/prebuild/cfg" ) @@ -21,7 +22,20 @@ var ( // Main directive interface type Builder interface { cfg.BaseInterface - Apply(profile string) string + Apply(opt *Option, profile string) (string, error) +} + +// Builder options +type Option struct { + Name string + File *paths.Path +} + +func NewOption(file *paths.Path) *Option { + return &Option{ + Name: file.Base(), + File: file, + } } func Register(names ...string) { @@ -37,3 +51,15 @@ func Register(names ...string) { func RegisterBuilder(d Builder) { Builders[d.Name()] = d } + +func Run(file *paths.Path, profile string) (string, error) { + var err error + opt := NewOption(file) + for _, b := range Builds { + profile, err = b.Apply(opt, profile) + if err != nil { + return "", fmt.Errorf("%s %s: %w", b.Name(), opt.File, err) + } + } + return profile, nil +} diff --git a/pkg/prebuild/builder/core_test.go b/pkg/prebuild/builder/core_test.go index b0c59e77..c242259f 100644 --- a/pkg/prebuild/builder/core_test.go +++ b/pkg/prebuild/builder/core_test.go @@ -7,6 +7,8 @@ package builder import ( "slices" "testing" + + "github.com/roddhjav/apparmor.d/pkg/prebuild/cfg" ) func TestBuilder_Apply(t *testing.T) { @@ -15,6 +17,7 @@ func TestBuilder_Apply(t *testing.T) { b Builder profile string want string + wantErr bool }{ { name: "abi3", @@ -215,7 +218,7 @@ func TestBuilder_Apply(t *testing.T) { }`, }, { - name: "userspace-1", + name: "userspace-2", b: Builders["userspace"], profile: ` profile foo /usr/bin/foo { @@ -237,7 +240,13 @@ func TestBuilder_Apply(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := tt.b.Apply(tt.profile); got != tt.want { + opt := &Option{File: cfg.RootApparmord.Join(tt.name)} + got, err := tt.b.Apply(opt, tt.profile) + if (err != nil) != tt.wantErr { + t.Errorf("Builder.Apply() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { t.Errorf("Builder.Apply() = %v, want %v", got, tt.want) } }) @@ -257,7 +266,6 @@ func TestRegister(t *testing.T) { }, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { Register(tt.names...) for _, name := range tt.names { diff --git a/pkg/prebuild/builder/dev.go b/pkg/prebuild/builder/dev.go index e555e5d9..f8ebdff0 100644 --- a/pkg/prebuild/builder/dev.go +++ b/pkg/prebuild/builder/dev.go @@ -31,6 +31,6 @@ func init() { }) } -func (b Dev) Apply(profile string) string { - return regDev.Replace(profile) +func (b Dev) Apply(opt *Option, profile string) (string, error) { + return regDev.Replace(profile), nil } diff --git a/pkg/prebuild/builder/enforce.go b/pkg/prebuild/builder/enforce.go index a3bd2c1d..bc25e03d 100644 --- a/pkg/prebuild/builder/enforce.go +++ b/pkg/prebuild/builder/enforce.go @@ -24,16 +24,16 @@ func init() { }) } -func (b Enforce) Apply(profile string) string { +func (b Enforce) Apply(opt *Option, profile string) (string, error) { matches := regFlags.FindStringSubmatch(profile) if len(matches) == 0 { - return profile + return profile, nil } flags := strings.Split(matches[1], ",") idx := slices.Index(flags, "complain") if idx == -1 { - return profile + return profile, nil } flags = slices.Delete(flags, idx, idx+1) strFlags := "{" @@ -43,5 +43,5 @@ func (b Enforce) Apply(profile string) string { // Remove all flags definition, then set new flags profile = regFlags.ReplaceAllLiteralString(profile, "") - return regProfileHeader.ReplaceAllLiteralString(profile, strFlags) + return regProfileHeader.ReplaceAllLiteralString(profile, strFlags), nil } diff --git a/pkg/prebuild/builder/fsp.go b/pkg/prebuild/builder/fsp.go index 07bbbb8a..003f7952 100644 --- a/pkg/prebuild/builder/fsp.go +++ b/pkg/prebuild/builder/fsp.go @@ -28,6 +28,6 @@ func init() { }) } -func (b FullSystemPolicy) Apply(profile string) string { - return regFullSystemPolicy.Replace(profile) +func (b FullSystemPolicy) Apply(opt *Option, profile string) (string, error) { + return regFullSystemPolicy.Replace(profile), nil } diff --git a/pkg/prebuild/builder/userspace.go b/pkg/prebuild/builder/userspace.go index 702fd56d..7060d2b1 100644 --- a/pkg/prebuild/builder/userspace.go +++ b/pkg/prebuild/builder/userspace.go @@ -29,15 +29,26 @@ func init() { }) } -func (b Userspace) Apply(profile string) string { - p := aa.DefaultTunables() - p.ParseVariables(profile) - p.ResolveAttachments() - att := p.NestAttachments() +func (b Userspace) Apply(opt *Option, profile string) (string, error) { + if ok, _ := opt.File.IsInsideDir(cfg.RootApparmord.Join("abstractions")); ok { + return profile, nil + } + if ok, _ := opt.File.IsInsideDir(cfg.RootApparmord.Join("tunables")); ok { + return profile, nil + } + + f := aa.DefaultTunables() + if err := f.Parse(profile); err != nil { + return "", err + } + if err := f.Resolve(); err != nil { + return "", err + } + att := f.GetDefaultProfile().GetAttachments() matches := regAttachments.FindAllString(profile, -1) if len(matches) > 0 { strheader := strings.Replace(matches[0], "@{exec_path}", att, -1) - return regAttachments.ReplaceAllLiteralString(profile, strheader) + return regAttachments.ReplaceAllLiteralString(profile, strheader), nil } - return profile + return profile, nil } diff --git a/pkg/prebuild/directive/core.go b/pkg/prebuild/directive/core.go index 74498484..53176b01 100644 --- a/pkg/prebuild/directive/core.go +++ b/pkg/prebuild/directive/core.go @@ -26,7 +26,7 @@ var ( // Main directive interface type Directive interface { cfg.BaseInterface - Apply(opt *Option, profile string) string + Apply(opt *Option, profile string) (string, error) } // Directive options @@ -72,14 +72,18 @@ func RegisterDirective(d Directive) { Directives[d.Name()] = d } -func Run(file *paths.Path, profile string) string { +func Run(file *paths.Path, profile string) (string, error) { + var err error for _, match := range regDirective.FindAllStringSubmatch(profile, -1) { opt := NewOption(file, match) drtv, ok := Directives[opt.Name] if !ok { - panic(fmt.Sprintf("Unknown directive: %s", opt.Name)) + return "", fmt.Errorf("Unknown directive '%s' in %s", opt.Name, opt.File) + } + profile, err = drtv.Apply(opt, profile) + if err != nil { + return "", fmt.Errorf("%s %s: %w", drtv.Name(), opt.File, err) } - profile = drtv.Apply(opt, profile) } - return profile + return profile, nil } diff --git a/pkg/prebuild/directive/core_test.go b/pkg/prebuild/directive/core_test.go index c30a5afd..faf39df4 100644 --- a/pkg/prebuild/directive/core_test.go +++ b/pkg/prebuild/directive/core_test.go @@ -70,6 +70,7 @@ func TestRun(t *testing.T) { file *paths.Path profile string want string + wantErr bool }{ { name: "none", @@ -86,7 +87,12 @@ func TestRun(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := Run(tt.file, tt.profile); got != tt.want { + got, err := Run(tt.file, tt.profile) + if (err != nil) != tt.wantErr { + t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { t.Errorf("Run() = %v, want %v", got, tt.want) } }) diff --git a/pkg/prebuild/directive/dbus.go b/pkg/prebuild/directive/dbus.go index 2c171624..dc7ac16d 100644 --- a/pkg/prebuild/directive/dbus.go +++ b/pkg/prebuild/directive/dbus.go @@ -50,41 +50,47 @@ func setInterfaces(rules map[string]string) []string { return interfaces } -func (d Dbus) Apply(opt *Option, profile string) string { - var p *aa.AppArmorProfile +func (d Dbus) Apply(opt *Option, profile string) (string, error) { + var r aa.Rules - action := d.sanityCheck(opt) + action, err := d.sanityCheck(opt) + if err != nil { + return "", err + } switch action { case "own": - p = d.own(opt.ArgMap) + r = d.own(opt.ArgMap) case "talk": - p = d.talk(opt.ArgMap) + r = d.talk(opt.ArgMap) } - generatedDbus := p.String() + aa.IndentationLevel = strings.Count( + strings.SplitN(opt.Raw, Keyword, 1)[0], aa.Indentation, + ) + generatedDbus := r.String() lenDbus := len(generatedDbus) generatedDbus = generatedDbus[:lenDbus-1] profile = strings.Replace(profile, opt.Raw, generatedDbus, -1) - return profile + return profile, nil } -func (d Dbus) sanityCheck(opt *Option) string { +func (d Dbus) sanityCheck(opt *Option) (string, error) { if len(opt.ArgList) < 1 { - panic(fmt.Sprintf("Unknown dbus action: %s in %s", opt.Name, opt.File)) + return "", fmt.Errorf("Unknown dbus action: %s in %s", opt.Name, opt.File) } action := opt.ArgList[0] if action != "own" && action != "talk" { - panic(fmt.Sprintf("Unknown dbus action: %s in %s", opt.Name, opt.File)) + return "", fmt.Errorf("Unknown dbus action: %s in %s", opt.Name, opt.File) } if _, present := opt.ArgMap["name"]; !present { - panic(fmt.Sprintf("Missing name for 'dbus: %s' in %s", action, opt.File)) + return "", fmt.Errorf("Missing name for 'dbus: %s' in %s", action, opt.File) } if _, present := opt.ArgMap["bus"]; !present { - panic(fmt.Sprintf("Missing bus for '%s' in %s", opt.ArgMap["name"], opt.File)) + return "", fmt.Errorf("Missing bus for '%s' in %s", opt.ArgMap["name"], opt.File) } if _, present := opt.ArgMap["label"]; !present && action == "talk" { - panic(fmt.Sprintf("Missing label for '%s' in %s", opt.ArgMap["name"], opt.File)) + return "", fmt.Errorf("Missing label for '%s' in %s", opt.ArgMap["name"], opt.File) } // Set default values @@ -92,66 +98,66 @@ func (d Dbus) sanityCheck(opt *Option) string { opt.ArgMap["path"] = "/" + strings.Replace(opt.ArgMap["name"], ".", "/", -1) + "{,/**}" } opt.ArgMap["name"] += "{,.*}" - return action + return action, nil } -func (d Dbus) own(rules map[string]string) *aa.AppArmorProfile { +func (d Dbus) own(rules map[string]string) aa.Rules { interfaces := setInterfaces(rules) - p := &aa.AppArmorProfile{} - p.Rules = append(p.Rules, &aa.Dbus{ - Access: "bind", Bus: rules["bus"], Name: rules["name"], + res := aa.Rules{} + res = append(res, &aa.Dbus{ + Access: []string{"bind"}, Bus: rules["bus"], Name: rules["name"], }) for _, iface := range interfaces { - p.Rules = append(p.Rules, &aa.Dbus{ - Access: "receive", + res = append(res, &aa.Dbus{ + Access: []string{"receive"}, Bus: rules["bus"], Path: rules["path"], Interface: iface, - Name: `":1.@{int}"`, + PeerName: `":1.@{int}"`, }) } for _, iface := range interfaces { - p.Rules = append(p.Rules, &aa.Dbus{ - Access: "send", + res = append(res, &aa.Dbus{ + Access: []string{"send"}, Bus: rules["bus"], Path: rules["path"], Interface: iface, - Name: `"{:1.@{int},org.freedesktop.DBus}"`, + PeerName: `"{:1.@{int},org.freedesktop.DBus}"`, }) } - p.Rules = append(p.Rules, &aa.Dbus{ - Access: "receive", + res = append(res, &aa.Dbus{ + Access: []string{"receive"}, Bus: rules["bus"], Path: rules["path"], Interface: "org.freedesktop.DBus.Introspectable", Member: "Introspect", - Name: `":1.@{int}"`, + PeerName: `":1.@{int}"`, }) - return p + return res } -func (d Dbus) talk(rules map[string]string) *aa.AppArmorProfile { +func (d Dbus) talk(rules map[string]string) aa.Rules { interfaces := setInterfaces(rules) - p := &aa.AppArmorProfile{} + res := aa.Rules{} for _, iface := range interfaces { - p.Rules = append(p.Rules, &aa.Dbus{ - Access: "send", + res = append(res, &aa.Dbus{ + Access: []string{"send"}, Bus: rules["bus"], Path: rules["path"], Interface: iface, - Name: `"{:1.@{int},` + rules["name"] + `}"`, - Label: rules["label"], + PeerName: `"{:1.@{int},` + rules["name"] + `}"`, + PeerLabel: rules["label"], }) } for _, iface := range interfaces { - p.Rules = append(p.Rules, &aa.Dbus{ - Access: "receive", + res = append(res, &aa.Dbus{ + Access: []string{"receive"}, Bus: rules["bus"], Path: rules["path"], Interface: iface, - Name: `"{:1.@{int},` + rules["name"] + `}"`, - Label: rules["label"], + PeerName: `"{:1.@{int},` + rules["name"] + `}"`, + PeerLabel: rules["label"], }) } - return p + return res } diff --git a/pkg/prebuild/directive/dbus_test.go b/pkg/prebuild/directive/dbus_test.go index 6d7c0594..65e55e78 100644 --- a/pkg/prebuild/directive/dbus_test.go +++ b/pkg/prebuild/directive/dbus_test.go @@ -38,6 +38,7 @@ func TestDbus_Apply(t *testing.T) { opt *Option profile string want string + wantErr bool }{ { name: "own", @@ -137,7 +138,12 @@ func TestDbus_Apply(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := Directives["dbus"].Apply(tt.opt, tt.profile); got != tt.want { + got, err := Directives["dbus"].Apply(tt.opt, tt.profile) + if (err != nil) != tt.wantErr { + t.Errorf("Dbus.Apply() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { t.Errorf("Dbus.Apply() = %v, want %v", got, tt.want) } }) diff --git a/pkg/prebuild/directive/exec.go b/pkg/prebuild/directive/exec.go index db08ba6e..3bf89168 100644 --- a/pkg/prebuild/directive/exec.go +++ b/pkg/prebuild/directive/exec.go @@ -2,6 +2,8 @@ // Copyright (C) 2021-2024 Alexandre Pujol // SPDX-License-Identifier: GPL-2.0-only +// TODO: Local variables in profile header need to be resolved + package directive import ( @@ -27,7 +29,7 @@ func init() { }) } -func (d Exec) Apply(opt *Option, profile string) string { +func (d Exec) Apply(opt *Option, profileRaw string) (string, error) { transition := "Px" transitions := []string{"P", "U", "p", "u", "PU", "pu"} t := opt.ArgList[0] @@ -36,26 +38,34 @@ func (d Exec) Apply(opt *Option, profile string) string { delete(opt.ArgMap, t) } - p := &aa.AppArmorProfile{} + rules := aa.Rules{} for name := range opt.ArgMap { profiletoTransition := util.MustReadFile(cfg.RootApparmord.Join(name)) dstProfile := aa.DefaultTunables() - dstProfile.ParseVariables(profiletoTransition) - for _, variable := range dstProfile.Variables { + if err := dstProfile.Parse(profiletoTransition); err != nil { + return "", err + } + if err := dstProfile.Resolve(); err != nil { + return "", err + } + for _, variable := range dstProfile.Preamble.GetVariables() { if variable.Name == "exec_path" { for _, v := range variable.Values { - p.Rules = append(p.Rules, &aa.File{ + rules = append(rules, &aa.File{ Path: v, - Access: transition, + Access: []string{transition}, }) } break } } } - p.Sort() - rules := p.String() - lenRules := len(rules) - rules = rules[:lenRules-1] - return strings.Replace(profile, opt.Raw, rules, -1) + + aa.IndentationLevel = strings.Count( + strings.SplitN(opt.Raw, Keyword, 1)[0], aa.Indentation, + ) + rules = rules.Sort() + new := rules.String() + new = new[:len(new)-1] + return strings.Replace(profileRaw, opt.Raw, new, -1), nil } diff --git a/pkg/prebuild/directive/exec_test.go b/pkg/prebuild/directive/exec_test.go index 8f6c2ebd..c6d4e32a 100644 --- a/pkg/prebuild/directive/exec_test.go +++ b/pkg/prebuild/directive/exec_test.go @@ -18,6 +18,7 @@ func TestExec_Apply(t *testing.T) { opt *Option profile string want string + wantErr bool }{ { name: "exec", @@ -30,8 +31,8 @@ func TestExec_Apply(t *testing.T) { Raw: " #aa:exec DiscoverNotifier", }, profile: ` #aa:exec DiscoverNotifier`, - want: ` @{lib}/@{multiarch}/{,libexec/}DiscoverNotifier Px, - @{lib}/DiscoverNotifier Px,`, + want: ` /{,usr/}lib{,exec,32,64}/*-linux-gnu*/{,libexec/}DiscoverNotifier Px, + /{,usr/}lib{,exec,32,64}/DiscoverNotifier Px,`, }, { name: "exec-unconfined", @@ -44,15 +45,20 @@ func TestExec_Apply(t *testing.T) { Raw: " #aa:exec U polkit-agent-helper", }, profile: ` #aa:exec U polkit-agent-helper`, - want: ` @{lib}/polkit-[0-9]/polkit-agent-helper-[0-9] Ux, - @{lib}/polkit-agent-helper-[0-9] Ux,`, + want: ` /{,usr/}lib{,exec,32,64}/polkit-[0-9]/polkit-agent-helper-[0-9] Ux, + /{,usr/}lib{,exec,32,64}/polkit-agent-helper-[0-9] Ux,`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg.RootApparmord = tt.rootApparmord - if got := Directives["exec"].Apply(tt.opt, tt.profile); got != tt.want { - t.Errorf("Exec.Apply() = %v, want %v", got, tt.want) + got, err := Directives["exec"].Apply(tt.opt, tt.profile) + if (err != nil) != tt.wantErr { + t.Errorf("Exec.Apply() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Exec.Apply() = |%v|, want |%v|", got, tt.want) } }) } diff --git a/pkg/prebuild/directive/filter.go b/pkg/prebuild/directive/filter.go index 305b3b0b..1c90fa76 100644 --- a/pkg/prebuild/directive/filter.go +++ b/pkg/prebuild/directive/filter.go @@ -41,12 +41,12 @@ func filterRuleForUs(opt *Option) bool { return slices.Contains(opt.ArgList, cfg.Distribution) || slices.Contains(opt.ArgList, cfg.Family) } -func filter(only bool, opt *Option, profile string) string { +func filter(only bool, opt *Option, profile string) (string, error) { if only && filterRuleForUs(opt) { - return opt.Clean(profile) + return opt.Clean(profile), nil } if !only && !filterRuleForUs(opt) { - return opt.Clean(profile) + return opt.Clean(profile), nil } inline := true @@ -64,13 +64,13 @@ func filter(only bool, opt *Option, profile string) string { regRemoveParagraph := regexp.MustCompile(`(?s)` + opt.Raw + `\n.*?\n\n`) profile = regRemoveParagraph.ReplaceAllString(profile, "") } - return profile + return profile, nil } -func (d FilterOnly) Apply(opt *Option, profile string) string { +func (d FilterOnly) Apply(opt *Option, profile string) (string, error) { return filter(true, opt, profile) } -func (d FilterExclude) Apply(opt *Option, profile string) string { +func (d FilterExclude) Apply(opt *Option, profile string) (string, error) { return filter(false, opt, profile) } diff --git a/pkg/prebuild/directive/filter_test.go b/pkg/prebuild/directive/filter_test.go index 6ef62c6a..465ba50a 100644 --- a/pkg/prebuild/directive/filter_test.go +++ b/pkg/prebuild/directive/filter_test.go @@ -18,6 +18,7 @@ func TestFilterOnly_Apply(t *testing.T) { opt *Option profile string want string + wantErr bool }{ { name: "inline", @@ -79,7 +80,12 @@ func TestFilterOnly_Apply(t *testing.T) { t.Run(tt.name, func(t *testing.T) { cfg.Distribution = tt.dist cfg.Family = tt.family - if got := Directives["only"].Apply(tt.opt, tt.profile); got != tt.want { + got, err := Directives["only"].Apply(tt.opt, tt.profile) + if (err != nil) != tt.wantErr { + t.Errorf("FilterOnly.Apply() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { t.Errorf("FilterOnly.Apply() = %v, want %v", got, tt.want) } }) @@ -94,6 +100,7 @@ func TestFilterExclude_Apply(t *testing.T) { opt *Option profile string want string + wantErr bool }{ { name: "inline", @@ -128,7 +135,12 @@ func TestFilterExclude_Apply(t *testing.T) { t.Run(tt.name, func(t *testing.T) { cfg.Distribution = tt.dist cfg.Family = tt.family - if got := Directives["exclude"].Apply(tt.opt, tt.profile); got != tt.want { + got, err := Directives["exclude"].Apply(tt.opt, tt.profile) + if (err != nil) != tt.wantErr { + t.Errorf("FilterExclude.Apply() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { t.Errorf("FilterExclude.Apply() = %v, want %v", got, tt.want) } }) diff --git a/pkg/prebuild/directive/stack.go b/pkg/prebuild/directive/stack.go index cb891acc..e0ab9d84 100644 --- a/pkg/prebuild/directive/stack.go +++ b/pkg/prebuild/directive/stack.go @@ -38,13 +38,13 @@ func init() { }) } -func (s Stack) Apply(opt *Option, profile string) string { +func (s Stack) Apply(opt *Option, profile string) (string, error) { res := "" for name := range opt.ArgMap { stackedProfile := util.MustReadFile(cfg.RootApparmord.Join(name)) m := regRules.FindStringSubmatch(stackedProfile) if len(m) < 2 { - panic(fmt.Sprintf("No profile found in %s", name)) + return "", fmt.Errorf("No profile found in %s", name) } stackedRules := m[1] stackedRules = regCleanStakedRules.Replace(stackedRules) @@ -54,9 +54,9 @@ func (s Stack) Apply(opt *Option, profile string) string { // Insert the stacked profile at the end of the current profile, remove the stack directive m := regEndOfRules.FindStringSubmatch(profile) if len(m) <= 1 { - panic(fmt.Sprintf("No end of rules found in %s", opt.File)) + return "", fmt.Errorf("No end of rules found in %s", opt.File) } profile = strings.Replace(profile, m[0], res+m[0], -1) profile = strings.Replace(profile, opt.Raw, "", -1) - return profile + return profile, nil } diff --git a/pkg/prebuild/directive/stack_test.go b/pkg/prebuild/directive/stack_test.go index da3208ab..ef603aae 100644 --- a/pkg/prebuild/directive/stack_test.go +++ b/pkg/prebuild/directive/stack_test.go @@ -18,6 +18,7 @@ func TestStack_Apply(t *testing.T) { opt *Option profile string want string + wantErr bool }{ { name: "stack", @@ -68,7 +69,12 @@ profile parent @{exec_path} { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg.RootApparmord = tt.rootApparmord - if got := Directives["stack"].Apply(tt.opt, tt.profile); got != tt.want { + got, err := Directives["stack"].Apply(tt.opt, tt.profile) + if (err != nil) != tt.wantErr { + t.Errorf("Stack.Apply() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { t.Errorf("Stack.Apply() = %v, want %v", got, tt.want) } }) diff --git a/pkg/prebuild/prebuild.go b/pkg/prebuild/prebuild.go index faf7d914..a0e8117d 100644 --- a/pkg/prebuild/prebuild.go +++ b/pkg/prebuild/prebuild.go @@ -83,10 +83,14 @@ func Build() error { if err != nil { return err } - for _, b := range builder.Builds { - profile = b.Apply(profile) + profile, err = builder.Run(file, profile) + if err != nil { + return err + } + profile, err = directive.Run(file, profile) + if err != nil { + return err } - profile = directive.Run(file, profile) if err := file.WriteFile([]byte(profile)); err != nil { return err } diff --git a/tests/string.aa b/tests/string.aa index 896ac0b0..2ec5d3a6 100644 --- a/tests/string.aa +++ b/tests/string.aa @@ -1,4 +1,5 @@ -# Simple test profile for the AppArmorProfile.String() method +# Simple test profile for the AppArmorProfileFile.String() method + abi , alias /mnt/usr -> /usr, @@ -18,13 +19,13 @@ profile foo @{exec_path} xattrs=(security.tagged=allowed) flags=(complain attach network inet stream, network inet6 stream, - mount fstype=fuse.portal options=(rw rbind) @{run}/user/@{uid}/ -> /, + mount fstype=fuse.portal options=(rw rbind) @{run}/user/@{uid}/ -> /, # failed perms check umount @{run}/user/@{uid}/, - signal (receive) set=(term) peer=at-spi-bus-launcher, + signal receive set=term peer=at-spi-bus-launcher, - ptrace (read) peer=nautilus, + ptrace read peer=nautilus, unix (send receive) type=stream addr=@/tmp/.ICE-unix/1995 peer=(label=gnome-shell, addr=none), diff --git a/tests/testdata/tunables/dir.d/aliases b/tests/testdata/tunables/dir.d/aliases new file mode 100644 index 00000000..61db9635 --- /dev/null +++ b/tests/testdata/tunables/dir.d/aliases @@ -0,0 +1,2 @@ +alias /usr/ -> /User/, +alias /lib/ -> /Libraries/, diff --git a/tests/testdata/tunables/dir.d/vars b/tests/testdata/tunables/dir.d/vars new file mode 100644 index 00000000..505ca6d3 --- /dev/null +++ b/tests/testdata/tunables/dir.d/vars @@ -0,0 +1,2 @@ +# variable declarations for inclusion +@{FOO} = /foo /bar /baz /biff /lib /tmp diff --git a/tests/testdata/tunables/global b/tests/testdata/tunables/global new file mode 100644 index 00000000..145ba3b7 --- /dev/null +++ b/tests/testdata/tunables/global @@ -0,0 +1,3 @@ + +include +