From 9fc6dd4c1f81d34216e62a03b7a0ad1d2e8cd321 Mon Sep 17 00:00:00 2001 From: Andrew Walker Date: Tue, 17 Dec 2024 07:30:07 -0600 Subject: [PATCH] Add script to parse output of the auditd af_unix socket This converts auditd messages into our specified log file format for system logs and submits to syslog-ng for database insertion. A few audit keys are also changed to correspond with event types registered for auditd. --- debian/control | 2 +- debian/rules | 6 + debian/truenas-audit-rules.lintian-overrides | 2 + plugins/af_unix.conf | 12 + rules/30-stig.rules | 60 +- scripts/truenas_audit_handler.py | 602 +++++++++++++++++++ systemd/tnaudit.service | 19 + 7 files changed, 679 insertions(+), 24 deletions(-) create mode 100644 plugins/af_unix.conf create mode 100755 scripts/truenas_audit_handler.py create mode 100644 systemd/tnaudit.service diff --git a/debian/control b/debian/control index 797ce8c..b7299f5 100644 --- a/debian/control +++ b/debian/control @@ -8,6 +8,6 @@ Homepage: http://www.truenas.com Package: truenas-audit-rules Architecture: any -Depends: truenas-files, ${misc:Depends}, ${shlibs:Depends} +Depends: python3:any, truenas-files, ${misc:Depends}, ${shlibs:Depends} Description: Auditd rules for TrueNAS when STIG compatibiliy mode enabled. diff --git a/debian/rules b/debian/rules index acf3b6b..d221b21 100644 --- a/debian/rules +++ b/debian/rules @@ -8,6 +8,12 @@ override_dh_auto_install: sh -c "\ mkdir -p debian/truenas-audit-rules/conf/audit_rules/; \ cp -a rules/* debian/truenas-audit-rules/conf/audit_rules/; \ + mkdir -p debian/truenas-audit-rules/conf/audit_plugins/; \ + cp -a plugins/* debian/truenas-audit-rules/conf/audit_plugins/; \ + mkdir -p debian/truenas-audit-rules/usr/local/libexec/; \ + cp -a scripts/* debian/truenas-audit-rules/usr/local/libexec/; \ + mkdir -p debian/truenas-audit-rules/lib/systemd/system/; \ + cp -a systemd/* debian/truenas-audit-rules/lib/systemd/system/; \ " override_dh_fixperms: diff --git a/debian/truenas-audit-rules.lintian-overrides b/debian/truenas-audit-rules.lintian-overrides index 1310d8a..62c780e 100644 --- a/debian/truenas-audit-rules.lintian-overrides +++ b/debian/truenas-audit-rules.lintian-overrides @@ -1,3 +1,5 @@ file-in-unusual-dir no-copyright-file non-standard-toplevel-dir +dir-in-usr-local +file-in-usr-local diff --git a/plugins/af_unix.conf b/plugins/af_unix.conf new file mode 100644 index 0000000..ae52450 --- /dev/null +++ b/plugins/af_unix.conf @@ -0,0 +1,12 @@ +# This file controls the configuration of the +# af_unix socket plugin. It simply takes events +# and writes them to a unix domain socket. This +# plugin can take 2 arguments, the path for the +# socket and the socket permissions in octal. + +active = yes +direction = out +path = builtin_af_unix +type = builtin +args = 0600 /var/run/audispd_events +format = string diff --git a/rules/30-stig.rules b/rules/30-stig.rules index 3cd69e8..043f649 100644 --- a/rules/30-stig.rules +++ b/rules/30-stig.rules @@ -18,16 +18,16 @@ -a always,exit -F arch=b64 -F path=/etc/localtime -F perm=wa -F key=time-change ## Things that affect identity --a always,exit -F arch=b32 -F path=/etc/group -F perm=wa -F auid>0 -F key=identity --a always,exit -F arch=b64 -F path=/etc/group -F perm=wa -F auid>0 -F key=identity --a always,exit -F arch=b32 -F path=/etc/passwd -F perm=wa -F auid>0 -F key=identity --a always,exit -F arch=b64 -F path=/etc/passwd -F perm=wa -F auid>0 -F key=identity --a always,exit -F arch=b32 -F path=/etc/gshadow -F perm=wa -F auid>0 -F key=identity --a always,exit -F arch=b64 -F path=/etc/gshadow -F perm=wa -F auid>0 -F key=identity --a always,exit -F arch=b32 -F path=/etc/shadow -F perm=wa -F auid>0 -F key=identity --a always,exit -F arch=b64 -F path=/etc/shadow -F perm=wa -F auid>0 -F key=identity --a always,exit -F arch=b32 -F path=/etc/security/opasswd -F auid>0 -F perm=wa -F key=identity --a always,exit -F arch=b64 -F path=/etc/security/opasswd -F auid>0 -F perm=wa -F key=identity +-a always,exit -F arch=b32 -F path=/etc/group -F perm=wa -F auid!=unset -F key=identity +-a always,exit -F arch=b64 -F path=/etc/group -F perm=wa -F auid!=unset -F key=identity +-a always,exit -F arch=b32 -F path=/etc/passwd -F perm=wa -F auid!=unset -F key=identity +-a always,exit -F arch=b64 -F path=/etc/passwd -F perm=wa -F auid!=unset -F key=identity +-a always,exit -F arch=b32 -F path=/etc/gshadow -F perm=wa -F key=identity +-a always,exit -F arch=b64 -F path=/etc/gshadow -F perm=wa -F key=identity +-a always,exit -F arch=b32 -F path=/etc/shadow -F perm=wa -F auid!=unset -F key=identity +-a always,exit -F arch=b64 -F path=/etc/shadow -F perm=wa -F auid!=unset -F key=identity +-a always,exit -F arch=b32 -F path=/etc/security/opasswd -F perm=wa -F key=identity +-a always,exit -F arch=b64 -F path=/etc/security/opasswd -F perm=wa -F key=identity ## Things that could affect system locale -a always,exit -F arch=b32 -S sethostname,setdomainname -F key=system-locale @@ -108,14 +108,14 @@ ## You have to mount media before using it. You must disable all automounting ## so that its done manually in order to get the correct user requesting the ## export --a always,exit -F arch=b32 -S mount -F auid>=900 -F auid!=unset -F key=export --a always,exit -F arch=b64 -S mount -F auid>=900 -F auid!=unset -F key=export +-a always,exit -F arch=b32 -S mount -F auid!=unset -F key=export +-a always,exit -F arch=b64 -S mount -F auid!=unset -F key=export ##- System startup and shutdown (unsuccessful and successful) ##- Files and programs deleted by the user (successful and unsuccessful) --a always,exit -F arch=b32 -S unlink,unlinkat,rename,renameat -F auid>=900 -F auid!=unset -F key=delete --a always,exit -F arch=b64 -S unlink,unlinkat,rename,renameat -F auid>=900 -F auid!=unset -F key=delete +#-a always,exit -F arch=b32 -S unlink,unlinkat,rename,renameat -F auid>900 -F auid!=unset -F key=delete +#-a always,exit -F arch=b64 -S unlink,unlinkat,rename,renameat -F auid>900 -F auid!=unset -F key=delete ##- All system administration actions ##- All security personnel actions @@ -124,18 +124,32 @@ ## If that is not found, use sudo which should be patched to record its ## commands to the audit system. Do not allow unrestricted root shells or ## sudo cannot record the action. --a always,exit -F arch=b32 -F path=/etc/sudoers -F perm=wa -F key=actions --a always,exit -F arch=b64 -F path=/etc/sudoers -F perm=wa -F key=actions --a always,exit -F arch=b32 -F dir=/etc/sudoers.d/ -F perm=wa -F key=actions --a always,exit -F arch=b64 -F dir=/etc/sudoers.d/ -F perm=wa -F key=actions +-a always,exit -F arch=b32 -F path=/etc/sudoers -F perm=wa -F key=escalation +-a always,exit -F arch=b64 -F path=/etc/sudoers -F perm=wa -F key=escalation +-a always,exit -F arch=b32 -F dir=/etc/sudoers.d/ -F perm=wa -F key=escalation +-a always,exit -F arch=b64 -F dir=/etc/sudoers.d/ -F perm=wa -F key=escalation ## Special case for systemd-run. It is not audit aware, specifically watch it --a always,exit -F arch=b32 -F path=/usr/bin/systemd-run -F perm=x -F auid!=unset -F key=maybe-escalation --a always,exit -F arch=b64 -F path=/usr/bin/systemd-run -F perm=x -F auid!=unset -F key=maybe-escalation +-a always,exit -F arch=b32 -F path=/usr/bin/systemd-run -F perm=x -F auid!=unset -F key=escalation +-a always,exit -F arch=b64 -F path=/usr/bin/systemd-run -F perm=x -F auid!=unset -F key=escalation ## Always audit call to disable rootfs protection --a always,exit -F arch=b64 -F path=/usr/local/libexec/disable-rootfs-protection -F perm=x -F key=maybe-escalation +-a always,exit -F arch=b64 -F path=/usr/local/libexec/disable-rootfs-protection -F perm=x -F key=escalation ## ZFS-related binares can also be used to bypass system protections. --a always,exit -F arch=b64 -F path=/sbin/zfs -F perm=x -F auid>0 -F key=maybe-escalation --a always,exit -F arch=b64 -F path=/sbin/zpool -F perm=x -F auid>0 -F key=maybe-escalation +-a always,exit -F arch=b64 -F path=/sbin/zfs -F perm=x -F auid!=unset -F key=escalation +-a always,exit -F arch=b64 -F path=/sbin/zpool -F perm=x -F auid!=unset -F key=escalation + +## TrueNAS configuration +-a always,exit -F arch=b64 -F dir=/data/ -F perm=r -F auid!=unset -F key=escalation +-a always,exit -F arch=b64 -F dir=/var/run/middleware/ -F perm=r -F auid!=unset -F key=escalation +-a always,exit -F arch=b64 -F dir=/var/db/system/ -F perm=r -F auid!=unset -F key=escalation +-a always,exit -F arch=b64 -F dir=/var/run/samba-cache/ -F perm=r -F auid!=unset -F key=escalation + +## Server configuration +-a always,exit -F arch=b64 -F path=/etc/exports -F perm=wa -F auid!=unset -F key=export +-a always,exit -F arch=b64 -F dir=/etc/exports.d -F perm=wa -F auid!=unset -F key=export +-a always,exit -F arch=b64 -F path=/etc/smb4.conf -F perm=wa -F auid!=unset -F key=export +-a always,exit -F arch=b64 -F path=/etc/proftpd/proftpd.conf -F perm=wa -F auid!=unset -F key=export +-a always,exit -F arch=b64 -F dir=/etc/proftpd/conf.d -F perm=wa -F auid!=unset -F key=export +-a always,exit -F arch=b64 -F path=/etc/scst.conf -F perm=wa -F auid!=unset -F key=export diff --git a/scripts/truenas_audit_handler.py b/scripts/truenas_audit_handler.py new file mode 100755 index 0000000..fe29f6f --- /dev/null +++ b/scripts/truenas_audit_handler.py @@ -0,0 +1,602 @@ +#!/usr/bin/python3 + +import argparse +import asyncio +import enum +import logging +import logging.handlers +import os +import signal +import stat + +from codecs import decode +from dataclasses import dataclass, field +from datetime import datetime +from collections import defaultdict, deque +from json import dumps +from middlewared.logger import TNSyslogHandler +from queue import Queue +from random import getrandbits +from uuid import UUID + + +DESCRIPTION = ( + 'Process audit messages in real time from the auditd dispatch unix domain ' + 'socket and write them to the syslog-ng handler, and if required raise ' + 'middlewared alerts for high priority items.' +) + +DEFAULT_AUDISPD_SOCK = '/var/run/audispd_events' +DEFAULT_SYSLOG_SOCK = '/var/run/syslog-ng/auditd.sock' +DEFAULT_RECOVERY_FILE = '/var/run/middleware/.auditd_handler.recovery' +SYSLOG_IDENT = 'TNAUDIT_SYSTEM: ' +AUDITD_LINE_SEPARATOR = '\x1d' +AUDITD_NULL_VALUES = frozenset(['(null)', '(none)']) +JSON_NULL = 'null' + +# TODO: generate critical middleware alert if our backlog starts to hit +# critical levels +ALERT_QUEUE_DEPTH = 1024 + + +class AuditMsgParser(enum.Enum): + @property + def idx(self) -> int: + return self.value[0] + + @property + def data_type(self) -> type: + return self.value[1] + + def get_entry(self, data: list[str]) -> tuple: + key, value = data[self.idx].split('=', 1) + if self.data_type is str: + # possibly strip leading and trailing quotes + if value[0] == '"': + value = value[1:] + if value[-1] == '"': + value = value[0:-1] + + # We may have literal string denoting a NULL value change back to + # python None type, which will then be encoded as JSON NULL when + # encoded for DB insertion. + if value in AUDITD_NULL_VALUES: + value = None + + return (key, value) + + elif self.data_type is bool: + return (key, value == "yes") + + return (key, int(value)) + + +class AuditMsgBase(AuditMsgParser): + TYPE = (0, str) + ID = (1, str) + + def get_entry(self, data: list[str]) -> tuple: + if self is AuditMsgBase.TYPE: + return super().get_entry(data) + + key, value = data[self.idx].split('=', 1) + # ID has a trailing colon ":" that needs to be stripped + return (key, value[0: -1]) + + +def get_msg_type(data: list[str]) -> str: + key, value = AuditMsgBase.TYPE.get_entry(data) + return value + + +def get_msg_id(data: list[str]) -> str: + key, value = AuditMsgBase.ID.get_entry(data) + return value + + +class AuditMsgPath(AuditMsgParser): + """ + Parser for path type entry + + Sample entry: + "type=PATH msg=audit(1734547436.320:852): item=1 name=\"/usr/local/libexec/disable-rootfs-protection\" inode=46471 dev=00:23 mode=0100755 ouid=0 ogid=0 rdev=00:00 nametype=NORMAL cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0 cap_frootid=0 OUID=\"root\" OGID=\"root\"" # noqa + """ + NAME = (3, str) + INODE = (4, int) + DEV = (5, str) + MODE = (6, str) + OUID = (7, int) + OGID = (8, int) + RDEV = (9, str) + + +class AuditMsgProctitle(AuditMsgParser): + """ + Parser for PROCTITLE type messages + + Sample entry: + "type=PROCTITLE msg=audit(1734547436.320:852): proctitle=2F7573722F62696E2F707974686F6E33002F7573722F6C6F63616C2F6C6962657865632F64697361626C652D726F6F7466732D70726F74656374696F6E" # noqa + """ + PROCTITLE = (2, str) + + def get_entry(self, data: list[str]) -> tuple: + key, value = super().get_entry(data) + + # Although userspace library guidelines state to hex-encode this value + # some libaudit consumers break (notably pam_tty_audit) break this expectation. + # If we fail to decode simply put original string in message. + try: + proc = decode(value, 'hex').decode().replace('\x00', ' ') + except Exception: + proc = value + + return (key, proc) + + +class AuditMsgCwd(AuditMsgParser): + """ + Parser for CWD type messages + + Sample entry: + "type=CWD msg=audit(1734547436.320:852): cwd=\"/root\"" + """ + CWD = (2, str) + + +class AuditMsgSyscall(AuditMsgParser): + """ + Parser for SYSCALL type messages + + Sample entry: + "type=SYSCALL msg=audit(1734547436.320:852): arch=c000003e syscall=59 success=yes exit=0 a0=7fb27f458c70 a1=7fb27f458ce0 a2=56289c566760 a3=8 items=4 ppid=10424 pid=11969 auid=0 uid=0 gid=0 euid=0 suid=0 fsuid=0 egid=0 sgid=0 fsgid=0 tty=pts2 ses=12 comm=\"disable-rootfs-\" exe=\"/usr/bin/python3.11\" subj=unconfined key=\"escalation\" ARCH=x86_64 SYSCALL=execve AUID=\"root\" UID=\"root\" GID=\"root\" EUID=\"root\" SUID=\"root\" FSUID=\"root\" EGID=\"root\" SGID=\"root\" FSGID=\"root\" # noqa + """ + SUCCESS = (4, bool) + EXIT = (5, int) + PPID = (11, int) + PID = (12, int) + AUID = (13, int) + UID = (14, int) + GID = (15, int) + EUID = (16, int) + SUID = (17, int) + FSUID = (18, int) + EGID = (19, int) + SGID = (20, int) + FSGID = (21, int) + TTY = (22, str) + SES = (23, int) + KEY = (27, str) + SYSCALL_STR = (29, str) + AUID_STR = (30, str) + UID_STR = (31, str) + GID_STR = (32, str) + + +class AuditMsgSyscallNoRval(AuditMsgParser): + """ + Some syscall entries do not have a proper exit code + + Sample entry: + type=SYSCALL msg=audit(1735072331.659:2032): arch=c000003e syscall=231 a0=0 a1=e7 a2=3c a3=7ffd914e4b20 items=0 ppid=42401 pid=42411 auid=0 uid=0 gid=0 euid=0 suid=0 fsuid=0 egid=0 sgid=0 fsgid=0 tty=pts1 ses=33 comm="zsh" exe="/usr/bin/zsh" subj=unconfined key=(null) ARCH=x86_64 SYSCALL=exit_group AUID="root" UID="root" GID="root" EUID="root" SUID="root" FSUID="root" EGID="root" SGID="root" FSGID="root" # noqa + """ + PPID = (9, int) + PID = (10, int) + AUID = (11, int) + UID = (12, int) + GID = (13, int) + EUID = (14, int) + SUID = (15, int) + FSUID = (16, int) + EGID = (17, int) + SGID = (18, int) + FSGID = (19, int) + TTY = (20, str) + SES = (21, int) + EXE = (23, str) + KEY = (25, str) + SYSCALL_STR = (27, str) + AUID_STR = (28, str) + UID_STR = (29, str) + GID_STR = (30, str) + + +class AuditMsgLogin(AuditMsgParser): + """ + Parser for LOGIN type messages + + Sample entry: + type=LOGIN msg=audit(1735069956.674:1968): pid=38804 uid=0 subj=unconfined old-auid=4294967295 auid=0 tty=(none) old-ses=4294967295 ses=28 res=1 UID="root" OLD-AUID="unset" AUID="root" # noqa + """ + OLD_AUID = (5, int) + NEW_AUID = (6, int) + TTY = (7, str) + OLD_SES = (8, int) + NEW_SES = (9, int) + RES = (10, int) + + +class AuditMsgEventType(enum.StrEnum): + LOGIN = 'LOGIN' + PROCTITLE = 'PROCTITLE' + PATH = 'PATH' + CWD = 'CWD' + EXECVE = 'EXECVE' + SYSCALL = 'SYSCALL' + CONFIG_CHANGE = 'CONFIG_CHANGE' + EOE = 'EOE' + BPF = 'BPF' + TTY = 'TTY' + + +class AuditEvent(enum.StrEnum): + PRIVILEGED = 'privileged' + ESCALATION = 'escalation' + EXPORT = 'export' + IDENTITY = 'identity' + TIMECHANGE = 'time-change' + MODULE = 'module-load' + GENERIC = 'generic' + LOGIN = 'login' + + +def get_audit_event(parts: list[str]) -> AuditEvent | None: + # only syscall events will have the key loaded + if get_msg_type(parts) != 'SYSCALL': + return None + + # Some syscalls may not have a return value in the audit entry + # The simplest way to determine which type of record we have is to + # read the beginning the string at the SUCCESS offset. + if not parts[AuditMsgSyscall.SUCCESS.idx].startswith('success'): + msg_obj = AuditMsgSyscallNoRval + else: + msg_obj = AuditMsgSyscall + + key, value = msg_obj.KEY.get_entry(parts) + if value is None: + return AuditEvent.GENERIC + + return AuditEvent(value) + + +@dataclass(slots=True) +class AUDITEntry: + event_type: AuditEvent | None = None + key_event: str | None = None + raw_lines: list[str] = field(default_factory=list) + + +MULTIPART_EVENT = frozenset([ + AuditMsgEventType.PROCTITLE, + AuditMsgEventType.PATH, + AuditMsgEventType.CWD, + AuditMsgEventType.EXECVE, + AuditMsgEventType.SYSCALL, + AuditMsgEventType.CONFIG_CHANGE, + AuditMsgEventType.EOE, + AuditMsgEventType.BPF, + AuditMsgEventType.LOGIN, + AuditMsgEventType.TTY, +]) + + +def __parse_cwd(msg_parts: list, event_data: dict) -> None: + key, cwd = AuditMsgCwd.CWD.get_entry(msg_parts) + event_data['cwd'] = cwd + + +def __parse_path(msg_parts: list, paths: list) -> None: + path_entry = {} + + # deliberately leave off the item number from the line since it + # can be inferred from array index. + for item in AuditMsgPath: + key, value = item.get_entry(msg_parts) + path_entry[key] = value + + paths.append(path_entry) + + +def __parse_proctitle(msg_parts: list, event_data: dict) -> None: + key, proctitle = AuditMsgProctitle.PROCTITLE.get_entry(msg_parts) + event_data['proctitle'] = proctitle + + +def __parse_syscall(msg_parts: list, event_data: dict) -> None: + if event_data['syscall'] is not None: + return + + event_data['syscall'] = {} + + # Some syscalls may not have a return value in the audit entry + # The simplest way to determine which type of record we have is to + # read the beginning the string at the SUCCESS offset. + if not msg_parts[AuditMsgSyscall.SUCCESS.idx].startswith('success'): + msg_obj = AuditMsgSyscallNoRval + else: + msg_obj = AuditMsgSyscall + + for item in msg_obj: + key, value = item.get_entry(msg_parts) + event_data['syscall'][key] = value + + +def __parse_login(msg_parts: list, event_data: dict) -> None: + event_data['login'] = {} + + for item in AuditMsgLogin: + key, value = item.get_entry(msg_parts) + event_data['login'][key] = value + + +def __parse_raw_msg(msg: str, event_data: dict): + # We can include inferred items in our entry + parts = msg.split() + match get_msg_type(parts): + case 'PATH': + return __parse_path(parts, event_data['paths']) + case 'PROCTITLE': + return __parse_proctitle(parts, event_data) + case 'CWD': + return __parse_cwd(parts, event_data) + case 'SYSCALL': + return __parse_syscall(parts, event_data) + case 'LOGIN': + return __parse_login(parts, event_data) + case _: + pass + + +def __generate_event_data( + entry: AUDITEntry, + data_out: dict +) -> None: + + data_out['event'] = data_out['event'].upper() + raw_lines = entry.raw_lines + + if entry.key_event: + key, user = AuditMsgSyscall.UID_STR.get_entry(entry.key_event) + data_out['user'] = user + + key, success = AuditMsgSyscall.SUCCESS.get_entry(entry.key_event) + data_out['success'] = success + data_out['event_data']['raw_lines'] = None + + for item in raw_lines: + __parse_raw_msg(item, data_out['event_data']) + + +def __parse_msgid(msgid: str, entry_data: dict): + """ + msgid is string such as audit(1734419821.939:3615). The part before the `:` + character is a timestamp and the part after it is the audit event id. + We need to convert this string into a UUID for the audit event. + + Unfortunately the audit event id is only an unsigned int, and so it's not + actually universally unique and potentialy not unique over time. + + We convert this into a UUID by moving the timestamp to upper 64 bits of a + 128 bit integer, using the audit event id as the bottom 32 bits, and then + placing random 32 bits in the middle of it. + """ + msgid = msgid.split('(')[1].strip(')') + timestamp, eventid = msgid.split(':') + ts_datetime = datetime.fromtimestamp(float(timestamp)) + + upper_64 = int(timestamp.replace('.', '')) << 64 + lower_32 = int(eventid) + mid_32 = getrandbits(32) << 32 + + entry_data['time'] = ts_datetime.strftime('%Y-%m-%d %H:%M:%S.%f') + entry_data['aid'] = str(UUID(int=upper_64 + lower_32 + mid_32)) + + +def audit_entry_to_json(msgid: str, entry: AUDITEntry) -> str: + to_write = {'TNAUDIT': { + 'aid': None, + 'vers': {'major': 0, 'minor': 1}, + 'addr': '127.0.0.1', + 'user': None, + 'sess': None, + 'time': None, + 'svc': 'SYSTEM', + 'svc_data': JSON_NULL, # per our NEP null is OK here + 'event': entry.event_type or AuditEvent.GENERIC, + 'event_data': { + 'audit_msg_id_str': msgid, + 'proctitle': None, + 'syscall': None, + 'login': None, + 'cwd': None, + 'paths': [], + 'raw_lines': entry.raw_lines + }, + 'success': True + }} + + __parse_msgid(msgid, to_write['TNAUDIT']) + __generate_event_data(entry, to_write['TNAUDIT']) + + # Message may contain login operation. In this case flag as a login + # event if it is currently identified as a GENERIC event + if to_write['TNAUDIT']['event_data']['login']: + if to_write['TNAUDIT']['event'] == AuditEvent.GENERIC: + to_write['TNAUDIT']['event'] = AuditEvent.LOGIN + + to_write['TNAUDIT']['event_data'] = dumps(to_write['TNAUDIT']['event_data']) + + return '@cee:' + dumps(to_write) + + +class AuditdHandler: + def __init__( + self, + audis_sock: str, + syslog_sock: str, + recovery_file: str, + loop: asyncio.AbstractEventLoop + ): + self.exit = False + self.loop = loop + self.logger = None + self.syslog_handler = None + self.audis_path = audis_sock + self.syslog_path = syslog_sock + self.recovery_file = recovery_file + self.syslog_queue_listener = None + self.audis_reader = None + self.audis_writer = None + self.partial_records = defaultdict(AUDITEntry) + self.pending_queue = deque() + self.__setup_logger() + self.__read_recovery_file() + + def __setup_logger(self) -> logging.Logger: + # Set up logging queue to make sending messages to syslog nonblocking + logq = Queue() + queue_handler = logging.handlers.QueueHandler(logq) + queue_handler.setLevel(logging.DEBUG) + audit_handler = TNSyslogHandler(self.syslog_path, self.pending_queue) + audit_handler.setLevel(logging.DEBUG) + audit_handler.ident = SYSLOG_IDENT + + # Syslog messages are sent in separate thread + queue_listener = logging.handlers.QueueListener(logq, audit_handler) + queue_listener.start() + logger = logging.getLogger('AuditLogger') + logger.addHandler(queue_handler) + self.logger = logger + self.syslog_hander = audit_handler + self.syslog_queue_listener = queue_listener + + def __write_recovery_file(self): + with open(self.recovery_file, 'w') as f: + while self.pending_queue: + record = self.pending_queue.popleft() + f.write(f'{record.msg}\n') + + f.flush() + + def __read_recovery_file(self): + # read our recovery file into the pending queue and then remove it. + if not os.path.exists(self.recovery_file): + return + + with open(self.recovery_file, 'r') as f: + for line in f: + # immediately emit events in recovery file + self.logger.critical(line) + + os.unlink(self.recovery_file) + + def terminate(self): + # By this point our logger has shut down, but we may have a queue. + self.__write_recovery_file() + + # Setting our reader / writer to None breaks out of loop + self.audis_reader = None + self.audis_writer = None + + async def __setup_reader(self) -> None: + r, w = await asyncio.open_unix_connection(path=self.audis_path) + self.audis_reader = r + self.audis_writer = w + + async def send_completed(self, msgid: str, data: AUDITEntry) -> None: + json_data = audit_entry_to_json(msgid, data) + self.logger.critical(json_data) + + async def parse_audit_line(self, line: bytes): + # decode and strip off trailing newline character + decoded = line.decode()[0:-1] + if not decoded: + return + + decoded = decoded.replace(AUDITD_LINE_SEPARATOR, ' ') + + parts = decoded.split() + msgid = get_msg_id(parts) + msgtype = get_msg_type(parts) + + if msgtype not in MULTIPART_EVENT: + return (msgid, AUDITEntry(raw_lines=[decoded])) + + # Keep adding to raw_lines until we get an End of Event (EOE) message. + entry = self.partial_records[msgid] + entry.raw_lines.append(decoded) + + # prioritize line with the identifier key + if (audit_event := get_audit_event(parts)) is not None: + entry.event_type = audit_event + entry.key_event = parts + + if msgtype != AuditMsgEventType.EOE: + # Incomplete message. We store in `partial_records` dictionary + # until it is completed. + return None + + return (msgid, self.partial_records.pop(msgid)) + + async def handle_auditd_msg(self): + # Auditd messages are newline-terminated + data = await self.audis_reader.readline() + if (completed := await self.parse_audit_line(data)) is not None: + await self.send_completed(*completed) + + def __setup_signal_handlers(self): + self.loop.add_signal_handler(signal.SIGTERM, self.terminate) + self.loop.add_signal_handler(signal.SIGINT, self.terminate) + + async def run(self): + await self.__setup_reader() + self.__setup_signal_handlers() + + while self.audis_reader: + await self.handle_auditd_msg() + + +def __process_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=DESCRIPTION) + parser.add_argument( + '-a', '--audit-socket', + help='Path to audispd-af_unix socket.', + default=DEFAULT_AUDISPD_SOCK + ) + parser.add_argument( + '-s', '--syslog-socket', + help='Path to syslog unix socket.', + default=DEFAULT_SYSLOG_SOCK + ) + parser.add_argument( + '-', '--recovery-file', + help='Path to recovery file.', + default=DEFAULT_RECOVERY_FILE + ) + return parser.parse_args() + + +def __validate_socket_path(path: str): + if not stat.S_ISSOCK(os.stat(path).st_mode): + raise RuntimeError(f'{path}: not a socket.') + + +def __validate_args(args: argparse.Namespace): + __validate_socket_path(args.audit_socket) + + +def main(): + loop = asyncio.get_event_loop() + args = __process_args() + __validate_args(args) + handler = AuditdHandler( + args.audit_socket, + args.syslog_socket, + args.recovery_file, + loop + ) + loop.run_until_complete(handler.run()) + + +if __name__ == '__main__': + main() diff --git a/systemd/tnaudit.service b/systemd/tnaudit.service new file mode 100644 index 0000000..240cc75 --- /dev/null +++ b/systemd/tnaudit.service @@ -0,0 +1,19 @@ +[Unit] +Description=TrueNAS Auditd Handler +DefaultDependencies=no + +# Restart this service concurrently with auditd +PartOf=auditd.service + +[Service] +Type=exec +ExecStart=/usr/local/libexec/truenas_audit_handler.py +SendSIGKILL=no +Restart=on-failure +MemoryDenyWriteExecute=true +LockPersonality=true +ProtectControlGroups=true +ProtectKernelModules=true + +[Install] +WantedBy=multi-user.target