diff --git a/pkg/logs/loggers.go b/pkg/logs/loggers.go new file mode 100644 index 00000000..d304c357 --- /dev/null +++ b/pkg/logs/loggers.go @@ -0,0 +1,85 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2023 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package logs + +import ( + "bytes" + "encoding/json" + "io" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// LogFiles is the list of default path to query +var LogFiles = []string{ + "/var/log/audit/audit.log", + "/var/log/syslog", +} + +// GetAuditLogs return a reader with the logs entries from Auditd +func GetAuditLogs(path string) (io.Reader, error) { + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return nil, err + } + return file, err +} + +// GetJournalctlLogs return a reader with the logs entries from Systemd +func GetJournalctlLogs(path string, useFile bool) (io.Reader, error) { + var logs []SystemdLog + var stdout bytes.Buffer + var value string + + if useFile { + // content, err := os.ReadFile(filepath.Clean(path)) + content, err := ioutil.ReadFile(filepath.Clean(path)) + if err != nil { + return nil, err + } + value = string(content) + } else { + // journalctl -b -o json > systemd.log + cmd := exec.Command("journalctl", "--boot", "--output=json") + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return nil, err + } + value = stdout.String() + } + + value = strings.Replace(value, "\n", ",\n", -1) + value = strings.TrimSuffix(value, ",\n") + value = `[` + value + `]` + if err := json.Unmarshal([]byte(value), &logs); err != nil { + return nil, err + } + res := "" + for _, log := range logs { + res += log.Message + "\n" + } + return strings.NewReader(res), nil +} + +func GetLogFile(path string) string { + info, err := os.Stat(filepath.Clean(path)) + if err == nil && !info.IsDir() { + return path + } + for _, logfile := range LogFiles { + if _, err := os.Stat(logfile); err == nil { + oldLogfile := filepath.Clean(logfile + "." + path) + if _, err := os.Stat(oldLogfile); err == nil { + return oldLogfile + } else { + return logfile + } + } + } + return "" +} diff --git a/pkg/logs/loggers_test.go b/pkg/logs/loggers_test.go new file mode 100644 index 00000000..9d58a50c --- /dev/null +++ b/pkg/logs/loggers_test.go @@ -0,0 +1,85 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2023 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package logs + +import ( + "reflect" + "testing" +) + +func TestGetJournalctlLogs(t *testing.T) { + tests := []struct { + name string + path string + useFile bool + want AppArmorLogs + }{ + { + name: "gsd-xsettings", + useFile: true, + path: "../../tests/systemd.log", + want: AppArmorLogs{ + { + "apparmor": "ALLOWED", + "profile": "", + "label": "gsd-xsettings", + "operation": "dbus_method_call", + "name": ":1.88", + "mask": "receive", + "bus": "session", + "path": "/org/gtk/Settings", + "interface": "org.freedesktop.DBus.Properties", + "member": "GetAll", + "peer_label": "gnome-extension-ding", + }, + }, + }, + { + name: "journalctl", + useFile: false, + path: "", + want: AppArmorLogs{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader, _ := GetJournalctlLogs(tt.path, tt.useFile) + if got := NewApparmorLogs(reader, tt.name); !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewApparmorLogs() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetLogFile(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "Get audit.log", + path: "../../tests/audit.log", + want: "../../tests/audit.log", + }, + { + name: "Get /var/log/audit/audit.log.1", + path: "1", + want: "/var/log/audit/audit.log.1", + }, + { + name: "Get default log file", + path: "", + want: "/var/log/audit/audit.log", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetLogFile(tt.path); got != tt.want { + t.Errorf("getLogFile() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/logs/logs.go b/pkg/logs/logs.go new file mode 100644 index 00000000..199a6384 --- /dev/null +++ b/pkg/logs/logs.go @@ -0,0 +1,179 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2023 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package logs + +import ( + "bufio" + "fmt" + "io" + "regexp" + "strings" + + "github.com/roddhjav/apparmor.d/pkg/util" +) + +// Colors +const ( + Reset = "\033[0m" + FgGreen = "\033[32m" + FgYellow = "\033[33m" + FgBlue = "\033[34m" + FgMagenta = "\033[35m" + FgCian = "\033[36m" + FgWhite = "\033[37m" + BoldRed = "\033[1;31m" + BoldGreen = "\033[1;32m" + BoldYellow = "\033[1;33m" +) + +var ( + quoted bool + isAppArmorLogTemplate = regexp.MustCompile(`apparmor=("DENIED"|"ALLOWED"|"AUDIT")`) + regAALogs = []struct { + regex *regexp.Regexp + repl string + }{ + {regexp.MustCompile(`.*apparmor="`), `apparmor="`}, + {regexp.MustCompile(`(peer_|)pid=[0-9]* `), ""}, + {regexp.MustCompile(` fsuid.*`), ""}, + {regexp.MustCompile(` exe=.*`), ""}, + } + // Apparmor log states + state = map[string]string{ + "DENIED": BoldRed + "DENIED " + Reset, + "ALLOWED": BoldGreen + "ALLOWED" + Reset, + "AUDIT": BoldYellow + "AUDIT " + Reset, + } + // Print order of impression + keys = []string{ + "profile", "label", // Profile name + "operation", "name", + "mask", "bus", "path", "interface", "member", // dbus + "info", "comm", + "laddr", "lport", "faddr", "fport", "family", "sock_type", "protocol", + "requested_mask", "denied_mask", "signal", "peer", // "fsuid", "ouid", "FSUID", "OUID", + } + // Color template to use + colors = map[string]string{ + "profile": FgBlue, + "label": FgBlue, + "operation": FgYellow, + "name": FgMagenta, + "mask": BoldRed, + "bus": FgCian + "bus=", + "path": "path=" + FgWhite, + "requested_mask": "requested_mask=" + BoldRed, + "denied_mask": "denied_mask=" + BoldRed, + "interface": "interface=" + FgWhite, + "member": "member=" + FgGreen, + } +) + +type AppArmorLog map[string]string + +// AppArmorLogs describes all apparmor log entries +type AppArmorLogs []AppArmorLog + +// SystemdLog is a simplified systemd json log representation. +type SystemdLog struct { + Message string `json:"MESSAGE"` +} + +func splitQuoted(r rune) bool { + if r == '"' { + quoted = !quoted + } + return !quoted && r == ' ' +} + +func toQuote(str string) string { + if strings.Contains(str, " ") { + return `"` + str + `"` + } + return str +} + +// NewApparmorLogs return a new ApparmorLogs list of map from a log file +func NewApparmorLogs(file io.Reader, profile string) AppArmorLogs { + log := "" + isAppArmorLog := isAppArmorLogTemplate.Copy() + if profile != "" { + exp := `apparmor=("DENIED"|"ALLOWED"|"AUDIT")` + exp = fmt.Sprintf(exp+`.* (profile="%s.*"|label="%s.*")`, profile, profile) + isAppArmorLog = regexp.MustCompile(exp) + } + + // Select Apparmor logs + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if isAppArmorLog.MatchString(line) { + log += line + "\n" + } + } + + // Clean logs + for _, aa := range regAALogs { + log = aa.regex.ReplaceAllLiteralString(log, aa.repl) + } + + // Remove doublon in logs + logs := strings.Split(log, "\n") + logs = util.RemoveDuplicate(logs) + + // Parse log into ApparmorLog struct + aaLogs := make(AppArmorLogs, 0) + for _, log := range logs { + quoted = false + tmp := strings.FieldsFunc(log, splitQuoted) + + aa := make(AppArmorLog) + for _, item := range tmp { + kv := strings.Split(item, "=") + if len(kv) >= 2 { + aa[kv[0]] = strings.Trim(kv[1], `"`) + } + } + aa["profile"] = util.DecodeHex(aa["profile"]) + toDecode := []string{"name", "comm"} + for _, name := range toDecode { + if value, ok := aa[name]; ok { + aa[name] = util.DecodeHex(value) + } + } + + aaLogs = append(aaLogs, aa) + } + + return aaLogs +} + +// String returns a formatted AppArmor logs string +func (aaLogs AppArmorLogs) String() string { + res := "" + for _, log := range aaLogs { + seen := map[string]bool{"apparmor": true} + res += state[log["apparmor"]] + + for _, key := range keys { + if log[key] != "" { + if colors[key] != "" { + res += " " + colors[key] + toQuote(log[key]) + Reset + } else { + res += " " + key + "=" + toQuote(log[key]) + } + seen[key] = true + } + } + + for key, value := range log { + if !seen[key] && value != "" { + res += " " + key + "=" + toQuote(value) + } + } + res += "\n" + } + return res +} diff --git a/pkg/logs/logs_test.go b/pkg/logs/logs_test.go new file mode 100644 index 00000000..69608d2b --- /dev/null +++ b/pkg/logs/logs_test.go @@ -0,0 +1,263 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2023 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package logs + +import ( + "os" + "reflect" + "strings" + "testing" +) + +var refKmod = AppArmorLogs{ + { + "apparmor": "ALLOWED", + "profile": "kmod", + "operation": "file_inherit", + "comm": "modprobe", + "family": "unix", + "sock_type": "stream", + "protocol": "0", + "requested_mask": "send receive", + }, +} + +var refMan = AppArmorLogs{ + { + "apparmor": "ALLOWED", + "profile": "man", + "operation": "exec", + "name": "/usr/bin/preconv", + "info": "no new privs", + "comm": "man", + "requested_mask": "x", + "denied_mask": "x", + "error": "-1", + }, +} + +func TestAppArmorEvents(t *testing.T) { + tests := []struct { + name string + event string + want AppArmorLogs + }{ + { + name: "event_audit_1", + event: `type=AVC msg=audit(1345027352.096:499): apparmor="ALLOWED" operation="rename_dest" parent=6974 profile="/usr/sbin/httpd2-prefork//vhost_foo" name=2F686F6D652F7777772F666F6F2E6261722E696E2F68747470646F63732F61707061726D6F722F696D616765732F746573742F696D61676520312E6A7067 pid=20143 comm="httpd2-prefork" requested_mask="wc" denied_mask="wc" fsuid=30 ouid=30`, + want: AppArmorLogs{ + { + "apparmor": "ALLOWED", + "profile": "/usr/sbin/httpd2-prefork//vhost_foo", + "operation": "rename_dest", + "name": "/home/www/foo.bar.in/httpdocs/apparmor/images/test/image 1.jpg", + "comm": "httpd2-prefork", + "requested_mask": "wc", + "denied_mask": "wc", + "parent": "6974", + }, + }, + }, + { + name: "event_audit_2", + event: `type=AVC msg=audit(1322614918.292:4376): apparmor="ALLOWED" operation="file_perm" parent=16001 profile=666F6F20626172 name="/home/foo/.bash_history" pid=17011 comm="bash" requested_mask="rw" denied_mask="rw" fsuid=0 ouid=1000`, + want: AppArmorLogs{ + { + "apparmor": "ALLOWED", + "profile": "foo bar", + "operation": "file_perm", + "name": "/home/foo/.bash_history", + "comm": "bash", + "requested_mask": "rw", + "denied_mask": "rw", + "parent": "16001", + }, + }, + }, + { + name: "disconnected_path", + event: `type=AVC msg=audit(1424425690.883:716630): apparmor="ALLOWED" operation="file_mmap" info="Failed name lookup - disconnected path" error=-13 profile="/sbin/klogd" name="var/run/nscd/passwd" pid=25333 comm="id" requested_mask="r" denied_mask="r" fsuid=1002 ouid=0`, + want: AppArmorLogs{ + { + "apparmor": "ALLOWED", + "profile": "/sbin/klogd", + "operation": "file_mmap", + "name": "var/run/nscd/passwd", + "comm": "id", + "info": "Failed name lookup - disconnected path", + "requested_mask": "r", + "denied_mask": "r", + "error": "-13", + }, + }, + }, + { + name: "dbus_system", + event: `type=USER_AVC msg=audit(1111111111.111:1111): pid=1780 uid=102 auid=4294967295 ses=4294967295 subj=? msg='apparmor="ALLOWED" operation="dbus_method_call" bus="system" path="/org/freedesktop/PolicyKit1/Authority" interface="org.freedesktop.PolicyKit1.Authority" member="CheckAuthorization" mask="send" name="org.freedesktop.PolicyKit1" pid=1794 label="snapd" peer_pid=1790 peer_label="polkitd" exe="/usr/bin/dbus-daemon" sauid=102 hostname=? addr=? terminal=?'UID="messagebus" AUID="unset" SAUID="messagebus"`, + want: AppArmorLogs{ + { + "apparmor": "ALLOWED", + "profile": "", + "label": "snapd", + "operation": "dbus_method_call", + "name": "org.freedesktop.PolicyKit1", + "mask": "send", + "bus": "system", + "path": "/org/freedesktop/PolicyKit1/Authority", + "interface": "org.freedesktop.PolicyKit1.Authority", + "member": "CheckAuthorization", + "peer_label": "polkitd", + }, + }, + }, + { + name: "dbus_session", + event: `apparmor="ALLOWED" operation="dbus_bind" bus="session" name="org.freedesktop.portal.Documents" mask="bind" pid=2174 label="xdg-document-portal"`, + want: AppArmorLogs{ + { + "apparmor": "ALLOWED", + "profile": "", + "label": "xdg-document-portal", + "operation": "dbus_bind", + "name": "org.freedesktop.portal.Documents", + "mask": "bind", + "bus": "session", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file := strings.NewReader(tt.event) + if got := NewApparmorLogs(file, ""); !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewApparmorLogs() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNewApparmorLogs(t *testing.T) { + tests := []struct { + name string + path string + want AppArmorLogs + }{ + { + name: "dnsmasq", + path: "../../tests/audit.log", + want: AppArmorLogs{ + { + "apparmor": "DENIED", + "profile": "dnsmasq", + "operation": "open", + "name": "/proc/sys/kernel/osrelease", + "comm": "dnsmasq", + "requested_mask": "r", + "denied_mask": "r", + }, + { + "apparmor": "DENIED", + "profile": "dnsmasq", + "operation": "open", + "name": "/proc/1/environ", + "comm": "dnsmasq", + "requested_mask": "r", + "denied_mask": "r", + }, + { + "apparmor": "DENIED", + "profile": "dnsmasq", + "operation": "open", + "name": "/proc/cmdline", + "comm": "dnsmasq", + "requested_mask": "r", + "denied_mask": "r", + }, + }, + }, + { + name: "kmod", + path: "../../tests/audit.log", + want: refKmod, + }, + { + name: "man", + path: "../../tests/audit.log", + want: refMan, + }, + { + name: "power-profiles-daemon", + path: "../../tests/audit.log", + want: AppArmorLogs{ + { + "apparmor": "ALLOWED", + "profile": "", + "label": "power-profiles-daemon", + "operation": "dbus_method_call", + "name": "org.freedesktop.DBus", + "mask": "send", + "bus": "system", + "path": "/org/freedesktop/DBus", + "interface": "org.freedesktop.DBus", + "member": "AddMatch", + "peer_label": "dbus-daemon", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file, _ := os.Open(tt.path) + if got := NewApparmorLogs(file, tt.name); !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewApparmorLogs() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAppArmorLogs_String(t *testing.T) { + tests := []struct { + name string + aaLogs AppArmorLogs + want string + }{ + { + name: "kmod", + aaLogs: refKmod, + want: "\033[1;32mALLOWED\033[0m \033[34mkmod\033[0m \033[33mfile_inherit\033[0m comm=modprobe family=unix sock_type=stream protocol=0 requested_mask=\033[1;31m\"send receive\"\033[0m\n", + }, + { + name: "man", + aaLogs: refMan, + want: "\033[1;32mALLOWED\033[0m \033[34mman\033[0m \033[33mexec\033[0m \033[35m/usr/bin/preconv\033[0m info=\"no new privs\" comm=man requested_mask=\033[1;31mx\033[0m denied_mask=\033[1;31mx\033[0m error=-1\n", + }, + { + name: "power-profiles-daemon", + aaLogs: AppArmorLogs{ + { + "apparmor": "ALLOWED", + "profile": "", + "label": "power-profiles-daemon", + "operation": "dbus_method_call", + "name": "org.freedesktop.DBus", + "mask": "send", + "bus": "system", + "path": "/org/freedesktop/DBus", + "interface": "org.freedesktop.DBus", + "member": "AddMatch", + "peer_label": "dbus-daemon", + }, + }, + want: "\033[1;32mALLOWED\033[0m \033[34mpower-profiles-daemon\033[0m \033[33mdbus_method_call\033[0m \033[35morg.freedesktop.DBus\033[0m \033[1;31msend\033[0m \033[36mbus=system\033[0m path=\033[37m/org/freedesktop/DBus\033[0m interface=\033[37morg.freedesktop.DBus\033[0m member=\033[32mAddMatch\033[0m peer_label=dbus-daemon\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.aaLogs.String(); got != tt.want { + t.Errorf("AppArmorLogs.String() = %v, want %v len: %d - %d", got, tt.want, len(got), len(tt.want)) + } + }) + } +}