diff --git a/.github/local/needrestart b/.github/local/needrestart new file mode 100644 index 00000000..33b23e01 --- /dev/null +++ b/.github/local/needrestart @@ -0,0 +1,2 @@ + + /var/lib/waagent/** r, diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c7a76f87..75fa5c05 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -94,6 +94,7 @@ jobs: sudo apt-get install -y \ apparmor-profiles apparmor-utils \ bats bats-support + sudo install -Dm0644 .github/local/needrestart /etc/apparmor.d/local/needrestart - name: Install apparmor.d run: | diff --git a/apparmor.d/groups/cron/cron b/apparmor.d/groups/cron/cron index 4ce618ef..25549a39 100644 --- a/apparmor.d/groups/cron/cron +++ b/apparmor.d/groups/cron/cron @@ -27,6 +27,8 @@ profile cron @{exec_path} flags=(attach_disconnected) { ptrace (read) peer=unconfined, + unix bind type=stream addr=@@{udbus}/bus/cron/system, + @{exec_path} mr, @{sh_path} rix, diff --git a/apparmor.d/groups/network/networkd-dispatcher b/apparmor.d/groups/network/networkd-dispatcher index 63291093..45fbf76a 100644 --- a/apparmor.d/groups/network/networkd-dispatcher +++ b/apparmor.d/groups/network/networkd-dispatcher @@ -21,8 +21,9 @@ profile networkd-dispatcher @{exec_path} { @{exec_path} mr, @{bin}/ r, - @{bin}/networkctl rPx, + @{bin}/chronyc rPx, @{bin}/ls rix, + @{bin}/networkctl rPx, @{bin}/sed rix, @{lib}/networkd-dispatcher/routable.d/postfix rix, diff --git a/apparmor.d/groups/systemd/networkctl b/apparmor.d/groups/systemd/networkctl index a4bab2be..ce81686a 100644 --- a/apparmor.d/groups/systemd/networkctl +++ b/apparmor.d/groups/systemd/networkctl @@ -27,11 +27,10 @@ profile networkctl @{exec_path} flags=(attach_disconnected) { unix (bind) type=stream addr=@@{udbus}/bus/networkctl/system, #aa:dbus talk bus=system name=org.freedesktop.network1 label=systemd-networkd - # No label available - dbus send bus=system path=/org/freedesktop/network@{int} + dbus send bus=system path=/org/freedesktop/network1{,/**} interface=org.freedesktop.DBus.Properties member=Get - peer=(name=org.freedesktop.network@{int}), + peer=(name=org.freedesktop.network1), @{exec_path} mr, diff --git a/apparmor.d/profiles-a-f/apparmor_parser b/apparmor.d/profiles-a-f/apparmor_parser index 19c0f690..dc15d48b 100644 --- a/apparmor.d/profiles-a-f/apparmor_parser +++ b/apparmor.d/profiles-a-f/apparmor_parser @@ -17,6 +17,7 @@ profile apparmor_parser @{exec_path} flags=(attach_disconnected) { @{exec_path} mr, + @{lib_dirs}/@{multiarch}/** mr, @{lib_dirs}/snapd/apparmor.d/{,**} r, @{lib_dirs}/snapd/apparmor/{,**} r, diff --git a/apparmor.d/profiles-a-f/cgrulesengd b/apparmor.d/profiles-a-f/cgrulesengd index 08b1d83b..6f31a43d 100644 --- a/apparmor.d/profiles-a-f/cgrulesengd +++ b/apparmor.d/profiles-a-f/cgrulesengd @@ -12,19 +12,9 @@ profile cgrulesengd @{exec_path} { include include - # For creating Unix domain sockets/IPC sockets: - # socket(AF_NETLINK, SOCK_DGRAM, NETLINK_CONNECTOR) = 3 - # ... - # bind(3, {sa_family=AF_NETLINK, nl_pid=13284, nl_groups=0x000001}, 12) = -1 EPERM (Operation - # not permitted) - capability net_admin, - - # To remove the following errors: - # readlink("/proc/12/exe", 0x7ffc9fa85cd0, 4096) = -1 EACCES (Permission denied) - capability sys_ptrace, - - # To be able to read the /proc/ files of all processes in the system. capability dac_read_search, + capability net_admin, + capability sys_ptrace, network netlink dgram, @@ -32,22 +22,22 @@ profile cgrulesengd @{exec_path} { @{exec_path} mr, - @{sys}/fs/cgroup/**/tasks w, + + /etc/cgconfig.conf r, + /etc/cgconfig.d/{,*} r, + + /etc/cgrules.conf r, + /etc/cgrules.d/{,*} r, + + owner @{run}/cgred.socket w, + + @{sys}/fs/cgroup/** rw, @{PROC}/ r, @{PROC}/@{pids}/cmdline r, @{PROC}/@{pids}/task/ r, - owner @{PROC}/@{pid}/mounts r, @{PROC}/cgroups r, - - @{sys}/fs/cgroup/unified/cgroup.controllers r, - - owner @{run}/cgred.socket w, - - /etc/cgconfig.conf r, - /etc/cgrules.conf r, - /etc/cgconfig.d/ r, - + owner @{PROC}/@{pid}/mounts r, include if exists } diff --git a/apparmor.d/profiles-a-f/chsh b/apparmor.d/profiles-a-f/chsh index f8a2af5c..bf2b92a9 100644 --- a/apparmor.d/profiles-a-f/chsh +++ b/apparmor.d/profiles-a-f/chsh @@ -10,27 +10,35 @@ include @{exec_path} = @{bin}/chsh profile chsh @{exec_path} { include - include include + include + include include include capability audit_write, capability chown, capability fsetid, + capability net_admin, capability setuid, network netlink raw, + unix type=stream addr=@@{udbus}/bus/chsh/system, + + #aa:dbus talk bus=system name=org.freedesktop.home1 label=systemd-homed + @{exec_path} mr, /etc/shells r, + /etc/.chsh.@{rand6} rw, /etc/passwd rw, /etc/passwd- w, - /etc/passwd+ rw, /etc/passwd.@{pid} w, /etc/passwd.lock wl -> /etc/passwd.@{pid}, + /etc/passwd.OLD wl -> /etc/passwd, + /etc/passwd+ rw, /etc/shadow r, diff --git a/apparmor.d/profiles-a-f/flatpak b/apparmor.d/profiles-a-f/flatpak index 7368d7c3..bc21a583 100644 --- a/apparmor.d/profiles-a-f/flatpak +++ b/apparmor.d/profiles-a-f/flatpak @@ -62,7 +62,12 @@ profile flatpak @{exec_path} flags=(attach_disconnected,mediate_deleted,complain owner @{HOME}/.var/ w, owner @{HOME}/.var/app/{,**} rw, - owner @{user_documents_dirs}/ rw, + # Can create dotfile directories for any app + owner @{user_cache_dirs}/*/ w, + owner @{user_config_dirs}/*/ w, + owner @{user_share_dirs}/*/ w, + owner @{user_games_dirs}/{,**/} w, + owner @{user_documents_dirs}/ w, owner @{user_cache_dirs}/flatpak/{,**} rw, owner @{user_config_dirs}/pulse/client.conf r, diff --git a/apparmor.d/profiles-g-l/iotop b/apparmor.d/profiles-g-l/iotop index c53b4656..d85b0244 100644 --- a/apparmor.d/profiles-g-l/iotop +++ b/apparmor.d/profiles-g-l/iotop @@ -10,32 +10,28 @@ include @{exec_path} = @{bin}/iotop profile iotop @{exec_path} { include - include include + include - # Needed? - audit deny capability net_admin, - - # To set processes' priorities capability sys_nice, - @{exec_path} r, - @{bin}/python3.@{int} r, + network netlink raw, - @{bin}/file rix, + @{exec_path} r, @{bin}/ r, + @{bin}/file rix, + @{bin}/python3.@{int} r, + + /etc/magic r, @{PROC}/ r, - @{PROC}/vmstat r, - owner @{PROC}/@{pid}/mounts r, - owner @{PROC}/@{pid}/fd/ r, @{PROC}/@{pids}/cmdline r, @{PROC}/@{pids}/task/ r, @{PROC}/sys/kernel/pid_max r, - - # For file - /etc/magic r, + @{PROC}/vmstat r, + owner @{PROC}/@{pid}/fd/ r, + owner @{PROC}/@{pid}/mounts r, include if exists } diff --git a/apparmor.d/profiles-m-r/needrestart b/apparmor.d/profiles-m-r/needrestart index f5722ed3..56f95b58 100644 --- a/apparmor.d/profiles-m-r/needrestart +++ b/apparmor.d/profiles-m-r/needrestart @@ -22,10 +22,11 @@ profile needrestart @{exec_path} flags=(attach_disconnected) { ptrace (read), - mqueue r type=posix /, + mqueue (r,getattr) type=posix /, @{exec_path} mrix, + @{bin}/* r, @{sh_path} rix, @{bin}/dpkg-query rpx, @{bin}/fail2ban-server rPx, @@ -42,8 +43,6 @@ profile needrestart @{exec_path} flags=(attach_disconnected) { @{lib}/needrestart/* rPx, /usr/share/debconf/frontend rix, - @{bin}/networkd-dispatcher r, - @{bin}/gettext.sh r, /usr/share/needrestart/{,**} r, /usr/share/unattended-upgrades/unattended-upgrade-shutdown r, diff --git a/apparmor.d/profiles-s-z/snap-seccomp b/apparmor.d/profiles-s-z/snap-seccomp index 6b0917f8..e7660f7b 100644 --- a/apparmor.d/profiles-s-z/snap-seccomp +++ b/apparmor.d/profiles-s-z/snap-seccomp @@ -14,6 +14,8 @@ profile snap-seccomp @{exec_path} { include include + capability dac_read_search, + network netlink raw, @{exec_path} mr, diff --git a/apparmor.d/profiles-s-z/snapd b/apparmor.d/profiles-s-z/snapd index 63a1568b..250005f5 100644 --- a/apparmor.d/profiles-s-z/snapd +++ b/apparmor.d/profiles-s-z/snapd @@ -68,6 +68,7 @@ profile snapd @{exec_path} { @{sh_path} rix, @{bin}/apparmor_parser rPx, @{bin}/cp rix, + @{bin}/getent rix, @{bin}/gzip rix, @{bin}/journalctl rPx, @{bin}/kmod rPx, @@ -93,7 +94,7 @@ profile snapd @{exec_path} { @{lib_dirs}/snapd/snap-update-ns rPx, /usr/share/bash-completion/{,**} r, - /usr/share/dbus-1/{system,session}.d/{,snapd*} r, + /usr/share/dbus-1/{system,session}.d/{,snapd*} rw, /usr/share/dbus-1/services/*snap* r, /usr/share/polkit-1/actions/{,**/} r, diff --git a/apparmor.d/profiles-s-z/swayimg b/apparmor.d/profiles-s-z/swayimg new file mode 100644 index 00000000..a3ed158b --- /dev/null +++ b/apparmor.d/profiles-s-z/swayimg @@ -0,0 +1,23 @@ +# apparmor.d - Full set of apparmor profiles +# Copyright (C) 2024 valoq +# SPDX-License-Identifier: GPL-2.0-only + +abi , + +include + +@{exec_path} = @{bin}/swayimg +profile swayimg @{exec_path} { + include + include + include + include + + @{exec_path} mr, + + owner @{user_config_dirs}/swayimg/** r, + + include if exists +} + +# vim:syntax=apparmor diff --git a/apparmor.d/profiles-s-z/wttrbar b/apparmor.d/profiles-s-z/wttrbar new file mode 100644 index 00000000..37933679 --- /dev/null +++ b/apparmor.d/profiles-s-z/wttrbar @@ -0,0 +1,26 @@ +# apparmor.d - Full set of apparmor profiles +# Copyright (C) 2024 odomingao +# SPDX-License-Identifier: GPL-2.0-only + +abi , + +include + +@{exec_path} = @{bin}/wttrbar +profile wttrbar @{exec_path} { + include + include + + network inet dgram, + network inet6 dgram, + network inet stream, + network inet6 stream, + + @{exec_path} mr, + + owner /tmp/wttrbar--wttr.in.json rw, + + include if exists +} + +# vim:syntax=apparmor diff --git a/apparmor.d/tunables/multiarch.d/extensions b/apparmor.d/tunables/multiarch.d/extensions index 956e8c25..d3d56934 100644 --- a/apparmor.d/tunables/multiarch.d/extensions +++ b/apparmor.d/tunables/multiarch.d/extensions @@ -311,24 +311,24 @@ @{video_ext} += 3[gG]2 # 3g2 # Subtitles -@{suntitles_ext} = [aA][qQ][tT] # aqt -@{suntitles_ext} += [aA][sS][sS] # ass -@{suntitles_ext} += [gG][sS][uU][bB] # gsub -@{suntitles_ext} += [uU][sS][fF] # usf -@{suntitles_ext} += [pP][aA][cC] # pac -@{suntitles_ext} += [pP][jJ][sS] # pjs -@{suntitles_ext} += [pP][sS][bB] # psb -@{suntitles_ext} += [rR][tT] # rt -@{suntitles_ext} += [sS][bB][vV] # sbv -@{suntitles_ext} += [sS][mM][iI] # smi -@{suntitles_ext} += [sS][rR][tT] # srt -@{suntitles_ext} += [sS][sS][aA] # ssa -@{suntitles_ext} += [sS][sS][fF] # ssf -@{suntitles_ext} += [sS][tT][lL] # stl -@{suntitles_ext} += [sS][uU][bB] # sub -@{suntitles_ext} += [tT][t][mM][lL] # ttml -@{suntitles_ext} += [tT][t][xX][tT] # ttxt -@{suntitles_ext} += [vV][tT][t] # vtt +@{subtitles_ext} = [aA][qQ][tT] # aqt +@{subtitles_ext} += [aA][sS][sS] # ass +@{subtitles_ext} += [gG][sS][uU][bB] # gsub +@{subtitles_ext} += [uU][sS][fF] # usf +@{subtitles_ext} += [pP][aA][cC] # pac +@{subtitles_ext} += [pP][jJ][sS] # pjs +@{subtitles_ext} += [pP][sS][bB] # psb +@{subtitles_ext} += [rR][tT] # rt +@{subtitles_ext} += [sS][bB][vV] # sbv +@{subtitles_ext} += [sS][mM][iI] # smi +@{subtitles_ext} += [sS][rR][tT] # srt +@{subtitles_ext} += [sS][sS][aA] # ssa +@{subtitles_ext} += [sS][sS][fF] # ssf +@{subtitles_ext} += [sS][tT][lL] # stl +@{subtitles_ext} += [sS][uU][bB] # sub +@{subtitles_ext} += [tT][t][mM][lL] # ttml +@{subtitles_ext} += [tT][t][xX][tT] # ttxt +@{subtitles_ext} += [vV][tT][t] # vtt # Images @{image_ext} = [aA][pP][nN][gG] # apng diff --git a/tests/bats/needrestart.bats b/tests/bats/needrestart.bats index 567f8c77..4676b36a 100644 --- a/tests/bats/needrestart.bats +++ b/tests/bats/needrestart.bats @@ -5,10 +5,6 @@ load common -setup_file() { - skip "mqueue raised despite the rule being present. See https://gitlab.com/apparmor/apparmor/-/issues/362" -} - @test "needrestart: List outdated processes" { needrestart } diff --git a/tests/profile_check.py b/tests/profile_check.py new file mode 100644 index 00000000..9b61e636 --- /dev/null +++ b/tests/profile_check.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only + +# KNOWN ISSUES: +# No guards for file type - expects AppArmor +# Diffirent suggestions for single line are mutually exclusive +# Suggestion could point to changed profile name, based on other suggestion + +import sys +import argparse +import pathlib +import shlex +import json +from copy import deepcopy + +try: + from apparmor.regex import * + from apparmor.aa import is_skippable_file + from apparmor.rule.file import FileRule, FileRuleset + from apparmor.common import convert_regexp + try: + from apparmor.rule.variable import separate_vars + except ImportError: + from apparmor.aa import separate_vars + + LIBAPPARMOR = True + +except ImportError: + LIBAPPARMOR = False + +def sanitizeProfileName(name): + + if name.startswith('/') or name.startswith('@{'): + name = pathlib.Path(name).stem + + if ' ' in name: + name = re.sub(r'\s+', '-', name) + + return name + +def makeLocalIdentity(nestingStacker_): + + newStacker = [] + for i in nestingStacker_: + i = sanitizeProfileName(i) + newStacker.append(i) + + identity = '_'.join(newStacker) # separate each (sub)profile identity with underscores + + return identity + +def getCurrentProfile(stacker): + + if stacker: + profile = stacker[-1] + else: + profile = None + + return profile + +def handleFileMessages(l, file, profile, lineNum): + + wholeFileAccessProfiles = ( +# '', + ) + suggestOwner = ( # TODO: switch to AARE + r'^@{HOME}/', + r'^/home/\w+/', + r'^@{run}/user/@{uid}/', + r'^/run/user/\d+/', + r'^@{tmp}/', + r'^/tmp/', + r'^/var/tmp/', + r'^/dev/shm/', + ) + + lG = l.groupdict() + reason_ = None + if lG.get('path'): + if lG.get('path').startswith('/**') and profile not in wholeFileAccessProfiles: # false positives + severity_ = 'ERROR' + reason_ = 'Whole filesystem access is too broad' + suggestion_ = None + + for r in suggestOwner: + if re.match(r, lG.get('path')) and not lG.get('owner'): + indentRe = re.match(r'^\s+', l.group()) + if indentRe: + indent = indentRe.group() + else: + indent = '' + + severity_ = 'NOTICE' + reason_ = "'owner' is likely required" + suggestion_ = indent + 'owner ' + l.group().lstrip() + break + + elif lG.get('bare_file') and profile not in wholeFileAccessProfiles: + severity_ = 'ERROR' + reason_ = 'Whole filesystem access is too broad' + suggestion_ = None + + if reason_: # something matched + msg = {'filename': file, + 'profile': profile, + 'severity': severity_, + 'line': lineNum, + 'reason': reason_, + 'suggestion': suggestion_} + else: + msg = None + + return msg + +def readApparmorFile(fullpath): + '''AA file could contain multiple AA profiles''' + headers = ( + '# apparmor.d - Full set of apparmor profiles', + '# Copyright (C) ', + '# SPDX-License-Identifier: GPL-2.0-only', + ) + + file_data = {} + fileVars = {} + nestingStacker = [] + duplicateProfilesCounter = [] + localExists = {} + localExists_eol = {} + messages = [] + exceptionMsg = None + line = None + gotAbi = False + gotHeaders = {} + gotAttach = False + isAfterProfileStart = False + lastLineNum = None + try: + with open(fullpath, 'r') as f: + for n,line in enumerate(f, start=1): + if isAfterProfileStart: + isAfterProfileStart = False + expectedIndent = len(nestingStacker) * ' ' + indentRe = re.match(r'^\s+', line) + if indentRe: + indent = indentRe.group() + else: + indent = '' + + if indent != expectedIndent: + spacesCount = len(nestingStacker) * 2 + nestingCount = len(nestingStacker) + messages.append({'filename': fullpath, + 'profile': getCurrentProfile(nestingStacker), + 'severity': 'WARNING', + 'line': n, + 'reason': f"Expected {spacesCount} spaces for {nestingCount} nesting", + 'suggestion': f"{expectedIndent}{line.lstrip()}"}) + + if line.endswith(' \n'): + messages.append({'filename': fullpath, + 'profile': getCurrentProfile(nestingStacker), + 'severity': 'WARNING', + 'line': n, + 'reason': "Redundant trailing whitespace", + 'suggestion': line.rstrip()}) + + if '\t' in line: + messages.append({'filename': fullpath, + 'profile': getCurrentProfile(nestingStacker), + 'severity': 'WARNING', + 'line': n, + 'reason': "Tabs are not allowed", + 'suggestion': line.replace('\t', ' ')}) + + if len(gotHeaders) < 3 and not nestingStacker: + for nH,i in enumerate(headers): + if line.startswith(i): + gotHeaders[nH] = True + + if RE_ABI.search(line): + gotAbi = line + + elif RE_PROFILE_START.search(line) or RE_PROFILE_HAT_DEF.search(line): + isAfterProfileStart = True + m = parse_profile_start_line(line, fullpath) + if m.get('profile'): + nestingStacker.append(m.get('profile')) # set early + + if m.get('attachment') == '@{exec_path}' and not gotAttach: # can be only singular + gotAttach = True + + profileMsg = {'filename': fullpath, + 'profile': getCurrentProfile(nestingStacker), + 'severity': 'WARNING', + 'line': n, + 'reason': "A short named profile must be defined", + 'suggestion': None} + if m.get('plainprofile'): + messages.append(profileMsg) + elif m.get('namedprofile'): + if m.get('namedprofile').startswith('/'): + messages.append(profileMsg) + + if m.get('flags'): + m['flags'] = set(shlex.split(m.pop('flags').replace(',', ''))) + if 'complain' in m['flags']: + messages.append({'filename': fullpath, + 'profile': getCurrentProfile(nestingStacker), + 'severity': 'WARNING', + 'line': n, + 'reason': "'complain' flag must be defined in 'dists/flags'", + 'suggestion': None}) + else: + m['flags'] = set() + + if m.get('profile'): + duplicateProfilesCounter.append(m.get('profile')) + profileIdentity = '//'.join(nestingStacker) + file_data[profileIdentity] = m + + elif RE_PROFILE_VARIABLE.search(line): + lineV = RE_PROFILE_VARIABLE.search(line).groups() + + name = strip_quotes(lineV[0]) + operation = lineV[1] + val = separate_vars(lineV[2]) + if fileVars.get(name): + fileVars[name].update(set(val)) + if operation == '=': + messages.append({'filename': fullpath, + 'profile': getCurrentProfile(nestingStacker), + 'severity': 'DEGRADED', + 'line': n, + 'reason': "Tunable must be appended with '+='", + 'suggestion': None}) + else: + fileVars[name] = set(val) + if operation == '+=': + messages.append({'filename': fullpath, + 'profile': getCurrentProfile(nestingStacker), + 'severity': 'DEGRADED', + 'line': n, + 'reason': "Tunable must be defined with '='", + 'suggestion': None}) + + elif RE_INCLUDE.search(line): + if nestingStacker: + profileIdentity = '//'.join(nestingStacker) + localIdentity = makeLocalIdentity(nestingStacker) + localValue = f'include if exists ' # commented out will also match + if localValue in line: + localExists[profileIdentity] = localValue + + # Handle file entries + elif RE_PROFILE_FILE_ENTRY.search(line): + lineF = RE_PROFILE_FILE_ENTRY.search(line) + fileMsg = handleFileMessages(lineF, fullpath, getCurrentProfile(nestingStacker), n) + if fileMsg: + messages.append(fileMsg) + + elif RE_PROFILE_END.search(line): + if getCurrentProfile(nestingStacker): + if not nestingStacker: + messages.append({'filename': fullpath, + 'profile': None, + 'severity': 'DEGRADED', + 'line': n, + 'reason': "Unbalanced parenthesis?", # not fully covered + 'suggestion': None}) + else: + profileIdentity = '//'.join(nestingStacker) + localExists_eol[profileIdentity] = n + del nestingStacker[-1] # remove last + + lastLineNum = n + + except PermissionError: + exceptionMsg = 'Unable to read the file (PermissionError)' + + except UnicodeDecodeError: + exceptionMsg = 'Unable to read the file (UnicodeDecodeError)' + + except FileNotFoundError: + exceptionMsg = 'No such file or directory (FileNotFoundError)' + + if exceptionMsg: + messages.append({'filename': fullpath, + 'profile': None, + 'severity': 'NOTICE', + 'line': None, + 'reason': exceptionMsg, + 'suggestion': None}) + + # Ensure proper header is present + if len(gotHeaders) < 3: + combinedHeader = '\n'.join(headers) + messages.append({'filename': fullpath, + 'profile': None, + 'severity': 'WARNING', + 'line': 1, + 'reason': 'No proper header', + 'suggestion': combinedHeader}) + + # Ensure ABI is present + changeAbi = False + abi = 'abi ,' + if gotAbi: + if gotAbi.strip() != abi: + changeAbi = True + else: + changeAbi = True + + if changeAbi: + messages.append({'filename': fullpath, + 'profile': None, + 'severity': 'WARNING', + 'line': None, + 'reason': 'ABI is required', + 'suggestion': abi}) + + # Ensure singular '@{exec_path}' + if not gotAttach: + messages.append({'filename': fullpath, + 'profile': None, + 'severity': 'WARNING', + 'line': None, + 'reason': "'@{exec_path}' must be defined as main path attachment", + 'suggestion': None}) + + # Ensure trailing vim syntax + if line: + trailingSyntax = '# vim:syntax=apparmor\n' + if line != trailingSyntax: + messages.append({'filename': fullpath, + 'profile': None, + 'severity': 'WARNING', + 'line': lastLineNum, + 'reason': 'No trailing syntax hint', + 'suggestion': trailingSyntax}) + + # Assign variables to profile attachments as paths and assign filenames + for p,d in deepcopy(file_data).items(): + file_data[p]['filename'] = fullpath + attachment = d.get('attachment') + if attachment: + if attachment.startswith('@{'): + if fileVars.get(attachment): + file_data[p]['attach_paths'] = fileVars[attachment] # incoming set + else: + messages.append({'filename': fullpath, + 'profile': p, + 'severity': 'ERROR', + 'line': None, + 'reason': f"Unknown global variable as profile attachment: {attachment}", + 'suggestion': None}) + + else: + if isinstance(file_data[p].get('attachment'), set): + raise ValueError("Expecting 'str' or 'None', not 'set'") + file_data[p]['attach_paths'] = {file_data[p]['attachment']} + + # Check if profile block does not have corresponding 'local' include + for p,d in file_data.items(): + if not localExists.get(p): # not found previously + if '//' in p: + identity = p.split('//') + else: + identity = [p] + + localIdentity = makeLocalIdentity(identity) + filename = file_data[p]['filename'] + messages.append({'filename': filename, + 'profile': p, + 'severity': 'WARNING', + 'line': localExists_eol.get(p), # None? Unbalanced parenthesis? + 'reason': "The (sub)profile block does not have expected 'local' include", + 'suggestion': f'include if exists '}) + + # Track multiple definitions inside single file + for profile in duplicateProfilesCounter: + counter = duplicateProfilesCounter.count(profile) + if counter >= 2: + messages.append({'filename': fullpath, + 'profile': profile, + 'severity': 'DEGRADED', + 'line': None, + 'reason': "Profile has been defined {counter} times in the same file", + 'suggestion': None}) + + return (messages, file_data) + +def findAllProfileFilenames(profile_dir): + + profiles = set() + for path in pathlib.Path(profile_dir).iterdir(): + if path.is_file() and not is_skippable_file(path): + profiles.add(path.resolve()) + + # Not default, dig deeper + if not profiles: + nestedDirs = ( + 'groups', + 'profiles-a-f', + 'profiles-g-l', + 'profiles-m-r', + 'profiles-s-z', + ) + for d in nestedDirs: + dirpath = pathlib.Path(pathlib.Path(profile_dir).resolve(), pathlib.Path(d)) + for p in dirpath.rglob("*"): + if p.is_file(): + profiles.add(p) + + return profiles + +def handleArgs(): + """DEGRADED are purposed for fatal errors - when the profile set will fail to load entirely""" + + allSeverities = ['DEBUG', 'NOTICE', 'WARNING', 'ERROR', 'CRITICAL', 'DEGRADED'] + aaRoot = '/etc/apparmor.d' + + parser = argparse.ArgumentParser() + parser.add_argument('-d', '--aa-root-dir', action='store', + default=aaRoot, + help='Target different AppArmor root directory rather than default') + parser.add_argument('-p', '--profile', action='append', + help='Handle only specified profile') +# parser.add_argument('-s', '--severity', action='append', +# choices=allSeverities, +# help='Handle only specified severity event') + + args = parser.parse_args() + +# if not args.severity: +# args.severity = allSeverities + + return args + +def main(argv): + + args = handleArgs() + + messages = [] + + profile_dir = args.aa_root_dir + if not args.profile: + profiles = findAllProfileFilenames(profile_dir) + else: + profiles = set() + for p in args.profile: + absolutePath = pathlib.Path(p).resolve() + profiles.add(absolutePath) + + profile_data = {} + for path in sorted(profiles): + if not is_skippable_file(path): + readApparmorFile_Out = readApparmorFile(path) + profilesInFile = readApparmorFile_Out[1] + messages.extend(readApparmorFile_Out[0]) + profile_data.update(profilesInFile) + + for m in messages: + if m.get('suggestion'): + if m['suggestion'].endswith('\n'): + m['suggestion'] = m.get('suggestion').removesuffix('\n') + m['filename'] = str(m.get('filename')) + print(json.dumps(m, indent=2)) + + if messages: + sys.exit(1) + + return None + +if __name__ == '__main__': + + if not LIBAPPARMOR: + raise ImportError(f"""Can't find 'python3-apparmor' package! Install with: +$ sudo apt install python3-apparmor""") + + main(sys.argv)