diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3a0f2261d..17a9c3310 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,12 +2,15 @@ name: Ubuntu on: [push, pull_request, workflow_dispatch] +permissions: + contents: read + jobs: check: - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - name: Check out repository code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install linter dependencies run: | @@ -24,28 +27,33 @@ jobs: strategy: matrix: include: - - os: ubuntu-24.04 + - os: ubuntu-26.04 mode: default steps: - name: Check out repository code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install Build dependencies run: | sudo apt-get update -q sudo apt-get install -y \ - devscripts debhelper config-package-dev \ + devscripts debhelper \ auditd apparmor-profiles apparmor-utils pipx install rust-just echo "$HOME/.local/bin" >> $GITHUB_PATH - sudo rm /etc/apparmor.d/usr.lib.snapd.snap-confine.real + sudo rm -f /etc/apparmor.d/usr.lib.snapd.snap-confine.real - name: Build the apparmor.d package run: | just build-dpkg - name: Install apparmor.d - run: sudo dpkg --install .pkg/apparmor.d_*_amd64.deb || true + run: | + sudo dpkg --install .pkg/apparmor.d*.deb || true + sudo aa-install --install --all + + - name: Show the installation status + run: sudo aa-install --status - name: Reload AppArmor run: | @@ -63,29 +71,29 @@ jobs: - name: Show Number of loaded profile run: sudo aa-status --profiled - - name: Cache the build package - if: matrix.mode == 'default' && matrix.os == 'ubuntu-24.04' - uses: actions/cache/save@v4 + - name: Cache the build packages + if: matrix.mode == 'default' && matrix.os == 'ubuntu-26.04' + uses: actions/cache/save@v6 with: - path: .pkg/apparmor.d_*_amd64.deb - key: ${{ matrix.os }}-${{ matrix.mode }}-${{ hashFiles('.pkg/apparmor.d_*_amd64.deb') }} + path: .pkg/apparmor.d*.deb + key: ${{ matrix.os }}-${{ matrix.mode }}-${{ hashFiles('.pkg/apparmor.d*.deb') }} tests: - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 needs: build if: github.ref_name == 'dev' || github.event_name == 'workflow_dispatch' steps: - name: Check out repository code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - - name: Restore the cached build package - uses: actions/cache/restore@v4 + - name: Restore the cached build packages + uses: actions/cache/restore@v6 with: fail-on-cache-miss: true - path: .pkg/apparmor.d_*_amd64.deb - key: ubuntu-24.04-default-${{ hashFiles('.pkg/apparmor.d_*_amd64.deb') }} + path: .pkg/apparmor.d*.deb + key: ubuntu-26.04-default-${{ hashFiles('.pkg/apparmor.d*.deb') }} restore-keys: | - ubuntu-24.04-default- + ubuntu-26.04-default- - name: Install Tests dependencies run: | @@ -98,7 +106,8 @@ jobs: - name: Install apparmor.d run: | - sudo dpkg --install .pkg/apparmor.d_*_amd64.deb || true + sudo dpkg --install .pkg/apparmor.d*.deb || true + sudo aa-install --install --all sudo systemctl restart apparmor.service sudo systemctl daemon-reload systemctl --user daemon-reload diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e9d6bc9b5..6ca3eb6d9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -23,7 +23,7 @@ bash: - shellcheck --shell=bash PKGBUILD dists/*.sh tests/check.sh tests/packer/*.sh tests/packer/src/aa-update - tests/autopkgtest/autopkgtest.sh debian/common.postinst debian/common.postrm + tests/autopkgtest/autopkgtest.sh debian/apparmor.d.postinst debian/apparmor.d.postrm golangci-lint: stage: lint @@ -142,7 +142,7 @@ preprocess-debian: script: - apt-get update -q - apt-get install -y apparmor apparmor-profiles - - dpkg --install $PKGDEST/${PKGNAME}_* + - dpkg --install $PKGDEST/${PKGNAME}*.deb - apparmor_parser --preprocess /etc/apparmor.d 1> /dev/null preprocess-ubuntu: @@ -153,7 +153,7 @@ preprocess-ubuntu: script: - apt-get update -q - apt-get install -y apparmor apparmor-profiles - - dpkg --install $PKGDEST/${PKGNAME}_* + - dpkg --install $PKGDEST/${PKGNAME}*.deb - apparmor_parser --preprocess /etc/apparmor.d 1> /dev/null preprocess-opensuse: diff --git a/Justfile b/Justfile index 0bd2591af..a6d1756dd 100644 --- a/Justfile +++ b/Justfile @@ -92,6 +92,7 @@ help: # Build the go programs [group('build')] build: + @go build -o {{build}}/ ./cmd/aa-install @go build -o {{build}}/ ./cmd/aa-log @go build -o {{build}}/ ./cmd/aa-mode @go build -o {{build}}/ ./cmd/prebuild @@ -150,6 +151,7 @@ install-base: install-tools: #!/usr/bin/env bash set -eu -o pipefail + install -Dm0755 {{build}}/aa-install {{destdir}}/usr/bin/aa-install install -Dm0755 {{build}}/aa-log {{destdir}}/usr/bin/aa-log install -Dm0755 {{build}}/aa-mode {{destdir}}/usr/bin/aa-mode mapfile -t share < <(find "{{build}}/share" -type f -not -name "*.md" -not -name "*aa-flatpak*" -printf "%P\n") @@ -168,18 +170,12 @@ install-aa-flatpak: # Install prebuilt profiles [group('install')] -install-prebuilt: +install-prebuilt: _install-fixup #!/usr/bin/env bash set -eu -o pipefail mapfile -t aa < <(find "{{build}}/apparmor.d" -type f -not -path "*/abstractions/*" -not -path "*/tunables/*" -printf "%P\n") for file in "${aa[@]}"; do - #install -Dm0644 "{{build}}/apparmor.d/$file" "{{destdir}}/usr/share/apparmor.d/$file" - install -Dm0644 "{{build}}/apparmor.d/$file" "{{destdir}}/etc/apparmor.d/$file" - done - mapfile -t links < <(find "{{build}}/apparmor.d" -type l -printf "%P\n") - for file in "${links[@]}"; do - mkdir -p "{{destdir}}/etc/apparmor.d/disable" - cp -d "{{build}}/apparmor.d/$file" "{{destdir}}/etc/apparmor.d/$file" + install -Dm0644 "{{build}}/apparmor.d/$file" "{{destdir}}/usr/share/apparmor.d/$file" done for file in "{{build}}/systemd/system/"*; do service="$(basename "$file")" @@ -190,20 +186,22 @@ install-prebuilt: install -Dm0644 "$file" "{{destdir}}/usr/lib/systemd/user/$service.d/apparmor.conf" done +# Ensure profiles compatibility with upstream +[group('install')] +_install-fixup: + # The hostname profile fully conflicts with apparmor.d and must be disabled + @mkdir -p "{{destdir}}/etc/apparmor.d/disable" + @ln -sf ../hostname "{{destdir}}/etc/apparmor.d/disable/hostname" + # Install prebuild profiles [group('install')] -install: install-tools +install: install-tools _install-fixup #!/usr/bin/env bash set -eu -o pipefail mapfile -t aa < <(find "{{build}}/apparmor.d" -type f -printf "%P\n") for file in "${aa[@]}"; do install -Dm0644 "{{build}}/apparmor.d/$file" "{{destdir}}/etc/apparmor.d/$file" done - mapfile -t links < <(find "{{build}}/apparmor.d" -type l -printf "%P\n") - for file in "${links[@]}"; do - mkdir -p "{{destdir}}/etc/apparmor.d/disable" - cp -d "{{build}}/apparmor.d/$file" "{{destdir}}/etc/apparmor.d/$file" - done for file in "{{build}}/systemd/system/"*; do service="$(basename "$file")" install -Dm0644 "$file" "{{destdir}}/usr/lib/systemd/system/$service.d/apparmor.conf" @@ -295,22 +293,26 @@ build-rpm: (_ensure_pkgdest) # Build & install apparmor.d on Arch based systems [group('packages')] pkg: build-pkg - @sudo pacman -U --noconfirm \ - {{pkgdest}}/{{pkgname}}-`just version`*.pkg.tar* \ - {{pkgdest}}/{{pkgname}}-base-`just version`*.pkg.tar* \ - {{pkgdest}}/{{pkgname}}-tools-`just version`*.pkg.tar* + #!/usr/bin/env bash + shopt -s extglob + sudo pacman -U --noconfirm \ + {{pkgdest}}/{{pkgname}}?(-base|-tools)-$(just version)*.pkg.tar!(*.sig) # Build & install apparmor.d on Debian based systems [group('packages')] -dpkg name="": build-dpkg +dpkg: build-dpkg @sudo dpkg -i \ - {{pkgdest}}/{{pkgname}}{{ if name != "" { "." + name } else { "" } }}_`just version`*.deb + {{pkgdest}}/{{pkgname}}_`just version`*.deb \ + {{pkgdest}}/{{pkgname}}-base_`just version`*.deb \ + {{pkgdest}}/{{pkgname}}-tools_`just version`*.deb # Build & install apparmor.d on OpenSUSE based systems [group('packages')] rpm: build-rpm @sudo rpm -ivh --force \ - {{pkgdest}}/{{pkgname}}-`just version`*.rpm + {{pkgdest}}/{{pkgname}}-`just version`-*.rpm \ + {{pkgdest}}/{{pkgname}}-base-`just version`-*.rpm \ + {{pkgdest}}/{{pkgname}}-tools-`just version`-*.rpm # Run the linters [group('linter')] @@ -321,7 +323,7 @@ lint: shellcheck --shell=bash \ PKGBUILD dists/*.sh tests/check.sh \ tests/packer/*.sh tests/packer/src/aa-update \ - tests/autopkgtest/autopkgtest.sh debian/common.postinst debian/common.postrm + tests/autopkgtest/autopkgtest.sh debian/apparmor.d.postinst debian/apparmor.d.postrm # Run style checks on the profiles [group('linter')] @@ -331,6 +333,7 @@ check: # Generate the man pages [group('docs')] man: + @pandoc -t man -s -o share/man/man1/aa-install.1 share/man/man1/aa-install.md @pandoc -t man -s -o share/man/man1/aa-mode.1 share/man/man1/aa-mode.md @pandoc -t man -s -o share/man/man8/aa-log.8 share/man/man8/aa-log.md @@ -352,8 +355,9 @@ serve: # Remove all build artifacts clean: @rm -rf \ - debian/.debhelper debian/debhelper* debian/*.debhelper debian/{{pkgname}}* \ - debian/*.substvars debian/*.debhelper debian/files \ + debian/.debhelper debian/debhelper* debian/*.debhelper debian/{{pkgname}}/ \ + debian/{{pkgname}}-base/ debian/{{pkgname}}-tools/ \ + debian/*.substvars debian/*.debhelper debian/*.debhelper.log debian/files \ {{pkgdest}}/{{pkgname}}* {{pkgdest}}/ubuntu {{pkgdest}}/debian \ {{pkgdest}}/archlinux {{pkgdest}}/opensuse \ {{build}} coverage.out .logs/autopkgtest/ site .cache diff --git a/PKGBUILD b/PKGBUILD index 74520bfff..57d903d36 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -30,8 +30,7 @@ build() { export GOPATH="${srcdir}" export GOFLAGS="-buildmode=pie -trimpath -ldflags=-linkmode=external -mod=readonly -modcacherw -tags=dev" export DISTRIBUTION=arch - just complain -# just prebuild + just prebuild # just build-aa-flatpak } diff --git a/apparmor.d/abstractions/audio-client b/apparmor.d/abstractions/audio-client index ec9ac0503..edf0e633e 100644 --- a/apparmor.d/abstractions/audio-client +++ b/apparmor.d/abstractions/audio-client @@ -48,6 +48,7 @@ owner @{HOME}/.pulse-cookie rwk, owner @{user_cache_dirs}/event-sound-cache.tdb.@{hex32}.@{multiarch} rwk, # libcanberra + owner @{user_cache_dirs}/event-sound-cache.tdb.*.@{multiarch} rwk, # TODO: hostname owner @{user_config_dirs}/pulse/ rw, owner @{user_config_dirs}/pulse/client.conf r, diff --git a/apparmor.d/abstractions/common/electron b/apparmor.d/abstractions/common/electron index b499c2dcd..3fb4fcd06 100644 --- a/apparmor.d/abstractions/common/electron +++ b/apparmor.d/abstractions/common/electron @@ -33,6 +33,8 @@ include include + unix (send receive) type=seqpacket peer=(label=@{name}//crashpad_handler), + @{bin}/xdg-mime rix, @{bin}/xdg-settings rix, diff --git a/apparmor.d/abstractions/ibus-strict b/apparmor.d/abstractions/ibus-strict index b66be9771..cda78d945 100644 --- a/apparmor.d/abstractions/ibus-strict +++ b/apparmor.d/abstractions/ibus-strict @@ -6,6 +6,12 @@ abi , + owner @{desktop_config_dirs}/ibus/bus/ r, + owner @{desktop_config_dirs}/ibus/bus/@{hex32}-unix-@{int} r, + owner @{desktop_config_dirs}/ibus/bus/@{hex32}-unix-wayland-@{int} r, + owner @{desktop_config_dirs}/ibus/bus/machine-id-unix-@{int} r, + owner @{desktop_config_dirs}/ibus/bus/machine-id-unix-wayland-@{int} r, + owner @{user_cache_dirs}/ibus/dbus-@{rand8} rw, owner @{user_config_dirs}/ibus/ r, diff --git a/apparmor.d/abstractions/journal-read b/apparmor.d/abstractions/journal-read new file mode 100644 index 000000000..e384e56f6 --- /dev/null +++ b/apparmor.d/abstractions/journal-read @@ -0,0 +1,21 @@ +# apparmor.d - Full set of apparmor profiles +# Copyright (C) 2026 Alexandre Pujol +# SPDX-License-Identifier: GPL-2.0-only + +# Read only access to journalctl binary log files + + abi , + + /etc/machine-id r, + /var/lib/dbus/machine-id r, + + /{run,var}/log/journal/ r, + /{run,var}/log/journal/system.journal r, + /{run,var}/log/journal/system@@{hex16}-@{hex16}.journal~ r, + /{run,var}/log/journal/system@@{hex32}-@{hex16}-@{hex16}.journal r, + /{run,var}/log/journal/user-@{uid}.journal r, + /{run,var}/log/journal/user-@{uid}@@{hex16}-@{hex16}.journal~ r, + + include if exists + +# vim:syntax=apparmor diff --git a/apparmor.d/abstractions/nss b/apparmor.d/abstractions/nss index 4eb8193b9..d8bdf2e84 100644 --- a/apparmor.d/abstractions/nss +++ b/apparmor.d/abstractions/nss @@ -15,6 +15,12 @@ owner @{HOME}/.pki/nssdb/{cert9,key4}.db rwk, owner @{HOME}/.pki/nssdb/{cert9,key4}.db-journal rw, + owner @{user_share_dirs}/pki/ rw, + owner @{user_share_dirs}/pki/nssdb/ rw, + owner @{user_share_dirs}/pki/nssdb/pkcs11.txt rw, + owner @{user_share_dirs}/pki/nssdb/{cert9,key4}.db rwk, + owner @{user_share_dirs}/pki/nssdb/{cert9,key4}.db-journal rw, + include if exists # vim:syntax=apparmor diff --git a/apparmor.d/abstractions/python.d/complete b/apparmor.d/abstractions/python.d/complete index 35a5d3452..2edc41cd0 100644 --- a/apparmor.d/abstractions/python.d/complete +++ b/apparmor.d/abstractions/python.d/complete @@ -16,9 +16,9 @@ #aa:only apparmor>=4.1 # Normal python run do not need to update pycache files. It is done by pycompile. - audit @{lib}/@{python_name}/{,**/}__pycache__/ w, - audit @{lib}/@{python_name}/{,**/}__pycache__/**.pyc w, - audit @{lib}/@{python_name}/{,**/}__pycache__/**.pyc.@{u64} w, + @{lib}/@{python_name}/{,**/}__pycache__/ w, + @{lib}/@{python_name}/{,**/}__pycache__/**.pyc w, + @{lib}/@{python_name}/{,**/}__pycache__/**.pyc.@{u64} w, #aa:only test owner /tmp/pytest-of-user/ rw, diff --git a/apparmor.d/groups/apparmor/aa-install b/apparmor.d/groups/apparmor/aa-install new file mode 100644 index 000000000..81ce09135 --- /dev/null +++ b/apparmor.d/groups/apparmor/aa-install @@ -0,0 +1,65 @@ +# apparmor.d - Full set of apparmor profiles +# Copyright (C) 2026 Alexandre Pujol +# SPDX-License-Identifier: GPL-2.0-only + +abi , + +include + +@{exec_path} = @{bin}/aa-install +profile aa-install @{exec_path} { + include + include + include + + capability dac_read_search, + + @{exec_path} mr, + + @{bin}/journalctl Cx -> journalctl, + @{bin}/systemctl Cx -> systemctl, + @{sbin}/apparmor_parser Px, + + /usr/share/apparmor.d/** r, + /usr/share/apparmor/** r, + + /etc/apparmor.d/** rw, + /etc/apparmor/exclude.d/** r, + /etc/apparmor/flags.d/** r, + /etc/apparmor/ignore.d/** r, + /etc/apparmor/install.db rw, + /etc/apparmor/modes r, + + / r, + /**/ r, + + owner /tmp/aa-install@{int}/{,**} rw, + + profile systemctl { + include + include + + capability net_admin, + capability sys_resource, + + signal send set=term peer=systemd-tty-ask-password-agent, + + @{bin}/systemd-tty-ask-password-agent Px, + + include if exists + } + + profile journalctl { + include + include + include + + @{bin}/journalctl mr, + + include if exists + } + + include if exists +} + +# vim:syntax=apparmor diff --git a/apparmor.d/groups/firewall/firewalld b/apparmor.d/groups/firewall/firewalld index 5421b5778..123bf96d3 100644 --- a/apparmor.d/groups/firewall/firewalld +++ b/apparmor.d/groups/firewall/firewalld @@ -27,6 +27,11 @@ profile firewalld @{exec_path} flags=(attach_disconnected) { #aa:dbus own bus=system name=org.fedoraproject.FirewallD1 + dbus send bus=system path=/org/freedesktop/DBus + interface=org.freedesktop.DBus.Introspectable + member=Introspect + peer=(name=org.freedesktop.DBus, label="@{p_dbus_system}"), + @{exec_path} mr, @{python_path} r, diff --git a/apparmor.d/groups/flatpak/flatpak-system-helper b/apparmor.d/groups/flatpak/flatpak-system-helper index fe9c42b66..a3d2b9fca 100644 --- a/apparmor.d/groups/flatpak/flatpak-system-helper +++ b/apparmor.d/groups/flatpak/flatpak-system-helper @@ -33,14 +33,15 @@ profile flatpak-system-helper @{exec_path} flags=(attach_disconnected,mediate_de ptrace read, + unix type=seqpacket peer=(label=bazaar), + unix type=seqpacket peer=(label=bazaar//fusermount), + unix type=seqpacket peer=(label=bazaar//revokefs-fuse), unix type=seqpacket peer=(label=dbus-system), unix type=seqpacket peer=(label=flatpak), unix type=seqpacket peer=(label=flatpak//fusermount), unix type=seqpacket peer=(label=gnome-software), unix type=seqpacket peer=(label=gnome-software//fusermount), unix type=seqpacket peer=(label=unconfined), - unix type=seqpacket peer=(label=bazaar), - unix type=seqpacket peer=(label=bazaar//fusermount), #aa:dbus own bus=system name=org.freedesktop.Flatpak.SystemHelper diff --git a/apparmor.d/groups/freedesktop/xorg b/apparmor.d/groups/freedesktop/xorg index 1b3cb5170..770985811 100644 --- a/apparmor.d/groups/freedesktop/xorg +++ b/apparmor.d/groups/freedesktop/xorg @@ -68,6 +68,7 @@ profile xorg @{exec_path} flags=(attach_disconnected) { /var/lib/xkb/compiled/server-@{int}.xkm rw, /usr/share/libinput*/{,**} r, + /usr/share/X11/{,**} r, /etc/X11/{,**} r, diff --git a/apparmor.d/groups/systemd/systemd-journald b/apparmor.d/groups/systemd/systemd-journald index 1efdd7f62..421ee0e17 100644 --- a/apparmor.d/groups/systemd/systemd-journald +++ b/apparmor.d/groups/systemd/systemd-journald @@ -41,7 +41,9 @@ profile systemd-journald @{exec_path} flags=(attach_disconnected,mediate_deleted /{run,var}/log/journal/@{hex32}/* rwl -> /{run,var}/log/journal/@{hex32}/#@{int}, /{run,var}/log/journal/system.journal rw, /{run,var}/log/journal/system@@{hex16}-@{hex16}.journal~ rw, + /{run,var}/log/journal/system@@{hex32}-@{hex16}-@{hex16}.journal rw, /{run,var}/log/journal/user-@{uid}.journal rw, + /{run,var}/log/journal/user-@{uid}@@{hex16}-@{hex16}.journal~ r, owner @{run}/systemd/journal/{,**} rw, diff --git a/apparmor.d/profiles-a-f/console-setup-keyboard b/apparmor.d/profiles-a-f/console-setup-keyboard index 016b8c99c..35a15dcb6 100644 --- a/apparmor.d/profiles-a-f/console-setup-keyboard +++ b/apparmor.d/profiles-a-f/console-setup-keyboard @@ -23,6 +23,8 @@ profile console-setup-keyboard @{exec_path} { /etc/console-setup/{,**} r, + @{run}/console-setup/keymap_loaded w, + /dev/tty@{u8} rw, /dev/tty rw, diff --git a/apparmor.d/profiles-a-f/fwupd b/apparmor.d/profiles-a-f/fwupd index cddf0cd3f..8c059600f 100644 --- a/apparmor.d/profiles-a-f/fwupd +++ b/apparmor.d/profiles-a-f/fwupd @@ -57,6 +57,11 @@ profile fwupd @{exec_path} flags=(attach_disconnected) { @{exec_path} mr, + dbus send bus=system path=/org/freedesktop/login1 + interface=org.freedesktop.login1.Manager + member=Inhibit + peer=(name=@{busname}, label=systemd-logind), + @{lib}/fwupd/fwupd-detect-cet rix, @{bin}/gpg{,2} rCx -> gpg, diff --git a/apparmor.d/profiles-m-r/qemu-ga b/apparmor.d/profiles-m-r/qemu-ga index 6fcce06da..6d365b844 100644 --- a/apparmor.d/profiles-m-r/qemu-ga +++ b/apparmor.d/profiles-m-r/qemu-ga @@ -10,10 +10,11 @@ include profile qemu-ga @{exec_path} flags=(attach_disconnected) { include - network bind netlink raw, network inet stream, network inet6 stream, + network netlink raw, + @{exec_path} mr, @{bin}/systemctl Cx -> systemctl, diff --git a/cmd/aa-install/config.go b/cmd/aa-install/config.go new file mode 100644 index 000000000..9b2b9ffac --- /dev/null +++ b/cmd/aa-install/config.go @@ -0,0 +1,110 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package main + +import ( + "fmt" + "slices" + "strings" + + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/util" +) + +var ( + // deployModes are the modes accepted as a default deploy mode. + deployModes = []string{"enforce", "complain"} + + // includeModes are the modes accepted for the include key. + includeModes = []string{"default", "full"} + + // vendorConfigDir holds the vendor configuration defaults + vendorConfigDir = paths.New("/usr/share/apparmor") +) + +type conf struct { + mode string + include string + flagDirs paths.PathList + ignoreDirs paths.PathList + includeDirs paths.PathList + overwriteDirs paths.PathList +} + +// configTier returns the drop-in directories for name, vendor tier first so +// entries in configDir override it. +func configTier(configDir *paths.Path, name string) paths.PathList { + return paths.PathList{vendorConfigDir.Join(name), configDir.Join(name)} +} + +// loadConfig resolves the configuration from vendorConfigDir and configDir. +// The deploy mode is the "default" key of the modes files, overridden by +// cli arguments. +func loadConfig(configDir *paths.Path) (*conf, error) { + res := &conf{ + flagDirs: configTier(configDir, "flags.d"), + ignoreDirs: configTier(configDir, "ignore.d"), + includeDirs: configTier(configDir, "include.d"), + overwriteDirs: configTier(configDir, "overwrite.d"), + } + + modes := readModeConfig(vendorConfigDir.Join("modes"), configDir.Join("modes")) + res.mode = modes["default"] + if res.mode == "" { + res.mode = "complain" + } + switch { + case complain: + res.mode = "complain" + case enforce: + res.mode = "enforce" + } + if !slices.Contains(deployModes, res.mode) { + return nil, fmt.Errorf("invalid default mode %q in %s", res.mode, configDir.Join("modes")) + } + res.include = modes["include"] + if res.include == "" { + res.include = "default" + } + if !slices.Contains(includeModes, res.include) { + return nil, fmt.Errorf("invalid include mode %q in %s", res.include, configDir.Join("modes")) + } + return res, nil +} + +// readModeConfig parses a general aa-install config file (one "key value" +// per line, # comments filtered). Only the last existing file is read: a +// later file fully replaces an earlier one. Only the "default" key (the +// default deploy mode) is used for now. +func readModeConfig(files ...*paths.Path) map[string]string { + res := map[string]string{} + for i := len(files) - 1; i >= 0; i-- { + if !files[i].Exist() { + continue + } + for _, line := range files[i].MustReadFilteredFileAsLines() { + if key, value, ok := strings.Cut(line, " "); ok { + res[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + } + break + } + return res +} + +// userModeOverrides returns the set of profiles a flags.d drop-in assigns a +// mode to; the default deploy mode must not override these. +func userModeOverrides(dirs paths.PathList) map[string]bool { + res := map[string]bool{} + for profile, flags := range util.ReadFlagDirs(dirs...) { + for _, f := range flags { + if slices.Contains(util.ProfileModes, f) { + res[profile] = true + break + } + } + } + return res +} diff --git a/cmd/aa-install/config_test.go b/cmd/aa-install/config_test.go new file mode 100644 index 000000000..57a928f64 --- /dev/null +++ b/cmd/aa-install/config_test.go @@ -0,0 +1,260 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package main + +import ( + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" +) + +// setVendorConfigDir relocates vendorConfigDir to a temporary directory, +// restoring the original on cleanup. +func setVendorConfigDir(t *testing.T) *paths.Path { + t.Helper() + original := vendorConfigDir + vendorConfigDir = paths.New(t.TempDir()) + t.Cleanup(func() { vendorConfigDir = original }) + return vendorConfigDir +} + +func TestLoadConfig(t *testing.T) { + tests := []struct { + name string + vendorModes string // content of the vendor modes file, "" means not created + modes string // content of the config modes file, "" means not created + flags func() // set the -c/-e option globals + want string + wantInclude string // expected include mode, "" means default + wantErr bool + }{ + { + name: "default mode", + flags: func() {}, + want: "complain", + }, + { + name: "mode from config", + modes: "default enforce\n", + flags: func() {}, + want: "enforce", + }, + { + name: "mode from vendor config", + vendorModes: "default enforce\n", + flags: func() {}, + want: "enforce", + }, + { + name: "config overrides vendor", + vendorModes: "default enforce\n", + modes: "default complain\n", + flags: func() {}, + want: "complain", + }, + { + name: "complain overrides config", + modes: "default enforce\n", + flags: func() { complain = true }, + want: "complain", + }, + { + name: "enforce overrides config", + modes: "default complain\n", + flags: func() { enforce = true }, + want: "enforce", + }, + { + name: "invalid mode", + modes: "default unconfined\n", + flags: func() {}, + wantErr: true, + }, + { + name: "include mode from config", + modes: "include full\n", + flags: func() {}, + want: "complain", + wantInclude: "full", + }, + { + name: "invalid include mode", + modes: "include bogus\n", + flags: func() {}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vendorDir := setVendorConfigDir(t) + configDir := paths.New(t.TempDir()) + if tt.vendorModes != "" { + writeFile(t, vendorDir.Join("modes"), tt.vendorModes) + } + if tt.modes != "" { + writeFile(t, configDir.Join("modes"), tt.modes) + } + t.Cleanup(func() { complain, enforce = false, false }) + tt.flags() + + cfg, err := loadConfig(configDir) + if (err != nil) != tt.wantErr { + t.Fatalf("loadConfig() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if cfg.mode != tt.want { + t.Errorf("loadConfig() mode = %q, want %q", cfg.mode, tt.want) + } + wantInclude := tt.wantInclude + if wantInclude == "" { + wantInclude = "default" + } + if cfg.include != wantInclude { + t.Errorf("loadConfig() include = %q, want %q", cfg.include, wantInclude) + } + wantFlagDirs := paths.PathList{vendorDir.Join("flags.d"), configDir.Join("flags.d")} + for i, dir := range wantFlagDirs { + if cfg.flagDirs[i].String() != dir.String() { + t.Errorf("loadConfig() flagDirs[%d] = %s, want %s", i, cfg.flagDirs[i], dir) + } + } + }) + } +} + +func TestReadModeConfig(t *testing.T) { + tests := []struct { + name string + content string // "" means the file is not created + override string // content of a second file overriding the first + key string + want string + }{ + { + name: "default mode", + content: "default enforce\n", + key: "default", + want: "enforce", + }, + { + name: "comments and blanks ignored", + content: "# deploy mode\n\ndefault complain\n", + key: "default", + want: "complain", + }, + { + name: "missing file is empty", + content: "", + key: "default", + want: "", + }, + { + name: "unknown key absent", + content: "default enforce\n", + key: "other", + want: "", + }, + { + name: "later file overrides earlier", + content: "default enforce\n", + override: "default complain\n", + key: "default", + want: "complain", + }, + { + name: "later file fully replaces earlier", + content: "default enforce\n", + override: "other value\n", + key: "default", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tempPath(t, "modes") + if tt.content != "" { + if err := path.WriteFile([]byte(tt.content)); err != nil { + t.Fatalf("write modes: %v", err) + } + } + override := tempPath(t, "modes") + if tt.override != "" { + if err := override.WriteFile([]byte(tt.override)); err != nil { + t.Fatalf("write override modes: %v", err) + } + } + if got := readModeConfig(path, override)[tt.key]; got != tt.want { + t.Errorf("readModeConfig()[%q] = %q, want %q", tt.key, got, tt.want) + } + }) + } +} + +func TestUserModeOverrides(t *testing.T) { + tests := []struct { + name string + vendorFiles map[string]string + flagFiles map[string]string + want []string // profiles expected in the override set + wantNot []string // profiles expected absent + }{ + { + name: "mode entry overrides", + flagFiles: map[string]string{"core.conf": "ps enforce\ngjs complain\n"}, + want: []string{"ps", "gjs"}, + }, + { + name: "non-mode-only entry does not override", + flagFiles: map[string]string{"core.conf": "foo attach_disconnected\n"}, + wantNot: []string{"foo"}, + }, + { + name: "no flags dir", + flagFiles: nil, + wantNot: []string{"ps"}, + }, + { + name: "vendor mode entry overrides", + vendorFiles: map[string]string{"00-core.conf": "ps complain\n"}, + flagFiles: map[string]string{"10-user.conf": "gjs attach_disconnected\n"}, + want: []string{"ps"}, + wantNot: []string{"gjs"}, + }, + { + name: "same name user file replaces vendor file", + vendorFiles: map[string]string{"core.conf": "ps complain\n"}, + flagFiles: map[string]string{"core.conf": "gjs attach_disconnected\n"}, + wantNot: []string{"ps", "gjs"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writeDir := func(files map[string]string) *paths.Path { + dir := paths.New(t.TempDir()) + for name, content := range files { + if err := dir.Join(name).WriteFile([]byte(content)); err != nil { + t.Fatalf("write flag file: %v", err) + } + } + return dir + } + dirs := paths.PathList{writeDir(tt.vendorFiles), writeDir(tt.flagFiles)} + + got := userModeOverrides(dirs) + for _, p := range tt.want { + if !got[p] { + t.Errorf("userModeOverrides() missing %q, got %v", p, got) + } + } + for _, p := range tt.wantNot { + if got[p] { + t.Errorf("userModeOverrides() contains %q, want absent", p) + } + } + }) + } +} diff --git a/cmd/aa-install/main.go b/cmd/aa-install/main.go new file mode 100644 index 000000000..34e3e3148 --- /dev/null +++ b/cmd/aa-install/main.go @@ -0,0 +1,321 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package main + +import ( + "flag" + "fmt" + "os" + "slices" + + "github.com/roddhjav/apparmor.d/pkg/aa" + "github.com/roddhjav/apparmor.d/pkg/builder" + "github.com/roddhjav/apparmor.d/pkg/configure" + "github.com/roddhjav/apparmor.d/pkg/logging" + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/runtime" + "github.com/roddhjav/apparmor.d/pkg/tasks" + "github.com/roddhjav/apparmor.d/pkg/util" +) + +const ( + nilConfig = "" + nilMagic = "" + nilSrc = "" + usage = `aa-install [-h] [--config DIR] [--src DIR] [--magic DIR] [ -i | -u | -s | -l ] [-a] [-e|-c] + + Install and manage apparmor profiles from apparmor.d. + + With no action flag, print the installation status summary. + +Options: + -h, --help Show this help message and exit. + -s, --status Show the installation status summary (default). + -l, --list List installed profile paths from the manifest. + -i, --install Install the profiles. + -a, --all Install all profiles. + -c, --complain Set complain flag on all the profiles. + -e, --enforce Set enforce flag on all the profiles. + -u, --uninstall Remove all profiles installed. + --no-reload Do not reload the profiles after modifying them. + --config DIR Select an alternate configuration directory (default: /etc/apparmor/). + --magic DIR Select an alternate apparmor.d directory (default: /etc/apparmor.d). + --src DIR Select an alternate source directory (default: /usr/share/apparmor.d). + +Configuration files: + modes Modes of enforcement for all profiles. + flags.d/*.conf Set per-profile flags. + ignore.d/*.conf Set (group of) profiles to ignore. + include.d/*.conf Set (group of) profiles to install. + overwrite.d/*.conf Set upstream profiles to disable and replace. + +Configuration directories: + + /usr/share/apparmor/ - Vendor defaults are read first + /etc/apparmor/ - Local admin configuration, can be overwritten by --config DIR. + +See man aa-install(1) for more information. +` +) + +// Command line options +var ( + help bool + install bool + all bool + complain bool + enforce bool + uninstall bool + status bool + list bool + noReload bool + config string + magic string + src string +) + +// reloadAppArmor is swappable so tests can skip the system reload. +var reloadAppArmor = util.ReloadAppArmor + +func init() { + flag.BoolVar(&help, "h", false, "Show this help message and exit.") + flag.BoolVar(&help, "help", false, "Show this help message and exit.") + flag.BoolVar(&install, "i", false, "Install the profiles.") + flag.BoolVar(&install, "install", false, "Install the profiles.") + flag.BoolVar(&all, "a", false, "Install all profiles.") + flag.BoolVar(&all, "all", false, "Install all profiles,.") + flag.BoolVar(&complain, "c", false, "Set complain flag on all the profiles.") + flag.BoolVar(&complain, "complain", false, "Set complain flag on all the profiles.") + flag.BoolVar(&enforce, "e", false, "Set enforce flag on all the profiles.") + flag.BoolVar(&enforce, "enforce", false, "Set enforce flag on all the profiles.") + flag.BoolVar(&uninstall, "u", false, "Remove all profiles installed.") + flag.BoolVar(&uninstall, "uninstall", false, "Remove all profiles installed.") + flag.BoolVar(&status, "s", false, "Show the installation status summary.") + flag.BoolVar(&status, "status", false, "Show the installation status summary.") + flag.BoolVar(&list, "l", false, "List installed profile paths from the manifest.") + flag.BoolVar(&list, "list", false, "List installed profile paths from the manifest.") + flag.BoolVar(&noReload, "no-reload", false, "Do not reload the profiles after modifying them.") + flag.StringVar(&config, "config", nilConfig, "Select an alternate configuration directory (default: /etc/apparmor/).") + flag.StringVar(&magic, "magic", nilMagic, "Select an alternate apparmor.d directory (default: /etc/apparmor.d).") + flag.StringVar(&src, "src", nilSrc, "Select an alternate source directory (default: /usr/share/apparmor.d).") +} + +// aaStatus prints a summary of the installation state: the number of +// profiles recorded in the manifest, and how many are missing or drifted +// (hash mismatch) from the install target. +func aaStatus(stateDir *paths.Path, targetDir *paths.Path) error { + manifest := readManifest(stateDir) + if len(manifest) == 0 { + logging.Warning("No profiles installed") + return nil + } + + var missing, drifted, ok int + for rel, ident := range manifest { + target := targetDir.Join(rel) + // Lstat so a symlink entry (possibly dangling) counts as present. + if _, err := target.Lstat(); err != nil { + missing++ + continue + } + current, err := fileIdentity(target) + if err != nil { + return err + } + if current != ident { + drifted++ + continue + } + ok++ + } + + logging.Indent = "" + logging.Success("%d profiles installed in %s", len(manifest), targetDir) + logging.Indent = " " + logging.Bullet("%d up-to-date, %d drifted, %d missing", ok, drifted, missing) + logging.Indent = "" + return nil +} + +// aaList prints one absolute installed-profile path per line, sorted. +// The paths are resolved against targetDir. +func aaList(stateDir *paths.Path, targetDir *paths.Path) error { + manifest := readManifest(stateDir) + if len(manifest) == 0 { + return nil + } + keys := make([]string, 0, len(manifest)) + for k := range manifest { + keys = append(keys, k) + } + slices.Sort(keys) + for _, rel := range keys { + fmt.Println(targetDir.Join(rel).String()) + } + return nil +} + +func aaInstall(configDir *paths.Path, srcDir *paths.Path, cfg *conf) (bool, error) { + tmp, err := os.MkdirTemp("", "aa-install") + if err != nil { + return false, err + } + buildDir := paths.New(tmp) + defer buildDir.RemoveAll() + + c := tasks.NewTaskConfig(buildDir) + r := runtime.NewRunners(c) + + // Add default configure tasks + r.Configures. + + // Initialize a new clean apparmor.d build directory + Add(configure.NewSynchronise([]*paths.Path{srcDir})) + + // Full define include: only install the profiles from the include.d dirs + include := configure.NewInclude(cfg.includeDirs) + if cfg.include == "full" && include.Active() { + r.Configures.Add(include) + } + + // Continue adding default configure tasks + r.Configures. + + // Ignore profiles and files from the ignore.d dirs + Add(configure.NewUserIgnore(cfg.ignoreDirs)) + + // Default include: re-apply ignored profiles from the include.d dirs + var includeEntries []string + if cfg.include == "default" { + restore := configure.NewRestoreInclude(cfg.includeDirs, srcDir) + if restore.Active() { + r.Configures.Add(restore) + includeEntries = util.ReadConfDirs(cfg.includeDirs...) + } + } + + // Continue adding default configure tasks + r.Configures. + + // Merge profiles (from group/, profiles-*-*/) to a unified apparmor.d directory + Add(configure.NewMerge()). + + // Disable and replace upstream profiles from the overwrite.d dirs + Add(configure.NewOverwrite(util.ReadConfDirs(cfg.overwriteDirs...))). + + // Set user-defined flags from the flags.d dirs + Add(configure.NewSetFlags(cfg.flagDirs)). + + // Set detected system state in tunables/multiarch.d/state + Add(configure.NewSetState()) + + // Only keep profiles for installed programs, unless all are requested. + // Included profiles are installed even when their program is not. + if !all { + r.Configures.Add(configure.NewSelectInstalled(includeEntries...)) + } + + // Apply the default deploy mode to every profile, except those a user + // flags.d drop-in already assigns a mode to (SetFlags handled those). + r.Builders.Add(builder.NewDeployMode(cfg.mode, userModeOverrides(cfg.flagDirs))) + + if err := r.Configure(); err != nil { + return false, err + } + + if err := r.Build(); err != nil { + return false, err + } + return installProfiles(c.RootApparmor, aa.MagicRoot, configDir) +} + +// aaUninstall removes all files recorded in the manifest, then the manifest +// itself. Returns whether any file was removed. +func aaUninstall(stateDir *paths.Path, targetDir *paths.Path) (bool, error) { + manifest := readManifest(stateDir) + if len(manifest) == 0 { + logging.Warning("No manifest found, nothing to uninstall") + return false, nil + } + + removed := 0 + for rel := range manifest { + target := targetDir.Join(rel) + // Lstat so a dangling disable symlink still counts as present. + if _, err := target.Lstat(); err == nil { + if err := target.RemoveAll(); err != nil { + return false, err + } + removed++ + } + } + + manifestPath := stateDir.Join(manifestFile) + if manifestPath.Exist() { + if err := manifestPath.Remove(); err != nil { + return false, err + } + } + + logging.Success("Uninstalled %d profiles from %s", removed, targetDir) + return removed > 0, nil +} + +func run() error { + if complain && enforce { + return fmt.Errorf("--complain and --enforce are mutually exclusive") + } + + configDir := paths.New("/etc/apparmor/") + if config != nilConfig { + configDir = paths.New(config) + } + if magic != nilMagic { + aa.MagicRoot = paths.New(magic) + } + srcRoot := paths.New("/usr/share/apparmor.d") + if src != nilSrc { + srcRoot = paths.New(src) + } + cfg, err := loadConfig(configDir) + if err != nil { + return err + } + + var changed bool + switch { + case list: + return aaList(configDir, aa.MagicRoot) + + case install: + changed, err = aaInstall(configDir, srcRoot, cfg) + + case uninstall: + changed, err = aaUninstall(configDir, aa.MagicRoot) + + default: + return aaStatus(configDir, aa.MagicRoot) + + } + if err != nil { + return err + } + if changed && !noReload { + return reloadAppArmor() + } + return nil +} + +func main() { + flag.Usage = func() { fmt.Print(usage) } + flag.Parse() + if help { + flag.Usage() + os.Exit(0) + } + if err := run(); err != nil { + logging.Fatal("%s", err.Error()) + } +} diff --git a/cmd/aa-install/main_test.go b/cmd/aa-install/main_test.go new file mode 100644 index 000000000..aff90fd11 --- /dev/null +++ b/cmd/aa-install/main_test.go @@ -0,0 +1,477 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package main + +import ( + "errors" + "strings" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/aa" + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/state/detector" +) + +const keptProfile = `abi , + +profile aa_test_kept @{bin}/true { + include +} +` + +const droppedProfile = `abi , + +profile aa_test_dropped /usr/bin/aa-install-no-such-program { + include +} +` + +const overwriteProfile = `abi , + +profile aa_test_over @{bin}/true { + include +} +` + +const upstreamProfile = "# upstream dummy profile\n" + +// runEnv holds the directories and reload counter a run() test operates on. +type runEnv struct { + configDir *paths.Path + vendorDir *paths.Path + srcDir *paths.Path + targetDir *paths.Path + reloads int +} + +// setupRunEnv points the option globals at a fake source tree and stubs +// the AppArmor reload. All mutated globals are restored on cleanup. +func setupRunEnv(t *testing.T) *runEnv { + t.Helper() + env := &runEnv{} + + root := paths.New(t.TempDir()) + env.srcDir = root.Join("apparmor.d") + writeFile(t, env.srcDir.Join("groups/apps/aa_test_kept"), keptProfile) + writeFile(t, env.srcDir.Join("profiles-a-f/aa_test_dropped"), droppedProfile) + writeFile(t, env.srcDir.Join("tunables/multiarch.d/state"), + "@{VERSION} = 5\n\ninclude if exists \n") + writeFile(t, env.srcDir.Join("abstractions/base-strict"), "abi ,\n") + + env.configDir = paths.New(t.TempDir()) + env.targetDir = paths.New(t.TempDir()) + env.vendorDir = setVendorConfigDir(t) + + config = env.configDir.String() + magic = env.targetDir.String() + src = env.srcDir.String() + + oldMagic := aa.MagicRoot + oldReload := reloadAppArmor + reloadAppArmor = func() error { + env.reloads++ + return nil + } + // Keep the state detectors off the real host: fake filesystem root, + // no command execution. + oldRoot, oldRun := detector.Root, detector.Run + detector.Root = paths.New(t.TempDir()) + detector.Run = func(name string, arg ...string) (string, error) { + return "", errors.New("stubbed") + } + t.Cleanup(func() { + install, all, complain, enforce = false, false, false, false + uninstall, status, list = false, false, false + config, magic, src = nilConfig, nilMagic, nilSrc + aa.MagicRoot = oldMagic + reloadAppArmor = oldReload + detector.Root, detector.Run = oldRoot, oldRun + }) + + // The build directory is created with os.MkdirTemp and deliberately + // not removed; keep test runs from littering the system /tmp. + t.Setenv("TMPDIR", t.TempDir()) + return env +} + +func TestRun(t *testing.T) { + tests := []struct { + name string + flags func() // set the action option globals + setup func(t *testing.T, env *runEnv) // extra environment preparation + wantErr bool + wantReloads int + check func(t *testing.T, env *runEnv) + }{ + { + name: "conflicting modes", + flags: func() { complain, enforce = true, true }, + wantErr: true, + }, + { + name: "install keeps installed programs only", + flags: func() { install = true }, + wantReloads: 1, + check: func(t *testing.T, env *runEnv) { + if !env.targetDir.Join("aa_test_kept").Exist() { + t.Error("kept profile was not installed") + } + if env.targetDir.Join("aa_test_dropped").Exist() { + t.Error("profile for a missing program was installed") + } + }, + }, + { + name: "install all keeps profiles of missing programs", + flags: func() { install, all = true, true }, + wantReloads: 1, + check: func(t *testing.T, env *runEnv) { + if !env.targetDir.Join("aa_test_kept").Exist() { + t.Error("kept profile was not installed") + } + if !env.targetDir.Join("aa_test_dropped").Exist() { + t.Error("profile for a missing program was not installed") + } + }, + }, + { + name: "install complain", + flags: func() { install, complain = true, true }, + wantReloads: 1, + check: func(t *testing.T, env *runEnv) { + got, err := env.targetDir.Join("aa_test_kept").ReadFileAsString() + if err != nil { + t.Fatalf("read installed profile: %v", err) + } + if !strings.Contains(got, "flags=(complain)") { + t.Errorf("installed profile = %q, want complain flag", got) + } + }, + }, + { + name: "install enforce", + flags: func() { install, enforce = true, true }, + wantReloads: 1, + check: func(t *testing.T, env *runEnv) { + got, err := env.targetDir.Join("aa_test_kept").ReadFileAsString() + if err != nil { + t.Fatalf("read installed profile: %v", err) + } + if strings.Contains(got, "complain") { + t.Errorf("installed profile = %q, want no complain flag", got) + } + }, + }, + { + name: "install writes detected state", + flags: func() { install = true }, + setup: func(t *testing.T, env *runEnv) { + detector.Run = func(name string, arg ...string) (string, error) { + if name == "apparmor_parser" { + return "AppArmor parser version 4.0.2\n", nil + } + return "", errors.New("stubbed") + } + }, + wantReloads: 1, + check: func(t *testing.T, env *runEnv) { + got, err := env.targetDir.Join("tunables/multiarch.d/state.d/aa-install").ReadFileAsString() + if err != nil { + t.Fatalf("read installed state dropin: %v", err) + } + if !strings.Contains(got, "@{VERSION} = 4") { + t.Errorf("installed state dropin = %q, want @{VERSION} = 4", got) + } + }, + }, + { + name: "install with include.d full mode", + flags: func() { install = true }, + setup: func(t *testing.T, env *runEnv) { + writeFile(t, env.configDir.Join("modes"), "include full\n") + writeFile(t, env.srcDir.Join("profiles-a-f/aa_test_extra"), + strings.ReplaceAll(keptProfile, "aa_test_kept", "aa_test_extra")) + writeFile(t, env.configDir.Join("include.d/user.conf"), "aa_test_kept\n") + }, + wantReloads: 1, + check: func(t *testing.T, env *runEnv) { + if !env.targetDir.Join("aa_test_kept").Exist() { + t.Error("included profile was not installed") + } + if env.targetDir.Join("aa_test_extra").Exist() { + t.Error("profile not listed in include.d was installed") + } + }, + }, + { + name: "install with include.d default mode", + flags: func() { install = true }, + setup: func(t *testing.T, env *runEnv) { + writeFile(t, env.configDir.Join("ignore.d/user.conf"), "aa_test_kept\n") + writeFile(t, env.configDir.Join("include.d/user.conf"), + "aa_test_kept\naa_test_dropped\n") + }, + wantReloads: 1, + check: func(t *testing.T, env *runEnv) { + if !env.targetDir.Join("aa_test_kept").Exist() { + t.Error("ignored included profile was not re-applied") + } + if !env.targetDir.Join("aa_test_dropped").Exist() { + t.Error("included profile of a missing program was not installed") + } + }, + }, + { + name: "install invalid include mode config", + flags: func() { install = true }, + setup: func(t *testing.T, env *runEnv) { + writeFile(t, env.configDir.Join("modes"), "include bogus\n") + }, + wantErr: true, + }, + { + name: "install invalid mode config", + flags: func() { install = true }, + setup: func(t *testing.T, env *runEnv) { + writeFile(t, env.configDir.Join("modes"), "default bogus\n") + }, + wantErr: true, + }, + { + name: "install config overrides vendor mode", + flags: func() { install = true }, + setup: func(t *testing.T, env *runEnv) { + writeFile(t, env.vendorDir.Join("modes"), "default enforce\n") + writeFile(t, env.configDir.Join("modes"), "default complain\n") + }, + wantReloads: 1, + check: func(t *testing.T, env *runEnv) { + got, err := env.targetDir.Join("aa_test_kept").ReadFileAsString() + if err != nil { + t.Fatalf("read installed profile: %v", err) + } + if !strings.Contains(got, "flags=(complain)") { + t.Errorf("installed profile = %q, want complain flag", got) + } + }, + }, + { + name: "install missing source", + flags: func() { install = true }, + setup: func(t *testing.T, env *runEnv) { + src = "/does/not/exist/apparmor.d" + }, + wantErr: true, + }, + { + name: "install broken tmpdir", + flags: func() { install = true }, + setup: func(t *testing.T, env *runEnv) { + t.Setenv("TMPDIR", "/does/not/exist") + }, + wantErr: true, + }, + { + name: "install unknown directive", + flags: func() { install = true }, + setup: func(t *testing.T, env *runEnv) { + writeFile(t, env.srcDir.Join("groups/apps/aa_test_bad"), `abi , + +# #aa:bogus +profile aa_test_bad @{bin}/true { + include +} +`) + }, + wantErr: true, + }, + { + name: "install reload error", + flags: func() { install = true }, + setup: func(t *testing.T, env *runEnv) { + reloadAppArmor = func() error { return errors.New("reload failed") } + }, + wantErr: true, + }, + { + name: "uninstall remove error", + flags: func() { uninstall = true }, + setup: func(t *testing.T, env *runEnv) { + install = true + if err := run(); err != nil { + t.Fatalf("run(install): %v", err) + } + install = false + chmod(t, env.targetDir, 0o555) + }, + wantErr: true, + wantReloads: 1, // from the install in setup + }, + { + name: "install does not replace upstream profile", + flags: func() { install = true }, + setup: func(t *testing.T, env *runEnv) { + writeFile(t, env.srcDir.Join("profiles-m-r/aa_test_over"), overwriteProfile) + writeFile(t, env.configDir.Join("overwrite.d/user.conf"), "aa_test_over\n") + writeFile(t, env.targetDir.Join("aa_test_over"), upstreamProfile) + }, + wantReloads: 1, + check: func(t *testing.T, env *runEnv) { + got, err := env.targetDir.Join("aa_test_over").ReadFileAsString() + if err != nil { + t.Fatalf("read upstream profile: %v", err) + } + if got != upstreamProfile { + t.Errorf("upstream profile = %q, want untouched %q", got, upstreamProfile) + } + if !env.targetDir.Join("aa_test_over.apparmor.d").Exist() { + t.Error("overwriting profile was not installed as aa_test_over.apparmor.d") + } + if isLink, err := env.targetDir.Join("disable/aa_test_over").IsSymlink(); err != nil || !isLink { + t.Errorf("disable link IsSymlink() = %v, %v, want true", isLink, err) + } + }, + }, + { + name: "uninstall keeps upstream profile", + flags: func() { uninstall = true }, + setup: func(t *testing.T, env *runEnv) { + writeFile(t, env.srcDir.Join("profiles-m-r/aa_test_over"), overwriteProfile) + writeFile(t, env.configDir.Join("overwrite.d/user.conf"), "aa_test_over\n") + writeFile(t, env.targetDir.Join("aa_test_over"), upstreamProfile) + install = true + if err := run(); err != nil { + t.Fatalf("run(install): %v", err) + } + install = false + }, + wantReloads: 2, + check: func(t *testing.T, env *runEnv) { + got, err := env.targetDir.Join("aa_test_over").ReadFileAsString() + if err != nil { + t.Fatalf("read upstream profile: %v", err) + } + if got != upstreamProfile { + t.Errorf("upstream profile = %q, want untouched %q", got, upstreamProfile) + } + if env.targetDir.Join("aa_test_over.apparmor.d").Exist() { + t.Error("overwriting profile still installed after uninstall") + } + if _, err := env.targetDir.Join("disable/aa_test_over").Lstat(); err == nil { + t.Error("disable link still installed after uninstall") + } + }, + }, + { + name: "uninstall removes dangling disable link", + flags: func() { uninstall = true }, + setup: func(t *testing.T, env *runEnv) { + writeFile(t, env.srcDir.Join("profiles-m-r/aa_test_over"), overwriteProfile) + writeFile(t, env.configDir.Join("overwrite.d/user.conf"), "aa_test_over\n") + install = true + if err := run(); err != nil { + t.Fatalf("run(install): %v", err) + } + install = false + }, + wantReloads: 2, + check: func(t *testing.T, env *runEnv) { + if _, err := env.targetDir.Join("disable/aa_test_over").Lstat(); err == nil { + t.Error("dangling disable link still installed after uninstall") + } + }, + }, + { + name: "status default action", + flags: func() {}, + }, + { + name: "list", + flags: func() { list = true }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := setupRunEnv(t) + if tt.setup != nil { + tt.setup(t, env) + } + tt.flags() + err := run() + if (err != nil) != tt.wantErr { + t.Fatalf("run() error = %v, wantErr %v", err, tt.wantErr) + } + if env.reloads != tt.wantReloads { + t.Errorf("run() reloads = %d, want %d", env.reloads, tt.wantReloads) + } + if tt.check != nil { + tt.check(t, env) + } + }) + } +} + +// TestRun_Lifecycle exercises the full sequential user workflow: +// install, no-op reinstall, status, list, uninstall, empty uninstall. +func TestRun_Lifecycle(t *testing.T) { + env := setupRunEnv(t) + + steps := []struct { + name string + flags func() + wantReloads int + check func(t *testing.T) + }{ + { + name: "install", + flags: func() { install = true }, + wantReloads: 1, + check: func(t *testing.T) { + if !env.targetDir.Join("aa_test_kept").Exist() { + t.Error("kept profile was not installed") + } + }, + }, + { + name: "reinstall without changes", + flags: func() { install = true }, + wantReloads: 1, // unchanged: no new reload + }, + { + name: "status explicit", + flags: func() { status = true }, + wantReloads: 1, + }, + { + name: "uninstall", + flags: func() { uninstall = true }, + wantReloads: 2, + check: func(t *testing.T) { + if env.targetDir.Join("aa_test_kept").Exist() { + t.Error("profile still installed after uninstall") + } + }, + }, + { + name: "uninstall with nothing installed", + flags: func() { uninstall = true }, + wantReloads: 2, // nothing removed: no new reload + }, + } + for _, step := range steps { + install, all, complain, enforce = false, false, false, false + uninstall, status, list = false, false, false + step.flags() + if err := run(); err != nil { + t.Fatalf("run(%s) error = %v", step.name, err) + } + if env.reloads != step.wantReloads { + t.Errorf("run(%s) reloads = %d, want %d", step.name, env.reloads, step.wantReloads) + } + if step.check != nil { + step.check(t) + } + } +} diff --git a/cmd/aa-install/tools.go b/cmd/aa-install/tools.go new file mode 100644 index 000000000..9df00f5fa --- /dev/null +++ b/cmd/aa-install/tools.go @@ -0,0 +1,190 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "slices" + "strings" + + "github.com/roddhjav/apparmor.d/pkg/logging" + "github.com/roddhjav/apparmor.d/pkg/paths" +) + +const ( + // manifestFile is the name of the manifest file that tracks installed profiles. + manifestFile = "install.db" + + // symlinkPrefix marks a manifest entry as a symlink; the remainder is the + // link target. Disable links (disable/ -> ../) must be installed + // as symlinks, not dereferenced. The upstream profile they disable only + // exists on the target, so the link is dangling in the build directory. + symlinkPrefix = "symlink:" +) + +// hashFile returns the hex-encoded SHA-256 hash of a file's contents. +func hashFile(path *paths.Path) (string, error) { + data, err := path.ReadFile() + if err != nil { + return "", err + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]), nil +} + +// fileIdentity returns a change-detection identity for a build/target entry: +// the (prefixed) link target for a symlink, otherwise the content hash. +func fileIdentity(path *paths.Path) (string, error) { + isLink, err := path.IsSymlink() + if err != nil { + return "", err + } + if isLink { + target, err := os.Readlink(path.String()) + if err != nil { + return "", err + } + return symlinkPrefix + target, nil + } + return hashFile(path) +} + +// copyEntry writes the build entry to dst, recreating symlinks as symlinks. +func copyEntry(file, dst *paths.Path, ident string) error { + if target, ok := strings.CutPrefix(ident, symlinkPrefix); ok { + return os.Symlink(target, dst.String()) + } + return file.CopyTo(dst) +} + +// readManifest reads the manifest as a map of relative path → hash. +func readManifest(stateDir *paths.Path) map[string]string { + res := map[string]string{} + path := stateDir.Join(manifestFile) + if !path.Exist() { + return res + } + data, err := path.ReadFile() + if err != nil { + logging.Warning("Cannot read manifest %s: %s", path, err) + return res + } + for line := range strings.SplitSeq(strings.TrimSpace(string(data)), "\n") { + if fields := strings.SplitN(line, " ", 2); len(fields) == 2 { + res[fields[1]] = fields[0] + } + } + return res +} + +// writeManifest writes the manifest as "hash path" lines, sorted by path. +func writeManifest(stateDir *paths.Path, entries map[string]string) error { + if err := stateDir.MkdirAll(); err != nil { + return err + } + keys := make([]string, 0, len(entries)) + for k := range entries { + keys = append(keys, k) + } + slices.Sort(keys) + var buf strings.Builder + for _, k := range keys { + fmt.Fprintf(&buf, "%s %s\n", entries[k], k) + } + return stateDir.Join(manifestFile).WriteFile([]byte(buf.String())) +} + +// install copies files from the build directory to the install target +// and removes files that were previously installed but are no longer present. +// It uses a manifest file in stateDir to track installed files. +// Returns whether any file was added, updated, or removed. +func installProfiles(buildDir *paths.Path, targetDir *paths.Path, stateDir *paths.Path) (bool, error) { + previous := readManifest(stateDir) + + files, err := buildDir.ReadDirRecursiveFiltered(nil, paths.FilterOutDirectories()) + if err != nil { + return false, err + } + + newManifest := make(map[string]string, len(files)) + var added, updated, unchanged int + for _, file := range files { + rel, err := file.RelFrom(buildDir) + if err != nil { + return false, err + } + relStr := rel.String() + + ident, err := fileIdentity(file) + if err != nil { + return false, err + } + newManifest[relStr] = ident + + delete(previous, relStr) + + // Compare against the target itself, not the manifest, so that + // drifted or missing files are repaired on reinstall. Lstat so a + // symlink (possibly dangling) counts as present. + dst := targetDir.JoinPath(rel) + _, lerr := dst.Lstat() + existed := lerr == nil + if existed { + current, err := fileIdentity(dst) + if err != nil { + return false, err + } + if current == ident { + unchanged++ + continue + } + // Remove first: the entry type (file vs symlink) may change. + if err := dst.RemoveAll(); err != nil { + return false, err + } + } + + if err := dst.Parent().MkdirAll(); err != nil { + return false, err + } + if err := copyEntry(file, dst, ident); err != nil { + return false, err + } + + if existed { + updated++ + } else { + added++ + } + } + + removed := 0 + for old := range previous { + target := targetDir.Join(old) + if _, err := target.Lstat(); err == nil { + if err := target.RemoveAll(); err != nil { + return false, err + } + removed++ + } + } + + if err := writeManifest(stateDir, newManifest); err != nil { + return false, err + } + + logging.Indent = "" + logging.Success("Installed %d profiles to %s", len(newManifest), targetDir) + logging.Indent = " " + logging.Bullet("%d added, %d updated, %d unchanged", added, updated, unchanged) + if removed > 0 { + logging.Bullet("Removed %d stale files", removed) + } + logging.Indent = "" + return added+updated+removed > 0, nil +} diff --git a/cmd/aa-install/tools_test.go b/cmd/aa-install/tools_test.go new file mode 100644 index 000000000..280b5d6ed --- /dev/null +++ b/cmd/aa-install/tools_test.go @@ -0,0 +1,646 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "slices" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" +) + +func tempPath(t *testing.T, name string) *paths.Path { + t.Helper() + return paths.New(t.TempDir()).Join(name) +} + +func writeFile(t *testing.T, p *paths.Path, content string) { + t.Helper() + if err := p.Parent().MkdirAll(); err != nil { + t.Fatalf("mkdir %s: %v", p.Parent(), err) + } + if err := p.WriteFile([]byte(content)); err != nil { + t.Fatalf("write %s: %v", p, err) + } +} + +func writeFiles(t *testing.T, dir *paths.Path, files map[string]string) { + t.Helper() + for name, content := range files { + writeFile(t, dir.Join(name), content) + } +} + +func writeLinks(t *testing.T, dir *paths.Path, links map[string]string) { + t.Helper() + for name, target := range links { + p := dir.Join(name) + if err := p.Parent().MkdirAll(); err != nil { + t.Fatalf("mkdir %s: %v", p.Parent(), err) + } + if err := os.Symlink(target, p.String()); err != nil { + t.Fatalf("symlink %s: %v", p, err) + } + } +} + +func sha256Hex(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// chmod changes a path's mode for the test and restores a writable mode on +// cleanup so t.TempDir removal succeeds. Permission-based error tests are +// skipped for root, which bypasses file modes. +func chmod(t *testing.T, p *paths.Path, mode os.FileMode) { + t.Helper() + if os.Geteuid() == 0 { + t.Skip("running as root: permission errors cannot be provoked") + } + if err := os.Chmod(p.String(), mode); err != nil { + t.Fatalf("chmod %s: %v", p, err) + } + t.Cleanup(func() { _ = os.Chmod(p.String(), 0o755) }) +} + +func TestHashFile(t *testing.T) { + tests := []struct { + name string + content string + missing bool + want string + wantErr bool + }{ + {name: "hash content", content: "hello", want: sha256Hex("hello")}, + {name: "missing file", missing: true, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := tempPath(t, "f") + if !tt.missing { + writeFile(t, p, tt.content) + } + got, err := hashFile(p) + if (err != nil) != tt.wantErr { + t.Fatalf("hashFile() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("hashFile() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFileIdentity(t *testing.T) { + tests := []struct { + name string + content string // regular file content + link string // symlink target, takes precedence over content + missing bool + want string + wantErr bool + }{ + {name: "regular file hash", content: "hello", want: sha256Hex("hello")}, + {name: "symlink identity", link: "../x", want: symlinkPrefix + "../x"}, + {name: "missing file", missing: true, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := tempPath(t, "f") + switch { + case tt.missing: + case tt.link != "": + if err := os.Symlink(tt.link, p.String()); err != nil { + t.Fatalf("symlink: %v", err) + } + default: + writeFile(t, p, tt.content) + } + got, err := fileIdentity(p) + if (err != nil) != tt.wantErr { + t.Fatalf("fileIdentity() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("fileIdentity() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestWriteManifest(t *testing.T) { + tests := []struct { + name string + entries map[string]string + stateDirFile bool // the state dir path is an existing regular file + want string + wantErr bool + }{ + { + name: "sorted by path", + entries: map[string]string{ + "a/b.conf": "hash-b", + "a/a.conf": "hash-a", + "c.conf": "hash-c", + }, + want: "hash-a a/a.conf\nhash-b a/b.conf\nhash-c c.conf\n", + }, + { + name: "state dir is a file", + entries: map[string]string{"p": "h"}, + stateDirFile: true, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stateDir := paths.New(t.TempDir()) + if tt.stateDirFile { + stateDir = tempPath(t, "state") + writeFile(t, stateDir, "not a directory") + } + err := writeManifest(stateDir, tt.entries) + if (err != nil) != tt.wantErr { + t.Fatalf("writeManifest() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + data, err := stateDir.Join(manifestFile).ReadFile() + if err != nil { + t.Fatalf("read manifest: %v", err) + } + if string(data) != tt.want { + t.Errorf("writeManifest() wrote %q, want %q", string(data), tt.want) + } + }) + } +} + +func TestReadManifest(t *testing.T) { + tests := []struct { + name string + content string + missing bool + unreadable bool + want map[string]string + }{ + { + name: "missing manifest", + missing: true, + want: map[string]string{}, + }, + { + name: "round trip", + content: "hash-a a/a.conf\nhash-b b.conf\n", + want: map[string]string{"a/a.conf": "hash-a", "b.conf": "hash-b"}, + }, + { + name: "malformed lines ignored", + content: "hash1 path1\nmalformed-no-space\nhash2 path2\n", + want: map[string]string{"path1": "hash1", "path2": "hash2"}, + }, + { + name: "unreadable manifest", + content: "hash path\n", + unreadable: true, + want: map[string]string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stateDir := paths.New(t.TempDir()) + if !tt.missing { + writeFile(t, stateDir.Join(manifestFile), tt.content) + } + if tt.unreadable { + chmod(t, stateDir.Join(manifestFile), 0o000) + } + got := readManifest(stateDir) + if len(got) != len(tt.want) { + t.Fatalf("readManifest() = %v, want %v", got, tt.want) + } + for k, v := range tt.want { + if got[k] != v { + t.Errorf("readManifest()[%q] = %q, want %q", k, got[k], v) + } + } + }) + } +} + +func TestInstall(t *testing.T) { + tests := []struct { + name string + build map[string]string // build dir files for the first install + buildLinks map[string]string // build dir symlinks: relative path -> link target + twice bool // run install a second time after mutate + mutate func(t *testing.T, buildDir, targetDir *paths.Path) + wantChanged bool // changed result of the last install run + wantFiles map[string]string // target relative path -> expected content + wantLinks map[string]string // target relative path -> expected link target + wantGone []string // target relative paths that must not exist + wantManifest []string // exact sorted manifest keys + }{ + { + name: "adds files", + build: map[string]string{"profile.a": "content-a", "sub/profile.b": "content-b"}, + wantChanged: true, + wantFiles: map[string]string{"profile.a": "content-a", "sub/profile.b": "content-b"}, + wantManifest: []string{ + "profile.a", "sub/profile.b", + }, + }, + { + name: "noop when unchanged", + build: map[string]string{"p": "same"}, + twice: true, + wantChanged: false, + }, + { + name: "updates modified file", + build: map[string]string{"p": "v1"}, + twice: true, + mutate: func(t *testing.T, buildDir, targetDir *paths.Path) { + writeFile(t, buildDir.Join("p"), "v2") + }, + wantChanged: true, + wantFiles: map[string]string{"p": "v2"}, + }, + { + name: "repairs drifted and missing target", + build: map[string]string{"drifted": "good", "missing": "back"}, + twice: true, + mutate: func(t *testing.T, buildDir, targetDir *paths.Path) { + writeFile(t, targetDir.Join("drifted"), "tampered") + if err := targetDir.Join("missing").Remove(); err != nil { + t.Fatalf("remove target: %v", err) + } + }, + wantChanged: true, + wantFiles: map[string]string{"drifted": "good", "missing": "back"}, + }, + { + name: "removes stale files", + build: map[string]string{"keep": "k", "drop": "d"}, + twice: true, + mutate: func(t *testing.T, buildDir, targetDir *paths.Path) { + if err := buildDir.Join("drop").Remove(); err != nil { + t.Fatalf("remove from build: %v", err) + } + }, + wantChanged: true, + wantFiles: map[string]string{"keep": "k"}, + wantGone: []string{"drop"}, + wantManifest: []string{"keep"}, + }, + { + name: "installs disable link as symlink", + build: map[string]string{"systemd-detect-virt.apparmor.d": "profile\n"}, + buildLinks: map[string]string{"disable/systemd-detect-virt": "../systemd-detect-virt"}, + wantChanged: true, + wantFiles: map[string]string{"systemd-detect-virt.apparmor.d": "profile\n"}, + wantLinks: map[string]string{"disable/systemd-detect-virt": "../systemd-detect-virt"}, + wantManifest: []string{"disable/systemd-detect-virt", "systemd-detect-virt.apparmor.d"}, + }, + { + name: "noop when link unchanged", + buildLinks: map[string]string{"disable/x": "../x"}, + twice: true, + wantChanged: false, + wantLinks: map[string]string{"disable/x": "../x"}, + }, + { + name: "repairs link left as a regular file", + buildLinks: map[string]string{"disable/x": "../x"}, + twice: true, + mutate: func(t *testing.T, buildDir, targetDir *paths.Path) { + if err := targetDir.Join("disable/x").Remove(); err != nil { + t.Fatalf("remove target link: %v", err) + } + writeFile(t, targetDir.Join("disable/x"), "") + }, + wantChanged: true, + wantLinks: map[string]string{"disable/x": "../x"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buildDir := paths.New(t.TempDir()) + targetDir := paths.New(t.TempDir()) + stateDir := paths.New(t.TempDir()) + writeFiles(t, buildDir, tt.build) + writeLinks(t, buildDir, tt.buildLinks) + + changed, err := installProfiles(buildDir, targetDir, stateDir) + if err != nil { + t.Fatalf("install() error = %v", err) + } + if tt.twice { + if tt.mutate != nil { + tt.mutate(t, buildDir, targetDir) + } + changed, err = installProfiles(buildDir, targetDir, stateDir) + if err != nil { + t.Fatalf("install() second run error = %v", err) + } + } + if changed != tt.wantChanged { + t.Errorf("install() changed = %v, want %v", changed, tt.wantChanged) + } + for rel, want := range tt.wantFiles { + got, err := targetDir.Join(rel).ReadFileAsString() + if err != nil { + t.Errorf("read target %s: %v", rel, err) + continue + } + if got != want { + t.Errorf("target %s = %q, want %q", rel, got, want) + } + } + for rel, want := range tt.wantLinks { + got, err := os.Readlink(targetDir.Join(rel).String()) + if err != nil { + t.Errorf("readlink target %s: %v", rel, err) + continue + } + if got != want { + t.Errorf("target %s link = %q, want %q", rel, got, want) + } + } + for _, rel := range tt.wantGone { + if targetDir.Join(rel).Exist() { + t.Errorf("target %s still exists, want removed", rel) + } + } + if tt.wantManifest != nil { + manifest := readManifest(stateDir) + keys := make([]string, 0, len(manifest)) + for k := range manifest { + keys = append(keys, k) + } + slices.Sort(keys) + if !slices.Equal(keys, tt.wantManifest) { + t.Errorf("manifest keys = %v, want %v", keys, tt.wantManifest) + } + } + }) + } +} + +func TestInstall_Errors(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) (buildDir, targetDir, stateDir *paths.Path) + }{ + { + name: "missing build dir", + setup: func(t *testing.T) (*paths.Path, *paths.Path, *paths.Path) { + return tempPath(t, "missing"), paths.New(t.TempDir()), paths.New(t.TempDir()) + }, + }, + { + name: "unreadable build file", + setup: func(t *testing.T) (*paths.Path, *paths.Path, *paths.Path) { + buildDir := paths.New(t.TempDir()) + writeFile(t, buildDir.Join("p"), "x") + chmod(t, buildDir.Join("p"), 0o000) + return buildDir, paths.New(t.TempDir()), paths.New(t.TempDir()) + }, + }, + { + name: "undeletable drifted target file", + setup: func(t *testing.T) (*paths.Path, *paths.Path, *paths.Path) { + buildDir := paths.New(t.TempDir()) + targetDir := paths.New(t.TempDir()) + stateDir := paths.New(t.TempDir()) + writeFile(t, buildDir.Join("p"), "x") + if _, err := installProfiles(buildDir, targetDir, stateDir); err != nil { + t.Fatalf("first install: %v", err) + } + writeFile(t, targetDir.Join("p"), "drifted") + chmod(t, targetDir, 0o555) + return buildDir, targetDir, stateDir + }, + }, + { + name: "unreadable target file", + setup: func(t *testing.T) (*paths.Path, *paths.Path, *paths.Path) { + buildDir := paths.New(t.TempDir()) + targetDir := paths.New(t.TempDir()) + stateDir := paths.New(t.TempDir()) + writeFile(t, buildDir.Join("p"), "x") + if _, err := installProfiles(buildDir, targetDir, stateDir); err != nil { + t.Fatalf("first install: %v", err) + } + chmod(t, targetDir.Join("p"), 0o000) + return buildDir, targetDir, stateDir + }, + }, + { + name: "target subdir blocked by a file", + setup: func(t *testing.T) (*paths.Path, *paths.Path, *paths.Path) { + buildDir := paths.New(t.TempDir()) + targetDir := paths.New(t.TempDir()) + writeFile(t, buildDir.Join("sub/p"), "x") + writeFile(t, targetDir.Join("sub"), "a file, not a directory") + return buildDir, targetDir, paths.New(t.TempDir()) + }, + }, + { + name: "read-only target dir", + setup: func(t *testing.T) (*paths.Path, *paths.Path, *paths.Path) { + buildDir := paths.New(t.TempDir()) + targetDir := paths.New(t.TempDir()) + writeFile(t, buildDir.Join("p"), "x") + chmod(t, targetDir, 0o555) + return buildDir, targetDir, paths.New(t.TempDir()) + }, + }, + { + name: "undeletable stale file", + setup: func(t *testing.T) (*paths.Path, *paths.Path, *paths.Path) { + buildDir := paths.New(t.TempDir()) + targetDir := paths.New(t.TempDir()) + stateDir := paths.New(t.TempDir()) + writeFiles(t, buildDir, map[string]string{"keep": "k", "drop": "d"}) + if _, err := installProfiles(buildDir, targetDir, stateDir); err != nil { + t.Fatalf("first install: %v", err) + } + if err := buildDir.Join("drop").Remove(); err != nil { + t.Fatalf("remove from build: %v", err) + } + chmod(t, targetDir, 0o555) + return buildDir, targetDir, stateDir + }, + }, + { + name: "state dir is a file", + setup: func(t *testing.T) (*paths.Path, *paths.Path, *paths.Path) { + buildDir := paths.New(t.TempDir()) + stateDir := tempPath(t, "state") + writeFile(t, buildDir.Join("p"), "x") + writeFile(t, stateDir, "not a directory") + return buildDir, paths.New(t.TempDir()), stateDir + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buildDir, targetDir, stateDir := tt.setup(t) + if _, err := installProfiles(buildDir, targetDir, stateDir); err == nil { + t.Errorf("install() error = nil, wantErr true") + } + }) + } +} + +func TestAaStatus(t *testing.T) { + tests := []struct { + name string + manifest map[string]string + target map[string]string + unreadable []string // target files made unreadable + wantErr bool + }{ + { + name: "no manifest", + }, + { + name: "detects drift and missing", + manifest: map[string]string{ + "ok": sha256Hex("ok"), + "drifted": sha256Hex("old-content"), + "missing": sha256Hex("whatever"), + }, + target: map[string]string{"ok": "ok", "drifted": "new-content"}, + }, + { + name: "unreadable target", + manifest: map[string]string{"p": sha256Hex("x")}, + target: map[string]string{"p": "x"}, + unreadable: []string{"p"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + targetDir := paths.New(t.TempDir()) + stateDir := paths.New(t.TempDir()) + writeFiles(t, targetDir, tt.target) + if tt.manifest != nil { + if err := writeManifest(stateDir, tt.manifest); err != nil { + t.Fatalf("writeManifest: %v", err) + } + } + for _, rel := range tt.unreadable { + chmod(t, targetDir.Join(rel), 0o000) + } + err := aaStatus(stateDir, targetDir) + if (err != nil) != tt.wantErr { + t.Errorf("aaStatus() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAaList(t *testing.T) { + tests := []struct { + name string + manifest map[string]string + wantErr bool + }{ + {name: "empty manifest"}, + {name: "sorted paths", manifest: map[string]string{"b.conf": "h", "a.conf": "h"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stateDir := paths.New(t.TempDir()) + if tt.manifest != nil { + if err := writeManifest(stateDir, tt.manifest); err != nil { + t.Fatalf("writeManifest: %v", err) + } + } + err := aaList(stateDir, paths.New("/target")) + if (err != nil) != tt.wantErr { + t.Errorf("aaList() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAaUninstall(t *testing.T) { + tests := []struct { + name string + manifest map[string]string + target map[string]string + readOnlyTarget bool // make the target dir read-only + readOnlyState bool // make the state dir read-only + wantChanged bool + wantErr bool + }{ + { + name: "no manifest", + }, + { + name: "removes files and manifest", + manifest: map[string]string{"p1": sha256Hex("x"), "sub/p2": sha256Hex("y")}, + target: map[string]string{"p1": "x", "sub/p2": "y"}, + wantChanged: true, + }, + { + name: "undeletable target file", + manifest: map[string]string{"p": sha256Hex("x")}, + target: map[string]string{"p": "x"}, + readOnlyTarget: true, + wantErr: true, + }, + { + name: "undeletable manifest", + manifest: map[string]string{"p": "h"}, // listed file already gone + readOnlyState: true, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + targetDir := paths.New(t.TempDir()) + stateDir := paths.New(t.TempDir()) + writeFiles(t, targetDir, tt.target) + if tt.manifest != nil { + if err := writeManifest(stateDir, tt.manifest); err != nil { + t.Fatalf("writeManifest: %v", err) + } + } + if tt.readOnlyTarget { + chmod(t, targetDir, 0o555) + } + if tt.readOnlyState { + chmod(t, stateDir, 0o555) + } + + changed, err := aaUninstall(stateDir, targetDir) + if (err != nil) != tt.wantErr { + t.Fatalf("aaUninstall() error = %v, wantErr %v", err, tt.wantErr) + } + if changed != tt.wantChanged { + t.Errorf("aaUninstall() changed = %v, want %v", changed, tt.wantChanged) + } + if tt.wantErr || !tt.wantChanged { + return + } + for rel := range tt.target { + if targetDir.Join(rel).Exist() { + t.Errorf("target %s still exists after uninstall", rel) + } + } + if stateDir.Join(manifestFile).Exist() { + t.Error("manifest file still exists after uninstall") + } + }) + } +} diff --git a/cmd/aa-mode/main.go b/cmd/aa-mode/main.go index b842ca808..8d342b51f 100644 --- a/cmd/aa-mode/main.go +++ b/cmd/aa-mode/main.go @@ -114,23 +114,26 @@ func aaSetMode(files paths.PathList, mode string) error { return util.ReloadProfiles(modified) } -func main() { - flag.Usage = func() { fmt.Print(usage) } - flag.Parse() - if help || flag.NArg() < 1 { - flag.Usage() - os.Exit(0) - } - +func run() error { mode, err := selectedMode() if err != nil { - logging.Fatal("%s", err.Error()) + return err } files, err := paths.PathListFromArgs(flag.Args(), aa.MagicRoot) if err != nil { - logging.Fatal("%s", err.Error()) + return err + } + return aaSetMode(files, mode) +} + +func main() { + flag.Usage = func() { fmt.Print(usage) } + flag.Parse() + if help { + flag.Usage() + os.Exit(0) } - if err = aaSetMode(files, mode); err != nil { + if err := run(); err != nil { logging.Fatal("%s", err.Error()) } } diff --git a/cmd/prebuild/main.go b/cmd/prebuild/main.go index 165d9e21a..2ae45dd93 100644 --- a/cmd/prebuild/main.go +++ b/cmd/prebuild/main.go @@ -71,17 +71,17 @@ func main() { )). // Ignore profiles and files from dist/ignore - Add(configure.NewIgnore()). + Add(configure.NewIgnore()) + if !cli.Future() { // Merge profiles (from group/, profiles-*-*/) to a unified apparmor.d directory - Add(configure.NewMerge()). + r.Configures.Add(configure.NewMerge()) + } + r.Configures. // Set distribution specificities Add(configure.NewConfigure()). - // Overwrite dummy upstream profile - Add(configure.NewOverwrite(false)). - // Set systemd unit drop in files for dbus profiles Add(configure.NewSystemdDefault()) diff --git a/debian/apparmor.d.postinst b/debian/apparmor.d.postinst new file mode 100755 index 000000000..5dd600d7f --- /dev/null +++ b/debian/apparmor.d.postinst @@ -0,0 +1,18 @@ +#!/bin/bash +# apparmor.d - Full set of apparmor profiles +# Copyright (C) 2021-2026 Alexandre Pujol +# SPDX-License-Identifier: GPL-2.0-only + +set -e + +#DEBHELPER# + +case "$1" in +configure | triggered) + if command -v aa-install >/dev/null 2>&1; then + aa-install --install || true + fi + ;; +esac + +exit 0 diff --git a/debian/apparmor.d.postrm b/debian/apparmor.d.postrm new file mode 100755 index 000000000..d18df91db --- /dev/null +++ b/debian/apparmor.d.postrm @@ -0,0 +1,18 @@ +#!/bin/bash +# apparmor.d - Full set of apparmor profiles +# Copyright (C) 2021-2026 Alexandre Pujol +# SPDX-License-Identifier: GPL-2.0-only + +set -e + +#DEBHELPER# + +case "$1" in +remove | purge) + if command -v aa-install >/dev/null 2>&1; then + aa-install --uninstall || true + fi + ;; +esac + +exit 0 diff --git a/debian/apparmor.d.triggers b/debian/apparmor.d.triggers new file mode 100644 index 000000000..a16863e09 --- /dev/null +++ b/debian/apparmor.d.triggers @@ -0,0 +1,8 @@ +# apparmor.d - Full set of apparmor profiles +# Copyright (C) 2021-2026 Alexandre Pujol +# SPDX-License-Identifier: GPL-2.0-only + +# Re-run aa-install whenever any package is modified. +# Note: this is not enough and will be revisited later +interest-noawait /usr +interest-noawait /opt diff --git a/debian/common.hide b/debian/common.hide deleted file mode 100644 index 8fc1d019d..000000000 --- a/debian/common.hide +++ /dev/null @@ -1 +0,0 @@ -# This file is generated by "just", all edit will be lost. diff --git a/debian/common.postinst b/debian/common.postinst deleted file mode 100644 index dfb2b6084..000000000 --- a/debian/common.postinst +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -# apparmor.d - Full set of apparmor profiles -# Copyright (C) 2021-2024 Alexandre Pujol -# SPDX-License-Identifier: GPL-2.0-only - -set -e - -#DEBHELPER# - -deb-systemd-invoke reload apparmor.service || true - -exit 0 diff --git a/debian/common.postrm b/debian/common.postrm deleted file mode 100644 index 2d83cea75..000000000 --- a/debian/common.postrm +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -# apparmor.d - Full set of apparmor profiles -# Copyright (C) 2021-2024 Alexandre Pujol -# SPDX-License-Identifier: GPL-2.0-only - -set -e - -#DEBHELPER# - -case "$1" in - remove|purge) - apparmor_parser --purge-cache 2>/dev/null || true - deb-systemd-invoke reload apparmor.service || true - ;; -esac - -exit 0 diff --git a/debian/control b/debian/control index 2b61e694c..809e11ccd 100644 --- a/debian/control +++ b/debian/control @@ -4,7 +4,6 @@ Priority: standard Maintainer: Alexandre Pujol Build-Depends: debhelper-compat (= 13), golang-go, - config-package-dev, just, Homepage: https://github.com/roddhjav/apparmor.d Vcs-Browser: https://github.com/roddhjav/apparmor.d @@ -14,16 +13,24 @@ Rules-Requires-Root: no Package: apparmor.d Architecture: any -Depends: apparmor-profiles, ${misc:Depends}, ${shlibs:Depends} -Conflicts: apparmor-profiles-extra, apparmor.d.enforced, -Description: Full set of AppArmor profiles (complain mode) +Depends: ${misc:Depends}, + apparmor-profiles, + apparmor.d-base (= ${binary:Version}), + apparmor.d-tools (= ${binary:Version}), +Conflicts: apparmor-profiles-extra +Description: Full set of AppArmor profiles apparmor.d is a set of over 2000 AppArmor policies whose aim is to confine most Linux based applications and processes. -Package: apparmor.d.enforced +Package: apparmor.d-base +Architecture: all +Description: Full set of AppArmor profiles (base abstractions, tunables, and booleans) + apparmor.d-base is a set of base abstractions, tunables, and booleans defined + by apparmor.d + +Package: apparmor.d-tools Architecture: any -Depends: apparmor-profiles, ${misc:Depends}, ${shlibs:Depends} -Conflicts: apparmor-profiles-extra, apparmor.d, -Description: Full set of AppArmor profiles (enforced mode) - apparmor.d is a set of over 2000 AppArmor policies whose aim is to confine - most Linux based applications and processes. +Depends: ${misc:Depends}, ${shlibs:Depends} +Description: Full set of AppArmor profiles (userland toolings) + apparmor.d-tools is a set of userland toolings to help manage AppArmor + profiles defined in apparmor.d. diff --git a/debian/copyright b/debian/copyright index 04900924f..453253a3a 100644 --- a/debian/copyright +++ b/debian/copyright @@ -4,7 +4,7 @@ Upstream-Contact: Alexandre Pujol Source: https://gitlab.com/roddhjav/apparmor.d Files: * -Copyright: 2021-2025 Alexandre Pujol +Copyright: 2021-2026 Alexandre Pujol License: GPL-2 License: GPL-2 diff --git a/debian/rules b/debian/rules index 975069306..2a0e0e947 100755 --- a/debian/rules +++ b/debian/rules @@ -17,7 +17,7 @@ export CGO_LDFLAGS := $(LDFLAGS) export GOFLAGS := -buildmode=pie -trimpath -ldflags=-linkmode=external -mod=readonly -modcacherw %: - dh $@ --with=config-package + dh $@ # golang/1.19 compresses debug symbols itself. override_dh_dwz: @@ -32,23 +32,10 @@ override_dh_auto_test: override_dh_strip: dh_strip --no-automatic-dbgsym -override_dh_install: - dh_install - for pkgname in $(shell dh_listpackages); do \ - cp debian/common.hide debian/$${pkgname}.hide; \ - cp debian/common.postinst debian/$${pkgname}.postinst; \ - cp debian/common.postrm debian/$${pkgname}.postrm; \ - done - override_dh_auto_build: - just build=.build/enforce enforce - just build=.build/complain complain + just build=.build prebuild override_dh_auto_install: - just build=.build/complain destdir="${CURDIR}/debian/apparmor.d" install-prebuilt - just build=.build/complain destdir="${CURDIR}/debian/apparmor.d" install-base - just build=.build/complain destdir="${CURDIR}/debian/apparmor.d" install-tools - just build=.build/enforce destdir="${CURDIR}/debian/apparmor.d.enforced" install-prebuilt - just build=.build/enforce destdir="${CURDIR}/debian/apparmor.d.enforced" install-base - just build=.build/enforce destdir="${CURDIR}/debian/apparmor.d.enforced" install-tools - + just build=.build destdir="${CURDIR}/debian/apparmor.d" install-prebuilt + just build=.build destdir="${CURDIR}/debian/apparmor.d-base" install-base + just build=.build destdir="${CURDIR}/debian/apparmor.d-tools" install-tools diff --git a/dists/apparmor.d.spec b/dists/apparmor.d.spec index c213628cf..cf026c146 100644 --- a/dists/apparmor.d.spec +++ b/dists/apparmor.d.spec @@ -14,6 +14,8 @@ License: GPL-2.0-only URL: https://github.com/roddhjav/apparmor.d Source0: %{name}-%{version}.tar.gz Requires: apparmor-profiles +Requires: %{name}-base = %{version}-%{release} +Requires: %{name}-tools = %{version}-%{release} BuildRequires: distribution-release BuildRequires: just BuildRequires: golang-packaging @@ -22,33 +24,64 @@ BuildRequires: apparmor-profiles %description AppArmor.d is a set of over 1500 AppArmor profiles whose aim is to confine most Linux based applications and processes. +%package base +Summary: Full set of AppArmor policies (base abstractions, tunables, and booleans) +BuildArch: noarch + +%description base +apparmor.d-base is a set of base abstractions, tunables, and booleans defined by apparmor.d. + +%package tools +Summary: Full set of AppArmor policies (userland toolings) + +%description tools +apparmor.d-tools is a set of userland toolings to help manage AppArmor profiles defined in apparmor.d. + %prep %autosetup %build -just complain +just prebuild %install just destdir="%{buildroot}" install-prebuilt just destdir="%{buildroot}" install-base just destdir="%{buildroot}" install-tools -%posttrans -apparmor_parser --purge-cache -%restart_on_update apparmor +%transfiletriggerin -- /usr /opt +if command -v aa-install >/dev/null 2>&1; then + aa-install --install || true +fi + +%transfiletriggerpostun -- /usr /opt +if command -v aa-install >/dev/null 2>&1; then + aa-install --install || true +fi %files %license LICENSE %doc README.md -%config /etc/apparmor.d/ -/usr/bin/aa-log -/usr/bin/aa-mode +/usr/share/apparmor.d/ +%config /etc/apparmor.d/disable/hostname %dir /usr/lib/systemd/system/*.service.d /usr/lib/systemd/system/*.service.d/apparmor.conf %dir /usr/lib/systemd/user/*.service.d /usr/lib/systemd/user/*.service.d/apparmor.conf +%files base +%config /etc/apparmor.d/abstractions +%config /etc/apparmor.d/tunables + +%files tools +/usr/bin/aa-* + +%dir /usr/share/apparmor +/usr/share/apparmor/modes +/usr/share/apparmor/flags.d +/usr/share/apparmor/ignore.d +/usr/share/apparmor/overwrite.d/ + %dir /usr/share/zsh %dir /usr/share/zsh/site-functions /usr/share/zsh/site-functions/_aa-*.zsh diff --git a/dists/docker.sh b/dists/docker.sh index 7259975ea..f73f51cb8 100644 --- a/dists/docker.sh +++ b/dists/docker.sh @@ -91,7 +91,12 @@ build_in_docker_dpkg() { docker run -tid --name "$img" --volume "$VOLUME:$BUILDIR" \ --env DISTRIBUTION="$target" "$BASEIMAGE/$dist:$release" docker exec "$img" sudo apt-get update -q - docker exec "$img" sudo apt-get install -y config-package-dev lsb-release libdistro-info-perl golang-go + docker exec "$img" sudo apt-get install -y lsb-release libdistro-info-perl + local aptopt=() + if [[ "$dist" == debian && "$release" == "13" ]]; then + aptopt=(-t trixie-backports) + fi + docker exec "$img" sudo apt-get install -y "${aptopt[@]}" golang-go fi docker exec --workdir="$BUILDIR/$PKGNAME" "$img" just build-dpkg diff --git a/dists/flags/arch.flags b/dists/flags/arch.flags deleted file mode 100644 index a50c0b3a0..000000000 --- a/dists/flags/arch.flags +++ /dev/null @@ -1,4 +0,0 @@ -aurpublish complain -makepkg complain -mkinitcpio complain -pacman complain diff --git a/dists/ignore.d/arch.conf b/dists/ignore.d/arch.conf new file mode 100644 index 000000000..2fb9f430c --- /dev/null +++ b/dists/ignore.d/arch.conf @@ -0,0 +1,14 @@ +# Debian specific definition +apparmor.d/groups/apt +share/apparmor/flags.d/10-debian.conf +share/apparmor/ignore.d/10-debian.conf + +# Ubuntu specific definition +apparmor.d/groups/ubuntu +share/apparmor/flags.d/10-ubuntu.conf +share/apparmor/ignore.d/10-ubuntu.conf + +# OpenSUSE specific definition +apparmor.d/groups/suse +share/apparmor/flags.d/10-opensuse.conf +share/apparmor/ignore.d/10-opensuse.conf diff --git a/dists/ignore.d/debian.conf b/dists/ignore.d/debian.conf new file mode 100644 index 000000000..4004bcdc4 --- /dev/null +++ b/dists/ignore.d/debian.conf @@ -0,0 +1,20 @@ +# Archlinux specific +apparmor.d/groups/pacman +share/apparmor/flags.d/10-arch.conf +share/apparmor/ignore.d/10-arch.conf +share/libalpm + +# Ubuntu specific definition +apparmor.d/groups/ubuntu +share/apparmor/flags.d/10-ubuntu.conf +share/apparmor/ignore.d/10-ubuntu.conf + +# OpenSUSE specific definition +apparmor.d/groups/suse +share/apparmor/flags.d/10-opensuse.conf +share/apparmor/ignore.d/10-opensuse.conf + +# Profiles provided by they own package +chronyd +# libvirt +# virt-aa-helper diff --git a/dists/ignore.d/main.conf b/dists/ignore.d/main.conf new file mode 100644 index 000000000..4227ad722 --- /dev/null +++ b/dists/ignore.d/main.conf @@ -0,0 +1,10 @@ +# Common ignore file for all distributions +# File format: one ignore by line, it can be a profile name or a directory to ignore + +# Profiles here are not shipped in the apparmor.d package while profiles in +# share/apparmor/ignore.d are not enabled on the target host. + +# Work in progress and conflicts +apparmor.d/groups/cosmic +apparmor.d/namespaces/flatpak + diff --git a/dists/ignore/opensuse.ignore b/dists/ignore.d/opensuse.conf similarity index 50% rename from dists/ignore/opensuse.ignore rename to dists/ignore.d/opensuse.conf index 85fd1217d..ebabe195f 100644 --- a/dists/ignore/opensuse.ignore +++ b/dists/ignore.d/opensuse.conf @@ -1,12 +1,18 @@ # Archlinux specific apparmor.d/groups/pacman +share/apparmor/flags.d/10-arch.conf +share/apparmor/ignore.d/10-arch.conf share/libalpm # Debian specific definition apparmor.d/groups/apt +share/apparmor/flags.d/10-debian.conf +share/apparmor/ignore.d/10-debian.conf # Ubuntu specific definition apparmor.d/groups/ubuntu +share/apparmor/flags.d/10-ubuntu.conf +share/apparmor/ignore.d/10-ubuntu.conf # Profiles provided by they own package libvirt diff --git a/dists/ignore.d/ubuntu.conf b/dists/ignore.d/ubuntu.conf new file mode 100644 index 000000000..39b3b8af2 --- /dev/null +++ b/dists/ignore.d/ubuntu.conf @@ -0,0 +1,15 @@ +# Archlinux specific +apparmor.d/groups/pacman +share/apparmor/flags.d/10-arch.conf +share/apparmor/ignore.d/10-arch.conf +share/libalpm + +# OpenSUSE specific definition +apparmor.d/groups/suse +share/apparmor/flags.d/10-opensuse.conf +share/apparmor/ignore.d/10-opensuse.conf + +# Profiles provided by they own package +chronyd +# libvirt +# virt-aa-helper diff --git a/dists/ignore/arch.ignore b/dists/ignore/arch.ignore deleted file mode 100644 index c66f1d6d9..000000000 --- a/dists/ignore/arch.ignore +++ /dev/null @@ -1,8 +0,0 @@ -# Debian specific definition -apparmor.d/groups/apt - -# Ubuntu specific definition -apparmor.d/groups/ubuntu - -# OpenSUSE specific definition -apparmor.d/groups/suse diff --git a/dists/ignore/debian.ignore b/dists/ignore/debian.ignore deleted file mode 100644 index 4127be3bb..000000000 --- a/dists/ignore/debian.ignore +++ /dev/null @@ -1,14 +0,0 @@ -# Archlinux specific -apparmor.d/groups/pacman -share/libalpm - -# Ubuntu specific definition -apparmor.d/groups/ubuntu - -# OpenSUSE specific definition -apparmor.d/groups/suse - -# Profiles provided by they own package -chronyd -libvirt -virt-aa-helper diff --git a/dists/ignore/ubuntu.ignore b/dists/ignore/ubuntu.ignore deleted file mode 100644 index 7c7556766..000000000 --- a/dists/ignore/ubuntu.ignore +++ /dev/null @@ -1,11 +0,0 @@ -# Archlinux specific -apparmor.d/groups/pacman -share/libalpm - -# OpenSUSE specific definition -apparmor.d/groups/suse - -# Profiles provided by they own package -chronyd -libvirt -virt-aa-helper diff --git a/pkg/aa/parse.go b/pkg/aa/parse.go index 3579528b4..7e768e811 100644 --- a/pkg/aa/parse.go +++ b/pkg/aa/parse.go @@ -66,7 +66,6 @@ var ( HAT: "^", } inHeader = false - regParagraph = regexp.MustCompile(`(?s).*?\n\n|$`) regVariableDefinition = regexp.MustCompile(`@{(.*)}\s*[+=]+\s*(.*)`) ) @@ -1059,29 +1058,73 @@ func (f *AppArmorProfileFile) Parse(input string) (int, error) { return 0, nil } +// splitParagraphs splits input into paragraphs ending with a blank line +// (`\n\n`). Trailing content without a terminating blank line is discarded, +// matching the long-standing behavior of the previous regexp-based splitter. +// +// Adjacent paragraphs are merged when the next one continues an +// `if`/`else if`/`else` chain started by the previous (i.e. its first +// non-blank line begins with `}`). Without this, a chain like +// +// if "gnome" in @{DE} { ... } else if "kde" in @{DE} { ... } else { ... } +// +// separated by blank lines would emit paragraphs with a leading `}` whose +// matching `{` lives in the previous paragraph, and the rule tokenizer would +// see unbalanced braces. +func splitParagraphs(input string) []string { + var paragraphs []string + start := 0 + for i := 0; i+1 < len(input); i++ { + if input[i] == '\n' && input[i+1] == '\n' { + chunk := input[start : i+2] + if len(paragraphs) > 0 && startsWithClosingBrace(chunk) { + paragraphs[len(paragraphs)-1] += chunk + } else { + paragraphs = append(paragraphs, chunk) + } + start = i + 2 + i++ // skip the second \n + } + } + return paragraphs +} + +// startsWithClosingBrace reports whether the first non-whitespace, +// non-comment line of s starts with `}`. +func startsWithClosingBrace(s string) bool { + for _, line := range strings.Split(s, "\n") { + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + return strings.HasPrefix(trimmed, "}") + } + return false +} + // ParseRules parses apparmor profile rules by paragraphs func ParseRules(input string) (ParaRules, []string, error) { paragraphRules := ParaRules{} paragraphs := []string{} - for _, match := range regParagraph.FindAllStringSubmatch(input, -1) { - if len(match[0]) == 0 { + for _, raw := range splitParagraphs(input) { + if len(raw) == 0 { continue } // Ignore blocks header - tmp := strings.TrimLeft(match[0], "\t ") + tmp := strings.TrimLeft(raw, "\t ") tmp = strings.TrimRight(tmp, "\n") var paragraph string switch { case strings.HasPrefix(tmp, PROFILE.Tok()): - _, paragraph, _ = strings.Cut(match[0], "\n") + _, paragraph, _ = strings.Cut(raw, "\n") case strings.HasPrefix(tmp, HAT.String()), strings.HasPrefix(tmp, HAT.Tok()): - _, paragraph, _ = strings.Cut(match[0], "\n") + _, paragraph, _ = strings.Cut(raw, "\n") case strings.HasSuffix(tmp, "}"): - paragraph = strings.Replace(match[0], "}\n", "\n", 1) + paragraph = strings.Replace(raw, "}\n", "\n", 1) default: - paragraph = match[0] + paragraph = raw } paragraphs = append(paragraphs, paragraph) diff --git a/pkg/aa/parse_test.go b/pkg/aa/parse_test.go index ecaebe8c0..ca69cd321 100644 --- a/pkg/aa/parse_test.go +++ b/pkg/aa/parse_test.go @@ -398,6 +398,12 @@ func Test_Parser_UpstreamTestSuite(t *testing.T) { if !file.Exist() { panic(file.String() + " %s not found") } + inDir, err := file.IsInsideDir(datadir.Join("generated_x")) + if inDir && err == nil { + continue + } else if err != nil { + panic(err) + } name, err := file.RelFrom(datadir) if err != nil { panic(err) @@ -458,6 +464,7 @@ func Test_Parser_UpstreamTestSuite(t *testing.T) { // [01/06/24]: 1986 tests, success: 1242, fail 744, success rate: 62% // [14/12/25]: 2148 tests, success: 1722, fail 422, success rate: 80% // [23/03/26]: 2222 tests, success: 2035, fail 187, success rate: 91% + // [01/05/26]: 50645 tests, success: 48787, fail 1858, success rate: 96% reports.SumUp(t) } diff --git a/pkg/aa/util.go b/pkg/aa/util.go index 3228a947b..cd3086418 100644 --- a/pkg/aa/util.go +++ b/pkg/aa/util.go @@ -164,7 +164,7 @@ func validateValues(kind Kind, key string, values []string) error { continue } - // Skip variable references — they will be expanded at runtime + // Skip variable references, they will be expanded at runtime if strings.Contains(v, "@{") { continue } diff --git a/pkg/builder/attach.go b/pkg/builder/attach.go index 9f1b99834..b51f1f5fd 100644 --- a/pkg/builder/attach.go +++ b/pkg/builder/attach.go @@ -33,7 +33,6 @@ func NewAttach() *ReAttach { // - For compatibility, non disconnected profile will have the @{att} variable set to / func (b ReAttach) Apply(opt *Option, profile string) (string, error) { var insert string - var origin = "profile " + opt.Name isInside, err := opt.File.IsInsideDir(b.RootApparmor.Join("abstractions/attached")) if err != nil { @@ -43,16 +42,24 @@ func (b ReAttach) Apply(opt *Option, profile string) (string, error) { return profile, nil // Do not re-attach twice } + // The header name is authoritative: for namespaced profiles the filename + // (opt.Name) is e.g. "bwrap" while the header is ":glycin:bwrap". + name := opt.Name + if matches := regProfileName.FindStringSubmatch(profile); matches != nil { + name = matches[1] + } + origin := "profile " + name + if strings.Contains(profile, "attach_disconnected") { if opt.Kind == aa.ProfileKind { - if strings.Contains(opt.Name, ":") { - parts := strings.Split(opt.Name, ":") + if strings.Contains(name, ":") { + parts := strings.Split(name, ":") if len(parts) != 3 { - return profile, fmt.Errorf("attach: invalid namespaced profile name: %s", opt.Name) + return profile, fmt.Errorf("attach: invalid namespaced profile name: %s", name) } insert = "@{att} = /att/" + parts[1] + "/\n" } else { - insert = "@{att} = /att/" + opt.Name + "/\n" + insert = "@{att} = /att/" + name + "/\n" } } replacer := strings.NewReplacer( diff --git a/pkg/builder/core_test.go b/pkg/builder/core_test.go index 71f5d97c0..3a7832393 100644 --- a/pkg/builder/core_test.go +++ b/pkg/builder/core_test.go @@ -5,8 +5,10 @@ package builder import ( + "strings" "testing" + "github.com/roddhjav/apparmor.d/pkg/aa" "github.com/roddhjav/apparmor.d/pkg/paths" "github.com/roddhjav/apparmor.d/pkg/tasks" ) @@ -127,6 +129,28 @@ func TestBuilder_Apply(t *testing.T) { include if exists }`, }, + { + name: "complain-4", + b: NewComplain(), + profile: ` + @{exec_path} = @{bin}/foo + profile foo @{exec_path} flags=(unconfined) { + include + + @{exec_path} mr, + + include if exists + }`, + want: ` + @{exec_path} = @{bin}/foo + profile foo @{exec_path} flags=(unconfined) { + include + + @{exec_path} mr, + + include if exists + }`, + }, { name: "enforce-1", b: NewEnforce(), @@ -193,6 +217,30 @@ func TestBuilder_Apply(t *testing.T) { include if exists }`, }, + { + name: "deploy-mode complain", + b: NewDeployMode("complain", nil), + profile: "\nprofile foo /{,usr/}bin/foo {\n include \n}", + want: "\nprofile foo /{,usr/}bin/foo flags=(complain) {\n include \n}", + }, + { + name: "deploy-mode enforce clears complain", + b: NewDeployMode("enforce", nil), + profile: "\nprofile foo /{,usr/}bin/foo flags=(complain) {\n include \n}", + want: "\nprofile foo /{,usr/}bin/foo {\n include \n}", + }, + { + name: "deploy-mode override wins", + b: NewDeployMode("complain", map[string]bool{"foo": true}), + profile: "\nprofile foo /{,usr/}bin/foo {\n include \n}", + want: "\nprofile foo /{,usr/}bin/foo {\n include \n}", + }, + { + name: "deploy-mode empty is noop", + b: NewDeployMode("", nil), + profile: "\nprofile foo /{,usr/}bin/foo flags=(complain) {\n include \n}", + want: "\nprofile foo /{,usr/}bin/foo flags=(complain) {\n include \n}", + }, { name: "fsp", b: NewFSP(), @@ -325,6 +373,19 @@ profile attach-2 flags=(complain) { include include include +}`, + }, + { + name: "attach-namespace", + b: NewAttach(), + profile: ` +profile :glycin:bwrap flags=(attach_disconnected) { + include +}`, + want: ` +@{att} = /att/glycin/ +profile :glycin:bwrap flags=(attach_disconnected,attach_disconnected.path=@{att}) { + include }`, }, { @@ -366,6 +427,112 @@ profile debug-1 { } } +func TestNewOption(t *testing.T) { + tests := []struct { + name string + file *paths.Path + wantName string + wantKind aa.FileKind + }{ + { + name: "profile", + file: cfg.RootApparmor.Join("foo"), + wantName: "foo", + wantKind: aa.ProfileKind, + }, + { + name: "profile-with-suffix", + file: cfg.RootApparmor.Join("bar.apparmor.d"), + wantName: "bar", + wantKind: aa.ProfileKind, + }, + { + name: "abstraction", + file: cfg.RootApparmor.Join("abstractions", "app", "foo"), + wantName: "foo", + wantKind: aa.AbstractionKind, + }, + { + name: "tunable", + file: cfg.RootApparmor.Join("tunables", "global"), + wantName: "global", + wantKind: aa.TunableKind, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewOption(tt.file) + if got.Name != tt.wantName { + t.Errorf("NewOption() Name = %v, want %v", got.Name, tt.wantName) + } + if got.Kind != tt.wantKind { + t.Errorf("NewOption() Kind = %v, want %v", got.Kind, tt.wantKind) + } + if got.File != tt.file { + t.Errorf("NewOption() File = %v, want %v", got.File, tt.file) + } + }) + } +} + +func TestBuilders_Run(t *testing.T) { + tests := []struct { + name string + builders []Builder + profile string + want string + wantErr bool + }{ + { + name: "no-builder", + builders: []Builder{}, + profile: "profile foo /usr/bin/foo {\n}\n", + want: "profile foo /usr/bin/foo {\n}\n", + wantErr: false, + }, + { + name: "complain-then-enforce", + builders: []Builder{NewComplain(), NewEnforce()}, + profile: "profile foo /usr/bin/foo {\n}\n", + want: "profile foo /usr/bin/foo {\n}\n", + wantErr: false, + }, + { + name: "complain", + builders: []Builder{NewComplain()}, + profile: "profile foo /usr/bin/foo {\n}\n", + want: "profile foo /usr/bin/foo flags=(complain) {\n}\n", + wantErr: false, + }, + { + name: "error", + builders: []Builder{NewUserspace()}, + profile: "profile foo /usr/bin/foo {\n}\n", + want: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRunner(cfg) + for _, b := range tt.builders { + r.Add(b) + } + got, err := r.Run(cfg.RootApparmor.Join("foo"), tt.profile) + if (err != nil) != tt.wantErr { + t.Errorf("Builders.Run() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr && !strings.Contains(err.Error(), "userspace") { + t.Errorf("Builders.Run() error = %v, want it to contain the builder name", err) + } + if got != tt.want { + t.Errorf("Builders.Run() = %v, want %v", got, tt.want) + } + }) + } +} + func TestBuilders_Add(t *testing.T) { tests := []struct { name string diff --git a/pkg/builder/mode.go b/pkg/builder/mode.go new file mode 100644 index 000000000..52c7592fa --- /dev/null +++ b/pkg/builder/mode.go @@ -0,0 +1,45 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package builder + +import ( + "github.com/roddhjav/apparmor.d/pkg/tasks" + "github.com/roddhjav/apparmor.d/pkg/util" +) + +// DeployMode sets the default deploy mode (enforce/complain) on every profile, +// except those a user flags.d drop-in already assigns a mode to, those win. +type DeployMode struct { + tasks.BaseTask + mode string + overridden map[string]bool +} + +// NewDeployMode creates a new DeployMode builder. An empty mode is a no-op +// (profiles keep the mode they were built with). +func NewDeployMode(mode string, overridden map[string]bool) *DeployMode { + return &DeployMode{ + BaseTask: tasks.BaseTask{ + Keyword: "deploy-mode", + Msg: "Build: set the default deploy mode", + }, + mode: mode, + overridden: overridden, + } +} + +func (b DeployMode) Apply(opt *Option, profile string) (string, error) { + if b.mode == "" { + return profile, nil + } + name := opt.Name + if m := regProfileName.FindStringSubmatch(profile); m != nil { + name = m[1] + } + if b.overridden[name] { + return profile, nil // a user flags.d drop-in sets this profile's mode + } + return util.SetMode(profile, b.mode) +} diff --git a/pkg/builder/profile-mode_test.go b/pkg/builder/profile-mode_test.go new file mode 100644 index 000000000..0b4c14dc0 --- /dev/null +++ b/pkg/builder/profile-mode_test.go @@ -0,0 +1,104 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package builder + +import ( + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/prebuild" + "github.com/roddhjav/apparmor.d/pkg/tasks" +) + +// setupFlagDir points prebuild.FlagDir and tasks.Distribution at synthetic +// flag manifests, restored on test cleanup. +func setupFlagDir(t *testing.T) { + t.Helper() + flagDir := paths.New(t.TempDir()) + if err := flagDir.Join("main.conf").WriteFile([]byte( + "foo complain\n" + // profile mode from main.conf + "noflags\n" + // profile without any flag, must be skipped + "baz not-a-mode\n", // invalid mode, Apply must error + )); err != nil { + t.Fatal(err) + } + if err := flagDir.Join("testdist.conf").WriteFile([]byte( + "bar unconfined\n", // profile mode from the distribution flags + )); err != nil { + t.Fatal(err) + } + + origFlagDir := prebuild.FlagDir + origDist := tasks.Distribution + prebuild.FlagDir = flagDir + tasks.Distribution = "testdist" + t.Cleanup(func() { + prebuild.FlagDir = origFlagDir + tasks.Distribution = origDist + }) +} + +func TestProfileMode_Apply(t *testing.T) { + setupFlagDir(t) + b := NewProfileMode() + b.SetConfig(cfg) + + tests := []struct { + name string + profile string + want string + wantErr bool + }{ + { + name: "no-profile-name", + profile: "# a file without profile header\n", + want: "# a file without profile header\n", + wantErr: false, + }, + { + name: "name-not-in-modes", + profile: "profile other /usr/bin/other {\n}\n", + want: "profile other /usr/bin/other {\n}\n", + wantErr: false, + }, + { + name: "no-flags-entry", + profile: "profile noflags /usr/bin/noflags {\n}\n", + want: "profile noflags /usr/bin/noflags {\n}\n", + wantErr: false, + }, + { + name: "set-mode-main", + profile: "profile foo /usr/bin/foo {\n}\n", + want: "profile foo /usr/bin/foo flags=(complain) {\n}\n", + wantErr: false, + }, + { + name: "set-mode-dist", + profile: "profile bar /usr/bin/bar flags=(attach_disconnected,complain) {\n}\n", + want: "profile bar /usr/bin/bar flags=(attach_disconnected,unconfined) {\n}\n", + wantErr: false, + }, + { + name: "invalid-mode", + profile: "profile baz /usr/bin/baz {\n}\n", + want: "profile baz /usr/bin/baz {\n}\n", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opt := &Option{File: cfg.RootApparmor.Join(tt.name), Name: tt.name} + got, err := b.Apply(opt, tt.profile) + if (err != nil) != tt.wantErr { + t.Errorf("ProfileMode.Apply() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ProfileMode.Apply() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/configure/configure.go b/pkg/configure/configure.go index 26a59b885..651728c7a 100644 --- a/pkg/configure/configure.go +++ b/pkg/configure/configure.go @@ -8,7 +8,6 @@ import ( "fmt" "strings" - "github.com/roddhjav/apparmor.d/pkg/prebuild" "github.com/roddhjav/apparmor.d/pkg/tasks" ) @@ -42,10 +41,6 @@ func (p Configure) Apply() ([]string, error) { case "arch", "opensuse": case "ubuntu": - if err := prebuild.DebianHide.Init(); err != nil { - return res, err - } - // @{pci_bus} was upstreamed in 5.0, and backported to 4.1, and in Ubuntu 24.04 path := p.RootApparmor.Join("tunables/multiarch.d/system") out, err := path.ReadFileAsString() @@ -67,9 +62,6 @@ func (p Configure) Apply() ([]string, error) { } case "debian": - if err := prebuild.DebianHide.Init(); err != nil { - return res, err - } default: return []string{}, fmt.Errorf("%s is not a supported distribution", tasks.Distribution) diff --git a/pkg/configure/core.go b/pkg/configure/core.go index b5f9a6fe4..b8afe0405 100644 --- a/pkg/configure/core.go +++ b/pkg/configure/core.go @@ -11,6 +11,7 @@ import ( "fmt" "github.com/roddhjav/apparmor.d/pkg/logging" + "github.com/roddhjav/apparmor.d/pkg/paths" "github.com/roddhjav/apparmor.d/pkg/tasks" ) @@ -52,3 +53,15 @@ func (r *Configures) Add(task Task) *Configures { r.BaseRunner.Add(task) return r } + +// existingPaths returns the string form of every member of sources that +// exists, for task result messages. +func existingPaths(sources paths.PathList) []string { + var res []string + for _, src := range sources { + if src.Exist() { + res = append(res, src.String()) + } + } + return res +} diff --git a/pkg/configure/core_test.go b/pkg/configure/core_test.go index 9d2da0823..180e6ba43 100644 --- a/pkg/configure/core_test.go +++ b/pkg/configure/core_test.go @@ -31,6 +31,15 @@ func chdirGitRoot() { } func TestTask_Apply(t *testing.T) { + if err := os.MkdirAll("/tmp/tests", 0o755); err != nil { + t.Fatalf("mkdir /tmp/tests: %v", err) + } + t.Setenv("TMPDIR", "/tmp/tests") + userFlagDir := paths.New(t.TempDir()) + if err := userFlagDir.Join("user.conf").WriteFile([]byte("systemd complain\n")); err != nil { + t.Fatalf("write user flags: %v", err) + } + tests := []struct { name string task Task @@ -49,7 +58,7 @@ func TestTask_Apply(t *testing.T) { name: "ignore", task: NewIgnore(), wantErr: false, - want: "dists/ignore/main.ignore", + want: "dists/ignore.d/main.conf", }, { name: "merge", @@ -64,15 +73,9 @@ func TestTask_Apply(t *testing.T) { }, { name: "setflags", - task: NewSetFlags(), + task: NewSetFlags(paths.PathList{userFlagDir}), wantErr: false, - want: "dists/flags/main.flags", - }, - { - name: "overwrite", - task: NewOverwrite(false), - wantErr: false, - wantFiles: paths.PathList{cfg.RootApparmor.Join("flatpak.apparmor.d")}, + want: userFlagDir.String(), }, { name: "systemd-default", @@ -109,6 +112,51 @@ func TestTask_Apply(t *testing.T) { } } +// fakeTask is a minimal Task used to exercise the runner pipeline. +type fakeTask struct { + tasks.BaseTask + msgs []string + err error +} + +func (f *fakeTask) Apply() ([]string, error) { return f.msgs, f.err } + +func TestConfigures_Run(t *testing.T) { + tests := []struct { + name string + tasks []Task + wantErr bool + }{ + { + name: "all tasks succeed", + tasks: []Task{ + &fakeTask{BaseTask: tasks.BaseTask{Keyword: "one", Msg: "task one"}, msgs: []string{"detail"}}, + &fakeTask{BaseTask: tasks.BaseTask{Keyword: "two", Msg: "task two"}}, + }, + wantErr: false, + }, + { + name: "failing task aborts the pipeline", + tasks: []Task{ + &fakeTask{BaseTask: tasks.BaseTask{Keyword: "boom", Msg: "task boom"}, err: os.ErrPermission}, + }, + wantErr: true, + }, + } + c := tasks.NewTaskConfig(paths.New(".build")) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRunner(c) + for _, task := range tt.tasks { + r.Add(task) + } + if err := r.Run(); (err != nil) != tt.wantErr { + t.Errorf("Configures.Run() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + func TestConfigures_Add(t *testing.T) { tests := []struct { name string diff --git a/pkg/configure/flags.go b/pkg/configure/flags.go index d4199b625..daeb48070 100644 --- a/pkg/configure/flags.go +++ b/pkg/configure/flags.go @@ -6,59 +6,70 @@ package configure import ( "fmt" - "regexp" - "strings" - "github.com/roddhjav/apparmor.d/pkg/prebuild" + "github.com/roddhjav/apparmor.d/pkg/paths" "github.com/roddhjav/apparmor.d/pkg/tasks" -) - -var ( - regFlags = regexp.MustCompile(`flags=\(([^)]+)\)`) - regProfileHeader = regexp.MustCompile(` {\n`) + "github.com/roddhjav/apparmor.d/pkg/util" ) type SetFlags struct { tasks.BaseTask + sources paths.PathList } -// NewSetFlags creates a new SetFlags task. -func NewSetFlags() *SetFlags { +// NewSetFlags creates a SetFlags task that applies flags from the given +// sources, each a flags file or a directory of *.conf files, read in order +// so a later source overrides an earlier one. aa-install passes the +// flags.d config dirs; prebuild can pass the dists/flags.d files of the +// target distribution. +func NewSetFlags(sources paths.PathList) *SetFlags { return &SetFlags{ BaseTask: tasks.BaseTask{ Keyword: "setflags", - Msg: "Set flags as definied in dist/flags", + Msg: "Set flags as defined in:", }, + sources: sources, } } func (p SetFlags) Apply() ([]string, error) { - res := []string{} - for _, name := range []string{"main", tasks.Distribution} { - for profile, flags := range prebuild.Flags.Read(name) { - file := p.RootApparmor.Join(profile) - if !file.Exist() { + entries := util.ReadFlagDirs(p.sources...) + if len(entries) == 0 { + return nil, nil + } + res, err := p.applyFlags(entries) + if err != nil { + return res, err + } + return append(res, existingPaths(p.sources)...), nil +} + +func (p SetFlags) applyFlags(entries map[string][]string) ([]string, error) { + var res []string + for profile, flags := range entries { + file := p.RootApparmor.Join(profile) + if !file.Exist() { + if renamed := p.RootApparmor.Join(profile + "." + p.Pkgname); renamed.Exist() { + file = renamed + } else { res = append(res, fmt.Sprintf("Profile %s not found, ignoring", profile)) continue } - - // Overwrite profile flags - if len(flags) > 0 { - flagsStr := " flags=(" + strings.Join(flags, ",") + ") {\n" - out, err := file.ReadFileAsString() - if err != nil { - return res, err - } - - // Remove all flags definition, then set manifest' flags - out = regFlags.ReplaceAllLiteralString(out, "") - out = regProfileHeader.ReplaceAllLiteralString(out, flagsStr) - if err := file.WriteFile([]byte(out)); err != nil { - return res, err - } - } } - res = append(res, prebuild.FlagDir.Join(name+".flags").String()) + if len(flags) == 0 { + continue + } + out, err := file.ReadFileAsString() + if err != nil { + return res, err + } + out, err = util.ApplyFlags(out, flags) + if err != nil { + return res, err + } + if err := file.WriteFile([]byte(out)); err != nil { + return res, err + } } return res, nil } diff --git a/pkg/configure/flags_test.go b/pkg/configure/flags_test.go new file mode 100644 index 000000000..480af4946 --- /dev/null +++ b/pkg/configure/flags_test.go @@ -0,0 +1,342 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package configure + +import ( + "os" + "slices" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/tasks" +) + +func setupTmp(t *testing.T) { + t.Helper() + if err := os.MkdirAll("/tmp/tests", 0o755); err != nil { + t.Fatalf("mkdir /tmp/tests: %v", err) + } + t.Setenv("TMPDIR", "/tmp/tests") +} + +func newTaskConfigTmp(t *testing.T) *tasks.TaskConfig { + t.Helper() + c := tasks.NewTaskConfig(paths.New(t.TempDir())) + if err := c.RootApparmor.MkdirAll(); err != nil { + t.Fatalf("mkdir apparmor.d: %v", err) + } + return c +} + +// TestSetFlags_ApplyFileSources covers the prebuild use of the task: the +// sources are the dists/flags.d files of the target distribution, not +// directories, so other distributions' files must not be applied. +func TestSetFlags_ApplyFileSources(t *testing.T) { + tests := []struct { + name string + flagFiles map[string]string // flag filename -> content + sources []string // flag filenames passed to the task, in order + want string // expected profile content after apply + wantMissing string // source filename expected absent from the result + }{ + { + name: "only given files applied", + flagFiles: map[string]string{ + "main.conf": "foo complain\n", + "ubuntu.conf": "foo attach_disconnected\n", + }, + sources: []string{"main.conf", "arch.conf"}, + want: "profile foo /usr/bin/foo flags=(complain) {\n}\n", + }, + { + name: "later file overrides earlier", + flagFiles: map[string]string{ + "main.conf": "foo complain\n", + "arch.conf": "foo attach_disconnected\n", + }, + sources: []string{"main.conf", "arch.conf"}, + want: "profile foo /usr/bin/foo flags=(attach_disconnected) {\n}\n", + }, + } + + setupTmp(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newTaskConfigTmp(t) + profile := c.RootApparmor.Join("foo") + if err := profile.WriteFile([]byte("profile foo /usr/bin/foo {\n}\n")); err != nil { + t.Fatalf("write profile: %v", err) + } + flagDir := paths.New(t.TempDir()) + for name, content := range tt.flagFiles { + if err := flagDir.Join(name).WriteFile([]byte(content)); err != nil { + t.Fatalf("write flag file: %v", err) + } + } + sources := paths.PathList{} + for _, name := range tt.sources { + sources = append(sources, flagDir.Join(name)) + } + + task := NewSetFlags(sources) + task.SetConfig(c) + if _, err := task.Apply(); err != nil { + t.Fatalf("Apply() error = %v, wantErr false", err) + } + out, err := profile.ReadFileAsString() + if err != nil { + t.Fatalf("read profile: %v", err) + } + if out != tt.want { + t.Errorf("Apply() profile = %q, want %q", out, tt.want) + } + }) + } +} + +func TestSetFlags_Apply(t *testing.T) { + tests := []struct { + name string + vendorFiles map[string]string // vendor flag filename -> content + flagFiles map[string]string // user flag filename -> content + profiles map[string]string // profile basename -> content + noReadProfiles []string // profile basenames made unreadable + noWriteProfiles []string // profile basenames made read-only + wantErr bool + wantResult []string // substrings expected in result + wantContent map[string]string // profile basename -> expected content after apply + wantEmpty bool // true when Apply is expected to return no result + }{ + { + name: "empty dir returns empty", + flagFiles: map[string]string{}, + wantEmpty: true, + }, + { + name: "missing profile is reported not fatal", + flagFiles: map[string]string{ + "user.conf": "missing-profile complain\n", + }, + profiles: map[string]string{}, + wantResult: []string{"Profile missing-profile not found, ignoring"}, + }, + { + name: "overwritten profile matched by pkgname suffix", + flagFiles: map[string]string{ + "user.conf": "loupe complain\n", + }, + profiles: map[string]string{ + "loupe.apparmor.d": "profile loupe /usr/bin/loupe {\n}\n", + }, + wantContent: map[string]string{ + "loupe.apparmor.d": "profile loupe /usr/bin/loupe flags=(complain) {\n}\n", + }, + }, + { + name: "apply single flag", + flagFiles: map[string]string{ + "user.conf": "foo complain\n", + }, + profiles: map[string]string{ + "foo": "profile foo /usr/bin/foo {\n include \n}\n", + }, + wantContent: map[string]string{ + "foo": "profile foo /usr/bin/foo flags=(complain) {\n include \n}\n", + }, + }, + { + name: "apply multiple flags", + flagFiles: map[string]string{ + "user.conf": "bar attach_disconnected,complain\n", + }, + profiles: map[string]string{ + "bar": "profile bar /usr/bin/bar {\n}\n", + }, + wantContent: map[string]string{ + "bar": "profile bar /usr/bin/bar flags=(attach_disconnected,complain) {\n}\n", + }, + }, + { + name: "multiple flag files merge", + flagFiles: map[string]string{ + "a.conf": "foo complain\n", + "b.conf": "bar enforce\n", + }, + profiles: map[string]string{ + "foo": "profile foo /usr/bin/foo {\n}\n", + "bar": "profile bar /usr/bin/bar {\n}\n", + }, + wantContent: map[string]string{ + "foo": "profile foo /usr/bin/foo flags=(complain) {\n}\n", + // enforce is the default mode: no flags clause is emitted. + "bar": "profile bar /usr/bin/bar {\n}\n", + }, + }, + { + name: "mode entry preserves existing profile flags", + flagFiles: map[string]string{ + "core.conf": "accounts-daemon enforce\n", + }, + profiles: map[string]string{ + "accounts-daemon": "profile accounts-daemon @{exec_path} flags=(attach_disconnected) {\n}\n", + }, + wantContent: map[string]string{ + "accounts-daemon": "profile accounts-daemon @{exec_path} flags=(attach_disconnected) {\n}\n", + }, + }, + { + name: "later file overrides earlier alphabetically", + flagFiles: map[string]string{ + "10-base.conf": "foo complain\n", + "20-override.conf": "foo enforce\n", + }, + profiles: map[string]string{ + "foo": "profile foo /usr/bin/foo {\n}\n", + }, + // enforce wins (later file) and, as the default mode, clears the flags. + wantContent: map[string]string{ + "foo": "profile foo /usr/bin/foo {\n}\n", + }, + }, + { + name: "same name user file replaces vendor file", + vendorFiles: map[string]string{ + "user.conf": "foo complain\nbar complain\n", + }, + flagFiles: map[string]string{ + "user.conf": "foo attach_disconnected\n", + }, + profiles: map[string]string{ + "foo": "profile foo /usr/bin/foo {\n}\n", + "bar": "profile bar /usr/bin/bar {\n}\n", + }, + wantContent: map[string]string{ + "foo": "profile foo /usr/bin/foo flags=(attach_disconnected) {\n}\n", + "bar": "profile bar /usr/bin/bar {\n}\n", + }, + }, + { + name: "different names merge per profile", + vendorFiles: map[string]string{ + "00-main.conf": "foo complain\nbar complain\n", + }, + flagFiles: map[string]string{ + "10-user.conf": "foo attach_disconnected\n", + }, + profiles: map[string]string{ + "foo": "profile foo /usr/bin/foo {\n}\n", + "bar": "profile bar /usr/bin/bar {\n}\n", + }, + wantContent: map[string]string{ + "foo": "profile foo /usr/bin/foo flags=(attach_disconnected) {\n}\n", + "bar": "profile bar /usr/bin/bar flags=(complain) {\n}\n", + }, + }, + { + name: "non-conf file ignored", + flagFiles: map[string]string{ + "README.md": "not a flag file\n", + }, + wantEmpty: true, + }, + { + name: "empty flag list skipped", + flagFiles: map[string]string{ + "user.conf": "foo\n", + }, + profiles: map[string]string{ + "foo": "profile foo /usr/bin/foo {\n}\n", + }, + wantContent: map[string]string{ + "foo": "profile foo /usr/bin/foo {\n}\n", + }, + }, + { + name: "unreadable profile error", + flagFiles: map[string]string{ + "user.conf": "foo complain\n", + }, + profiles: map[string]string{ + "foo": "profile foo /usr/bin/foo {\n}\n", + }, + noReadProfiles: []string{"foo"}, + wantErr: true, + }, + { + name: "unwritable profile error", + flagFiles: map[string]string{ + "user.conf": "foo complain\n", + }, + profiles: map[string]string{ + "foo": "profile foo /usr/bin/foo {\n}\n", + }, + noWriteProfiles: []string{"foo"}, + wantErr: true, + }, + } + + setupTmp(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newTaskConfigTmp(t) + for name, content := range tt.profiles { + if err := c.RootApparmor.Join(name).WriteFile([]byte(content)); err != nil { + t.Fatalf("write profile: %v", err) + } + } + + vendorDir := paths.New(t.TempDir()) + for name, content := range tt.vendorFiles { + if err := vendorDir.Join(name).WriteFile([]byte(content)); err != nil { + t.Fatalf("write vendor flag file: %v", err) + } + } + userDir := paths.New(t.TempDir()) + for name, content := range tt.flagFiles { + if err := userDir.Join(name).WriteFile([]byte(content)); err != nil { + t.Fatalf("write flag file: %v", err) + } + } + for _, name := range tt.noReadProfiles { + chmodNoAccess(t, c.RootApparmor.Join(name)) + } + for _, name := range tt.noWriteProfiles { + chmodRO(t, c.RootApparmor.Join(name)) + } + + task := NewSetFlags(paths.PathList{vendorDir, userDir}) + task.SetConfig(c) + got, err := task.Apply() + if (err != nil) != tt.wantErr { + t.Errorf("Apply() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if tt.wantEmpty && len(got) != 0 { + t.Errorf("Apply() = %v, want empty", got) + } + if !tt.wantEmpty && !slices.Contains(got, userDir.String()) { + t.Errorf("Apply() = %v, want contains %q", got, userDir.String()) + } + for _, w := range tt.wantResult { + if !slices.Contains(got, w) { + t.Errorf("Apply() = %v, want contains %q", got, w) + } + } + for name, want := range tt.wantContent { + out, err := c.RootApparmor.Join(name).ReadFileAsString() + if err != nil { + t.Errorf("read profile %s: %v", name, err) + continue + } + if out != want { + t.Errorf("profile %s = %q, want %q", name, out, want) + } + } + }) + } +} diff --git a/pkg/configure/ignore.go b/pkg/configure/ignore.go index 4c21a6e92..b3b1204cd 100644 --- a/pkg/configure/ignore.go +++ b/pkg/configure/ignore.go @@ -8,13 +8,17 @@ import ( "github.com/roddhjav/apparmor.d/pkg/paths" "github.com/roddhjav/apparmor.d/pkg/prebuild" "github.com/roddhjav/apparmor.d/pkg/tasks" + "github.com/roddhjav/apparmor.d/pkg/util" ) type Ignore struct { tasks.BaseTask + userOnly bool + userDirs paths.PathList } -// NewIgnore creates a new Ignore task. +// NewIgnore creates an Ignore task that removes profiles listed in the +// distribution ignore files (dists/ignore.d/). Intended for prebuild. func NewIgnore() *Ignore { return &Ignore{ BaseTask: tasks.BaseTask{ @@ -24,28 +28,58 @@ func NewIgnore() *Ignore { } } +// NewUserIgnore creates an Ignore task that removes profiles listed in every +// *.conf file of dirs, read in order. Intended for the aa-install pipeline. +func NewUserIgnore(dirs paths.PathList) *Ignore { + return &Ignore{ + BaseTask: tasks.BaseTask{ + Keyword: "ignore", + Msg: "Ignore profiles and files from:", + }, + userOnly: true, + userDirs: dirs, + } +} + func (p Ignore) Apply() ([]string, error) { res := []string{} + if p.userOnly { + user := util.ReadConfDirs(p.userDirs...) + if len(user) == 0 { + return res, nil + } + if err := p.removeEntries(user); err != nil { + return res, err + } + return append(res, existingPaths(p.userDirs)...), nil + } for _, name := range []string{"main", tasks.Distribution} { - for _, ignore := range prebuild.Ignore.Read(name) { - profile := p.Root.Join(ignore) - if profile.NotExist() { - files, err := p.RootApparmor.ReadDirRecursiveFiltered(nil, paths.FilterNames(ignore)) - if err != nil { - return res, err - } - for _, path := range files { - if err := path.RemoveAll(); err != nil { - return res, err - } - } - } else { - if err := profile.RemoveAll(); err != nil { - return res, err + if err := p.removeEntries(prebuild.Ignore.Read(name)); err != nil { + return res, err + } + res = append(res, prebuild.IgnoreDir.Join(name+".conf").String()) + } + return res, nil +} + +func (p Ignore) removeEntries(entries []string) error { + for _, ignore := range entries { + profile := p.Root.Join(ignore) + if profile.NotExist() { + files, err := p.RootApparmor.ReadDirRecursiveFiltered(nil, paths.FilterNames(ignore)) + if err != nil { + return err + } + for _, path := range files { + if err := path.RemoveAll(); err != nil { + return err } } + } else { + if err := profile.RemoveAll(); err != nil { + return err + } } - res = append(res, prebuild.IgnoreDir.Join(name+".ignore").String()) } - return res, nil + return nil } diff --git a/pkg/configure/ignore_test.go b/pkg/configure/ignore_test.go new file mode 100644 index 000000000..134d3872f --- /dev/null +++ b/pkg/configure/ignore_test.go @@ -0,0 +1,215 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package configure + +import ( + "slices" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/prebuild" + "github.com/roddhjav/apparmor.d/pkg/tasks" +) + +// setIgnoreDir points prebuild.IgnoreDir to a temporary directory seeded +// with main.conf, restoring the original on cleanup. +func setIgnoreDir(t *testing.T, mainContent string) { + t.Helper() + dir := paths.New(t.TempDir()) + if mainContent != "" { + if err := dir.Join("main.conf").WriteFile([]byte(mainContent)); err != nil { + t.Fatalf("write main.conf: %v", err) + } + } + original := prebuild.IgnoreDir + prebuild.IgnoreDir = dir + t.Cleanup(func() { prebuild.IgnoreDir = original }) +} + +// userIgnoreDir returns a temporary directory seeded with a user.conf +// drop-in holding content. +func userIgnoreDir(t *testing.T, name string, content string) *paths.Path { + t.Helper() + dir := paths.New(t.TempDir()) + if content != "" { + if err := dir.Join(name).WriteFile([]byte(content)); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + return dir +} + +func TestIgnore_Apply(t *testing.T) { + tests := []struct { + name string + userOnly bool // apply NewUserIgnore instead of NewIgnore + profiles map[string]string // apparmor.d relative file -> content + mainIgnore string // content of dists ignore.d main.conf + vendorIgnore string // content of vendor drop-in 00-main.conf + userIgnore string // content of the user drop-in file + userIgnoreName string // basename of the user drop-in, default 10-user.conf + missingRoot bool // build root does not exist + roDirs []string // apparmor.d relative dirs made read-only + wantErr bool + wantEmpty bool // Apply is expected to return no result + wantKept []string // apparmor.d relative paths expected to remain + wantRemoved []string // apparmor.d relative paths expected to be removed + }{ + { + name: "no user entries is noop", + userOnly: true, + profiles: map[string]string{ + "foo": "profile foo {\n}\n", + }, + wantKept: []string{"foo"}, + wantEmpty: true, + }, + { + name: "remove root entries and recursive names", + profiles: map[string]string{ + "foo": "profile foo {\n}\n", + "groups/g1/bar": "profile bar {\n}\n", + "groups/g1/baz": "profile baz {\n}\n", + }, + mainIgnore: "apparmor.d/foo\nbar\n", + wantKept: []string{"groups/g1/baz"}, + wantRemoved: []string{"foo", "groups/g1/bar"}, + }, + { + name: "user ignore entries removed", + userOnly: true, + profiles: map[string]string{ + "foo": "profile foo {\n}\n", + "bar": "profile bar {\n}\n", + }, + userIgnore: "apparmor.d/foo\n", + wantKept: []string{"bar"}, + wantRemoved: []string{"foo"}, + }, + { + name: "vendor and user ignore entries combined", + userOnly: true, + profiles: map[string]string{ + "foo": "profile foo {\n}\n", + "bar": "profile bar {\n}\n", + "baz": "profile baz {\n}\n", + }, + vendorIgnore: "apparmor.d/foo\n", + userIgnore: "apparmor.d/bar\n", + wantKept: []string{"baz"}, + wantRemoved: []string{"foo", "bar"}, + }, + { + name: "same name user file replaces vendor file", + userOnly: true, + profiles: map[string]string{ + "foo": "profile foo {\n}\n", + "bar": "profile bar {\n}\n", + }, + vendorIgnore: "apparmor.d/foo\n", + userIgnore: "apparmor.d/bar\n", + userIgnoreName: "00-main.conf", + wantKept: []string{"foo"}, + wantRemoved: []string{"bar"}, + }, + { + name: "recursive read error", + mainIgnore: "does-not-exist\n", + missingRoot: true, + wantErr: true, + }, + { + name: "user entries error", + userOnly: true, + userIgnore: "does-not-exist\n", + missingRoot: true, + wantErr: true, + }, + { + name: "remove root entry error", + profiles: map[string]string{ + "foo": "profile foo {\n}\n", + }, + mainIgnore: "apparmor.d/foo\n", + roDirs: []string{"."}, + wantErr: true, + }, + { + name: "remove recursive entry error", + profiles: map[string]string{ + "groups/bar": "profile bar {\n}\n", + }, + mainIgnore: "bar\n", + roDirs: []string{"groups"}, + wantErr: true, + }, + } + + setupTmp(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var c *tasks.TaskConfig + if tt.missingRoot { + c = tasks.NewTaskConfig(paths.New(t.TempDir()).Join("missing")) + } else { + c = newTaskConfigTmp(t) + } + seedFiles(t, c.RootApparmor, tt.profiles) + setIgnoreDir(t, tt.mainIgnore) + userName := tt.userIgnoreName + if userName == "" { + userName = "10-user.conf" + } + vendorDir := userIgnoreDir(t, "00-main.conf", tt.vendorIgnore) + userDir := userIgnoreDir(t, userName, tt.userIgnore) + for _, dir := range tt.roDirs { + if dir == "." { + chmodRO(t, c.RootApparmor) + } else { + chmodRO(t, c.RootApparmor.Join(dir)) + } + } + + var task *Ignore + if tt.userOnly { + task = NewUserIgnore(paths.PathList{vendorDir, userDir}) + } else { + task = NewIgnore() + } + task.SetConfig(c) + got, err := task.Apply() + if (err != nil) != tt.wantErr { + t.Errorf("Apply() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if tt.wantEmpty { + if len(got) != 0 { + t.Errorf("Apply() = %v, want empty", got) + } + } else { + want := prebuild.IgnoreDir.Join("main.conf").String() + if tt.userOnly { + want = userDir.String() + } + if !slices.Contains(got, want) { + t.Errorf("Apply() = %v, want %v", got, want) + } + } + for _, rel := range tt.wantKept { + if c.RootApparmor.Join(rel).NotExist() { + t.Errorf("expected %s kept, was removed", rel) + } + } + for _, rel := range tt.wantRemoved { + if c.RootApparmor.Join(rel).Exist() { + t.Errorf("expected %s removed, still exists", rel) + } + } + }) + } +} diff --git a/pkg/configure/include.go b/pkg/configure/include.go new file mode 100644 index 000000000..32f202e34 --- /dev/null +++ b/pkg/configure/include.go @@ -0,0 +1,134 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package configure + +import ( + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/tasks" + "github.com/roddhjav/apparmor.d/pkg/util" +) + +type Include struct { + tasks.BaseTask + userDirs paths.PathList + src *paths.Path // restore mode: source of the re-applied profiles +} + +// NewInclude creates an Include task that only keeps the profiles listed in +// the *.conf files of dirs, plus the alwaysKeep ones (modes: include full). +func NewInclude(dirs paths.PathList) *Include { + return &Include{ + BaseTask: tasks.BaseTask{ + Keyword: "include", + Msg: "Only keep profiles and groups listed in manifest include files", + }, + userDirs: dirs, + } +} + +// NewRestoreInclude creates an Include task that re-applies the listed +// profiles from src after the ignore task (modes: include default). +func NewRestoreInclude(dirs paths.PathList, src *paths.Path) *Include { + return &Include{ + BaseTask: tasks.BaseTask{ + Keyword: "include", + Msg: "Re-apply ignored profiles listed in manifest include files", + }, + userDirs: dirs, + src: src, + } +} + +// Active reports whether any user include rules are defined. When true, +// this task takes over the selection role otherwise performed by Install. +func (p Include) Active() bool { + return len(util.ReadConfDirs(p.userDirs...)) > 0 +} + +func (p Include) Apply() ([]string, error) { + entries := util.ReadConfDirs(p.userDirs...) + if len(entries) == 0 { + return []string{}, nil + } + if p.src != nil { + return p.restore(entries) + } + return p.only(entries) +} + +// only removes every profile not listed in entries. +func (p Include) only(entries []string) ([]string, error) { + res := []string{} + keep := map[string]bool{} + var keepDirs []*paths.Path + for _, entry := range entries { + dir := p.RootApparmor.Join(entry) + if dir.IsDir() { + keepDirs = append(keepDirs, dir) + continue + } + keep[entry] = true + } + + files, err := p.RootApparmor.ReadDirRecursiveFiltered(skipSystemDirs, paths.FilterOutDirectories()) + if err != nil { + return res, err + } + for _, file := range files { + if keep[file.Base()] || isAlwaysKept(file.Base()) { + continue + } + if file.IsInsideAnyDir(keepDirs) { + continue + } + if err := file.Remove(); err != nil { + return res, err + } + } + + res = append(res, existingPaths(p.userDirs)...) + return res, nil +} + +// restore copies the listed entries back from the source directory. +func (p Include) restore(entries []string) ([]string, error) { + res := []string{} + for _, entry := range entries { + var files paths.PathList + var err error + if src := p.src.Join(entry); src.IsDir() { + files, err = src.ReadDirRecursiveFiltered(nil, paths.FilterOutDirectories()) + } else if src.Exist() { + files = paths.PathList{src} + } else { + files, err = p.src.ReadDirRecursiveFiltered(skipSystemDirs, paths.FilterNames(entry)) + } + if err != nil { + return res, err + } + for _, file := range files { + if err := p.restoreFile(file); err != nil { + return res, err + } + } + } + return append(res, existingPaths(p.userDirs)...), nil +} + +// restoreFile copies a source file to its build location when missing. +func (p Include) restoreFile(file *paths.Path) error { + rel, err := file.RelFrom(p.src) + if err != nil { + return err + } + dst := p.RootApparmor.JoinPath(rel) + if dst.Exist() { + return nil + } + if err := dst.Parent().MkdirAll(); err != nil { + return err + } + return file.CopyTo(dst) +} diff --git a/pkg/configure/include_test.go b/pkg/configure/include_test.go new file mode 100644 index 000000000..873ad9744 --- /dev/null +++ b/pkg/configure/include_test.go @@ -0,0 +1,262 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package configure + +import ( + "slices" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/tasks" +) + +func TestInclude_Active(t *testing.T) { + tests := []struct { + name string + entries map[string]string // include filename -> content + want bool + }{ + { + name: "no user dir entries", + entries: map[string]string{}, + want: false, + }, + { + name: "one entry", + entries: map[string]string{ + "a.conf": "foo\n", + }, + want: true, + }, + { + name: "non-include file ignored", + entries: map[string]string{ + "README.md": "foo\n", + }, + want: false, + }, + } + + setupTmp(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + userDir := paths.New(t.TempDir()) + for name, content := range tt.entries { + if err := userDir.Join(name).WriteFile([]byte(content)); err != nil { + t.Fatalf("write include file: %v", err) + } + } + if got := NewInclude(paths.PathList{userDir}).Active(); got != tt.want { + t.Errorf("Active() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestInclude_Apply(t *testing.T) { + tests := []struct { + name string + vendorFiles map[string]string // vendor include filename -> content + includeFiles map[string]string // user include filename -> content + profiles []string // apparmor.d/ files to seed + srcFiles []string // source files to seed (restore mode) + missingRoot bool // build root does not exist + roDirs []string // apparmor.d relative dirs made read-only + wantErr bool + wantKept []string // apparmor.d-relative paths expected to remain + wantRemoved []string // apparmor.d-relative paths expected to be removed + wantEmpty bool + }{ + { + name: "no entries is noop", + includeFiles: map[string]string{}, + profiles: []string{"foo", "bar"}, + wantKept: []string{"foo", "bar"}, + wantEmpty: true, + }, + { + name: "keep only listed profile", + includeFiles: map[string]string{ + "user.conf": "foo\n", + }, + profiles: []string{"foo", "bar", "baz"}, + wantKept: []string{"foo"}, + wantRemoved: []string{"bar", "baz"}, + }, + { + name: "vendor and user entries combined", + vendorFiles: map[string]string{ + "vendor.conf": "foo\n", + }, + includeFiles: map[string]string{ + "user.conf": "bar\n", + }, + profiles: []string{"foo", "bar", "baz"}, + wantKept: []string{"foo", "bar"}, + wantRemoved: []string{"baz"}, + }, + { + name: "keep files under listed directory", + includeFiles: map[string]string{ + "user.conf": "groups\n", + }, + profiles: []string{ + "groups/a/x", "groups/a/y", "unrelated", + }, + wantKept: []string{"groups/a/x", "groups/a/y"}, + wantRemoved: []string{"unrelated"}, + }, + { + name: "protect reserved directories", + includeFiles: map[string]string{ + "user.conf": "foo\n", + }, + profiles: []string{ + "foo", "bar", + "abstractions/base", + "tunables/global", + "disable/something", + "mappings/foo", + }, + wantKept: []string{ + "foo", + "abstractions/base", + "tunables/global", + "disable/something", + "mappings/foo", + }, + wantRemoved: []string{"bar"}, + }, + { + name: "always kept profiles survive", + includeFiles: map[string]string{ + "user.conf": "foo\n", + }, + profiles: []string{"foo", "bar", "child-open", "dbus-session", "namespaces/makepkg/gpg"}, + wantKept: []string{"foo", "child-open", "dbus-session", "namespaces/makepkg/gpg"}, + wantRemoved: []string{"bar"}, + }, + { + name: "restore ignored profile by name", + includeFiles: map[string]string{ + "user.conf": "foo\n", + }, + srcFiles: []string{"groups/a/foo", "bar"}, + profiles: []string{"bar"}, + wantKept: []string{"groups/a/foo", "bar"}, + }, + { + name: "restore ignored directory", + includeFiles: map[string]string{ + "user.conf": "groups/a\n", + }, + srcFiles: []string{"groups/a/x", "groups/a/y", "bar"}, + profiles: []string{}, + wantKept: []string{"groups/a/x", "groups/a/y"}, + }, + { + name: "restore keeps existing profiles untouched", + includeFiles: map[string]string{ + "user.conf": "foo\n", + }, + srcFiles: []string{"foo"}, + profiles: []string{"foo", "bar"}, + wantKept: []string{"foo", "bar"}, + }, + { + name: "read dir error", + includeFiles: map[string]string{ + "user.conf": "foo\n", + }, + missingRoot: true, + wantErr: true, + }, + { + name: "remove profile error", + includeFiles: map[string]string{ + "user.conf": "foo\n", + }, + profiles: []string{"sub/bar"}, + roDirs: []string{"sub"}, + wantErr: true, + }, + } + + setupTmp(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var c *tasks.TaskConfig + if tt.missingRoot { + c = tasks.NewTaskConfig(paths.New(t.TempDir()).Join("missing")) + } else { + c = newTaskConfigTmp(t) + } + for _, rel := range tt.profiles { + f := c.RootApparmor.Join(rel) + if err := f.Parent().MkdirAll(); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := f.WriteFile([]byte("profile\n")); err != nil { + t.Fatalf("write profile: %v", err) + } + } + vendorDir := paths.New(t.TempDir()) + for name, content := range tt.vendorFiles { + if err := vendorDir.Join(name).WriteFile([]byte(content)); err != nil { + t.Fatalf("write vendor include file: %v", err) + } + } + userDir := paths.New(t.TempDir()) + for name, content := range tt.includeFiles { + if err := userDir.Join(name).WriteFile([]byte(content)); err != nil { + t.Fatalf("write include file: %v", err) + } + } + for _, dir := range tt.roDirs { + chmodRO(t, c.RootApparmor.Join(dir)) + } + + task := NewInclude(paths.PathList{vendorDir, userDir}) + if tt.srcFiles != nil { + src := paths.New(t.TempDir()) + for _, rel := range tt.srcFiles { + f := src.Join(rel) + if err := f.Parent().MkdirAll(); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := f.WriteFile([]byte("profile\n")); err != nil { + t.Fatalf("write source profile: %v", err) + } + } + task = NewRestoreInclude(paths.PathList{vendorDir, userDir}, src) + } + task.SetConfig(c) + got, err := task.Apply() + if (err != nil) != tt.wantErr { + t.Errorf("Apply() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if tt.wantEmpty && len(got) != 0 { + t.Errorf("Apply() = %v, want empty", got) + } + if !tt.wantEmpty && !slices.Contains(got, userDir.String()) { + t.Errorf("Apply() = %v, want contains %q", got, userDir.String()) + } + for _, rel := range tt.wantKept { + if c.RootApparmor.Join(rel).NotExist() { + t.Errorf("expected %s kept, was removed", rel) + } + } + for _, rel := range tt.wantRemoved { + if c.RootApparmor.Join(rel).Exist() { + t.Errorf("expected %s removed, still exists", rel) + } + } + }) + } +} diff --git a/pkg/configure/installed.go b/pkg/configure/installed.go new file mode 100644 index 000000000..16ae392b7 --- /dev/null +++ b/pkg/configure/installed.go @@ -0,0 +1,466 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package configure + +import ( + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + + "github.com/roddhjav/apparmor.d/pkg/aa" + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/tasks" +) + +var ( + // skipSystemDirs filters out the top-level directories that are not + // per-program profiles (shared building blocks, compiled out, mapping data). + skipSystemDirs = paths.FilterOutNames("tunables", "abstractions", "disable", "mappings", "namespaces") + + // alwaysKeep lists profile-name globs installed even when they have no + // attachment and their group is not otherwise installed. + alwaysKeep = []string{ + "child-*", // groups/children + "glycin", // groups/children + "dbus-*", // groups/bus core buses + ":*", // namespaces/* (merged as ::) + } + + // ignoredSuffixes lists file extensions that are not executable programs + // to confine. + ignoredSuffixes = []string{ + ".so", + ".zst", + ".py", + ".pc", + ".service", + ".target", + ".socket", + ".mount", + ".timer", + } +) + +// isAlwaysKept reports whether a profile basename matches alwaysKeep. +func isAlwaysKept(base string) bool { + return slices.ContainsFunc(alwaysKeep, func(pattern string) bool { + ok, _ := filepath.Match(pattern, base) + return ok + }) +} + +func hasIgnoredSuffix(name string) bool { + return slices.ContainsFunc(ignoredSuffixes, func(s string) bool { + return strings.HasSuffix(name, s) + }) +} + +// sbinGlob returns the filesystem-glob form of `@{sbin}` for the current +// distribution. On Arch, /usr/sbin is a symlink to /usr/bin, so both +// /sbin and /bin must match. +func sbinGlob() string { + if tasks.Family == "pacman" { + return "/{,usr/}{,s}bin" + } + return "/{,usr/}sbin" +} + +type SelectInstalled struct { + tasks.BaseTask + include map[string]bool // profiles or groups kept even when not installed +} + +// NewSelectInstalled creates a SelectInstalled task. The include entries +// (profile or group names) are kept even when their program is not installed. +func NewSelectInstalled(include ...string) *SelectInstalled { + set := make(map[string]bool, len(include)) + for _, entry := range include { + set[filepath.Base(entry)] = true + } + return &SelectInstalled{ + BaseTask: tasks.BaseTask{ + Keyword: "installed", + Msg: "Only keep profiles for installed programs", + }, + include: set, + } +} + +// profileState holds the install decision inputs gathered in the first pass. +type profileState struct { + file *paths.Path + hasAtt bool // profile has an attachment (i.e. is an attachable program) + installed bool // an attachment resolves to an installed program + force bool // keep unconditionally (read/parse error: fail-safe) +} + +func (p SelectInstalled) Apply() ([]string, error) { + var res []string + + files, err := p.RootApparmor.ReadDirRecursiveFiltered( + skipSystemDirs, paths.FilterOutDirectories(), + ) + if err != nil { + return res, err + } + + // First pass: classify every profile and record which groups have at + // least one installed attachable profile. + states := make([]profileState, 0, len(files)) + installedGroups := map[string]bool{} + for _, file := range files { + st := profileState{file: file} + // On parse/read errors, keep the profile (fail-safe: don't silently drop). + profile, err := file.ReadFileAsString() + if err != nil { + st.force = true + log.Printf("Warning: failed to read profile %s: %v", file, err) + states = append(states, st) + continue + } + att, err := getAttachments(profile) + if err != nil { + st.force = true + states = append(states, st) + continue + } + st.hasAtt = len(att) > 0 + st.installed = st.hasAtt && isInstalled(att) + if st.installed { + if group := p.Groups[file.Base()]; group != "" { + installedGroups[group] = true + } + } + states = append(states, st) + } + + // Second pass: keep installed programs, keep attachment-less profiles that + // are standalone or whose group is installed, drop the rest. + var keptNames, removedNames []string + for _, st := range states { + base := st.file.Base() + keep := st.force || st.installed || isAlwaysKept(base) || + p.include[base] || p.include[p.Groups[base]] + if !keep && !st.hasAtt { + group := p.Groups[st.file.Base()] + keep = group == "" || installedGroups[group] + } + if !keep { + if err := st.file.Remove(); err != nil { + return res, err + } + removedNames = append(removedNames, st.file.Base()) + continue + } + keptNames = append(keptNames, st.file.Base()) + } + + res = append(res, + fmt.Sprintf("Kept %d profiles", len(keptNames)), + fmt.Sprintf("Ignored %d", len(removedNames)), + ) + return res, nil +} + +// getAttachments returns the resolved attachment paths from a +// post-prebuild profile header (e.g., "profile dolphin /{,usr/}bin/dolphin"). +// Returns nil for child profiles and abstractions with no attachment. +func getAttachments(profile string) (att []string, err error) { + defer func() { + if r := recover(); r != nil { + att = nil + err = fmt.Errorf("parse panic: %v", r) + } + }() + + f := aa.DefaultTunables() + if _, err := f.Parse(profile); err != nil { + return nil, err + } + return f.GetDefaultProfile().Attachments, nil +} + +var regVariableRef = regexp.MustCompile(`@\{[^{}]+\}`) + +// pathReplacer is the distro-aware AppArmor variable → filesystem glob +// substitution table. Order in strings.NewReplacer matters: more +// specific keys (e.g. @{user_share_dirs}) must precede generic ones +// (@{HOME}) so the specific match wins. +var pathReplacer = sync.OnceValue(func() *strings.Replacer { + return strings.NewReplacer( + "@{user_share_dirs}", "/home/*/.local/share", + "@{user_config_dirs}", "/home/*/.config", + "@{user_cache_dirs}", "/home/*/.cache", + "@{user_state_dirs}", "/home/*/.local/state", + "@{user_bin_dirs}", "/home/*/.local/bin", + "@{user_lib_dirs}", "/home/*/.local/lib", + "@{HOME}", "/home/*", + "@{lib}", "/{,usr/}lib{,exec,32,64}", + "@{sbin}", sbinGlob(), + "@{bin}", "/{,usr/}bin", + "@{etc_ro}", "/{,usr/}etc", + ) +}) + +// symlinkCache caches os.Lstat results for directory components to +// avoid repeated syscalls. A path that traverses any symlink directory +// is redundant (e.g., /bin/foo when /bin → /usr/bin) and can be skipped. +// +// Not safe for concurrent use: intentional for the single-pass +// prebuild CLI. If Apply ever parallelises profiles, guard this. +var symlinkCache = map[string]bool{} + +// readDirCache memoises os.ReadDir results per directory. The host +// filesystem is static during install-detection, so a directory read once +// need never be read again across the ~1500 profiles. A nil slice caches a +// read error (missing/inaccessible dir). +// +// Not safe for concurrent use: same single-pass assumption as symlinkCache. +var readDirCache = map[string][]os.DirEntry{} + +func cachedReadDir(dir string) []os.DirEntry { + if entries, ok := readDirCache[dir]; ok { + return entries + } + entries, err := os.ReadDir(dir) + if err != nil { + entries = nil + } + readDirCache[dir] = entries + return entries +} + +func hasSymlinkComponent(p string) bool { + for i := 1; i < len(p); i++ { + if p[i] != '/' { + continue + } + dir := p[:i] + isLink, cached := symlinkCache[dir] + if !cached { + info, err := os.Lstat(dir) + isLink = err == nil && info.Mode()&os.ModeSymlink != 0 + symlinkCache[dir] = isLink + } + if isLink { + return true + } + } + return false +} + +// pathIsInstalled returns whether a single resolved path indicates an +// installed program. Unresolvable variables are conservatively treated +// as installed; wildcard remnants and symlink-traversing paths are +// skipped (the non-symlinked duplicate will be stat'd instead). +func pathIsInstalled(p string) bool { + if !strings.HasPrefix(p, "/") { + return true + } + if strings.ContainsAny(p, "*?[{") { + return false + } + if hasSymlinkComponent(p) { + return false + } + _, err := os.Stat(p) + return err == nil +} + +// isInstalled reports whether any of a profile's attachments resolves to +// an existing filesystem path. +func isInstalled(attachments []string) bool { + return slices.ContainsFunc(attachments, attachmentInstalled) +} + +// attachmentInstalled reports whether a single AppArmor attachment matches +// an existing file. Known variables are substituted and unknown ones +// become wildcards; wildcard patterns (*, ?, [class], {alt}, **) are +// matched against the filesystem, plain paths are stat'd. +func attachmentInstalled(att string) bool { + att = pathReplacer().Replace(att) + att = regVariableRef.ReplaceAllString(att, "*") + if strings.ContainsAny(att, "*?[{") { + return globExists(att) + } + return pathIsInstalled(att) +} + +// globExists reports whether any real file matches the AppArmor glob +// pattern. Structural brace alternations that span a path separator (e.g. +// {,usr/}) are expanded into concrete branches; intra-segment braces and +// character classes (e.g. the @{version} pattern [0-9]{[0-9],}...) are kept +// and matched as a regexp per path segment, so patterns like @{user} or a +// version glob never blow up combinatorially. +func globExists(pattern string) bool { + return slices.ContainsFunc(expandPathBraces(pattern), func(p string) bool { + return matchDescend("/", strings.Split(strings.TrimPrefix(p, "/"), "/")) + }) +} + +// matchDescend walks a pattern segment by segment from dir, only reading +// the directories the pattern actually names. "**" matches any depth. +// +// ponytail: a non-terminal "**" (e.g. /a/**/b) recurses every subdir of a; +// in practice "**" is always terminal here, so no depth cap. Add one if a +// mid-pattern "**" over a huge tree ever shows up. +func matchDescend(dir string, segs []string) bool { + for len(segs) > 0 && segs[0] == "" { + segs = segs[1:] + } + if len(segs) == 0 { + _, err := os.Lstat(dir) + return err == nil + } + seg, rest := segs[0], segs[1:] + + if seg == "**" { + if matchDescend(dir, rest) { + return true + } + for _, e := range cachedReadDir(dir) { + if e.IsDir() && matchDescend(filepath.Join(dir, e.Name()), segs) { + return true + } + } + return false + } + + if !strings.ContainsAny(seg, "*?[{") { + return matchDescend(filepath.Join(dir, seg), rest) + } + + re, err := segRegexp(seg) + if err != nil { + return true // unparseable glob: keep the profile (fail-safe) + } + for _, e := range cachedReadDir(dir) { + if re.MatchString(e.Name()) && matchDescend(filepath.Join(dir, e.Name()), rest) { + return true + } + } + return false +} + +// segRegexp compiles a single AppArmor path segment (no '/') into an +// anchored regexp: * → any run of non-slash, ? → one char, {a,b} → (a|b), +// and [class] kept verbatim. +func segRegexp(seg string) (*regexp.Regexp, error) { + var b strings.Builder + b.WriteByte('^') + depth := 0 + for i := 0; i < len(seg); i++ { + c := seg[i] + switch c { + case '[': + b.WriteByte('[') + for i++; i < len(seg) && seg[i] != ']'; i++ { + b.WriteByte(seg[i]) + } + b.WriteByte(']') + case '*': + b.WriteString("[^/]*") + case '?': + b.WriteString("[^/]") + case '{': + depth++ + b.WriteByte('(') + case '}': + if depth > 0 { + depth-- + b.WriteByte(')') + } else { + b.WriteString(`\}`) + } + case ',': + if depth > 0 { + b.WriteByte('|') + } else { + b.WriteByte(',') + } + case '.', '+', '(', ')', '|', '^', '$', '\\': + b.WriteByte('\\') + b.WriteByte(c) + default: + b.WriteByte(c) + } + } + b.WriteByte('$') + return regexp.Compile(b.String()) +} + +// expandPathBraces expands only brace groups that span a path separator +// (structural alternations like {,usr/} or {/bin/x,/opt/y}), leaving +// intra-segment braces verbatim for segRegexp to handle. +func expandPathBraces(pattern string) []string { + searchFrom := 0 + for { + rel := strings.IndexByte(pattern[searchFrom:], '{') + if rel == -1 { + return []string{pattern} + } + start := searchFrom + rel + end := matchingBrace(pattern, start) + if end == -1 { + return []string{pattern} + } + content := pattern[start+1 : end] + if !strings.Contains(content, "/") { + searchFrom = end + 1 // intra-segment brace: leave for segRegexp + continue + } + prefix, suffix := pattern[:start], pattern[end+1:] + var out []string + for _, alt := range splitAtCommas(content) { + out = append(out, expandPathBraces(prefix+alt+suffix)...) + } + return out + } +} + +// matchingBrace returns the index of the '}' closing the '{' at start, or +// -1 if unbalanced. +func matchingBrace(s string, start int) int { + depth := 0 + for i := start; i < len(s); i++ { + switch s[i] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +// splitAtCommas splits a string at top-level commas, respecting nested braces. +func splitAtCommas(s string) []string { + var parts []string + depth := 0 + start := 0 + for i := 0; i < len(s); i++ { + switch s[i] { + case '{': + depth++ + case '}': + depth-- + case ',': + if depth == 0 { + parts = append(parts, s[start:i]) + start = i + 1 + } + } + } + parts = append(parts, s[start:]) + return parts +} diff --git a/pkg/configure/installed_test.go b/pkg/configure/installed_test.go new file mode 100644 index 000000000..685d1e3ba --- /dev/null +++ b/pkg/configure/installed_test.go @@ -0,0 +1,515 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package configure + +import ( + "os" + "slices" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/tasks" +) + +func TestIsInstalled(t *testing.T) { + tests := []struct { + name string + attachments []string + want bool + }{ + { + name: "existing binary", + attachments: []string{"@{bin}/ls"}, + want: true, + }, + { + name: "nonexistent binary", + attachments: []string{"@{bin}/this-program-does-not-exist-12345"}, + want: false, + }, + { + name: "unresolved variable kept", + attachments: []string{"@{brave_path}"}, + want: true, + }, + { + name: "absolute existing path", + attachments: []string{"/usr/bin/ls"}, + want: true, + }, + { + name: "post-prebuild resolved", + attachments: []string{"/{,usr/}bin/ls"}, + want: true, + }, + { + name: "post-prebuild not installed", + attachments: []string{"/{,usr/}bin/this-program-does-not-exist-12345"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isInstalled(tt.attachments) + if got != tt.want { + t.Errorf("isInstalled(%v) = %v, want %v", tt.attachments, got, tt.want) + } + }) + } +} + +func TestGetAttachments(t *testing.T) { + tests := []struct { + name string + profile string + want []string + wantErr bool + }{ + { + name: "post-prebuild resolved", + profile: ` +abi , + +profile dolphin /{,usr/}bin/dolphin { + include +}`, + want: []string{"/{,usr/}bin/dolphin"}, + }, + { + name: "post-prebuild multiple", + profile: ` +abi , + +profile claude {/{,usr/}bin/claude,/opt/claude-code/bin/claude} { + include +}`, + want: []string{"{/{,usr/}bin/claude,/opt/claude-code/bin/claude}"}, + }, + { + name: "child profile without exec_path", + profile: ` +abi , + +profile child-pager { + include +}`, + want: nil, + }, + { + name: "parse error", + profile: "profile broken {\n", + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getAttachments(tt.profile) + if (err != nil) != tt.wantErr { + t.Errorf("getAttachments() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("getAttachments() = %v, want %v", got, tt.want) + return + } + for i, w := range tt.want { + if i < len(got) && got[i] != w { + t.Errorf("getAttachments()[%d] = %q, want %q", i, got[i], w) + } + } + }) + } +} + +func TestSbinGlob(t *testing.T) { + tests := []struct { + name string + family string + want string + }{ + { + name: "pacman merges sbin and bin", + family: "pacman", + want: "/{,usr/}{,s}bin", + }, + { + name: "other families keep sbin only", + family: "apt", + want: "/{,usr/}sbin", + }, + } + + original := tasks.Family + t.Cleanup(func() { tasks.Family = original }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tasks.Family = tt.family + if got := sbinGlob(); got != tt.want { + t.Errorf("sbinGlob() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHasIgnoredSuffix(t *testing.T) { + tests := []struct { + name string + filename string + want bool + }{ + { + name: "shared library", + filename: "libfoo.so", + want: true, + }, + { + name: "systemd unit", + filename: "foo.service", + want: true, + }, + { + name: "python module", + filename: "module.py", + want: true, + }, + { + name: "versioned library not matched", + filename: "libfoo.so.1", + want: false, + }, + { + name: "regular program", + filename: "bash", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasIgnoredSuffix(tt.filename); got != tt.want { + t.Errorf("hasIgnoredSuffix(%q) = %v, want %v", tt.filename, got, tt.want) + } + }) + } +} + +func TestExpandPathBraces(t *testing.T) { + tests := []struct { + name string + pattern string + want []string + }{ + { + name: "structural brace expanded", + pattern: "/{,usr/}bin/foo", + want: []string{"/bin/foo", "/usr/bin/foo"}, + }, + { + name: "intra-segment brace kept verbatim", + pattern: "/usr/lib{,exec,32,64}/foo", + want: []string{"/usr/lib{,exec,32,64}/foo"}, + }, + { + name: "version pattern kept verbatim", + pattern: "/usr/lib/pg/[0-9]{[0-9],}/bin/pg_ctl", + want: []string{"/usr/lib/pg/[0-9]{[0-9],}/bin/pg_ctl"}, + }, + { + name: "nested structural alternation", + pattern: "/{{,usr/}bin/x,opt/y}", + want: []string{"/bin/x", "/usr/bin/x", "/opt/y"}, + }, + { + name: "unbalanced brace returned as-is", + pattern: "/usr/{bin/foo", + want: []string{"/usr/{bin/foo"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := expandPathBraces(tt.pattern) + if len(got) != len(tt.want) { + t.Errorf("expandPathBraces(%q) = %v, want %v", tt.pattern, got, tt.want) + return + } + for _, w := range tt.want { + if !slices.Contains(got, w) { + t.Errorf("expandPathBraces(%q) missing %q, got %v", tt.pattern, w, got) + } + } + }) + } +} + +func TestGlobExists(t *testing.T) { + setupTmp(t) + base := paths.New(t.TempDir()) + // Mimic /usr/lib/postgresql/17/bin/pg_ctl and /etc/update-motd.d/10-foo. + for _, rel := range []string{"lib/postgresql/17/bin", "motd.d"} { + if err := base.Join(rel).MkdirAll(); err != nil { + t.Fatalf("mkdir: %v", err) + } + } + for _, rel := range []string{"lib/postgresql/17/bin/pg_ctl", "motd.d/10-foo"} { + if err := base.Join(rel).WriteFile([]byte("x")); err != nil { + t.Fatalf("write: %v", err) + } + } + root := base.String() + + tests := []struct { + name string + pattern string + want bool + }{ + { + name: "trailing wildcard segment matches", + pattern: root + "/motd.d/*", + want: true, + }, + { + name: "wildcard segment no match", + pattern: root + "/nope.d/*", + want: false, + }, + { + name: "version char-class matches two digits", + pattern: root + "/lib{,exec}/postgresql/[0-9]{[0-9],}{[0-9],}/bin/pg_ctl", + want: true, + }, + { + name: "version char-class no match", + pattern: root + "/lib/postgresql/[0-9]/bin/pg_ctl", + want: false, // single [0-9] can't match "17" + }, + { + name: "doublestar matches when parent exists", + pattern: root + "/lib/postgresql/**", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := globExists(tt.pattern); got != tt.want { + t.Errorf("globExists(%q) = %v, want %v", tt.pattern, got, tt.want) + } + }) + } +} + +func TestPathIsInstalled(t *testing.T) { + setupTmp(t) + base := paths.New(t.TempDir()) + if err := base.Join("real").MkdirAll(); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := base.Join("real/prog").WriteFile([]byte("#!/bin/sh\n")); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.Symlink(base.Join("real").String(), base.Join("link").String()); err != nil { + t.Fatalf("symlink: %v", err) + } + + tests := []struct { + name string + path string + want bool + }{ + { + name: "unresolved variable kept", + path: "not-absolute", + want: true, + }, + { + name: "wildcard remnant skipped", + path: "/usr/bin/*", + want: false, + }, + { + name: "existing path", + path: base.Join("real/prog").String(), + want: true, + }, + { + name: "missing path", + path: base.Join("real/absent").String(), + want: false, + }, + { + name: "symlink component skipped", + path: base.Join("link/prog").String(), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := pathIsInstalled(tt.path); got != tt.want { + t.Errorf("pathIsInstalled(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} + +func TestSelectInstalled_Apply(t *testing.T) { + tests := []struct { + name string + profiles map[string]string // apparmor.d relative file -> content + groups map[string]string // profile basename -> group (as Merge records) + include []string // include entries passed to NewSelectInstalled + noReadProfiles []string // apparmor.d relative files made unreadable + roDirs []string // apparmor.d relative dirs made read-only + missingRoot bool // build root does not exist + wantErr bool + wantResult []string // entries expected in the result + wantKept []string // apparmor.d relative files expected to remain + wantRemoved []string // apparmor.d relative files expected to be removed + }{ + { + name: "keep installed remove missing", + profiles: map[string]string{ + "groups/misc/foo": "profile foo @{bin}/true {\n}\n", + "groups/misc/bar": "profile bar /usr/bin/no-such-program-zz {\n}\n", + "child": "profile child {\n}\n", + "garbage": "profile broken {\n", + "secret": "profile secret /usr/bin/no-such-program-zz {\n}\n", + }, + noReadProfiles: []string{"secret"}, + wantResult: []string{"Kept 4 profiles", "Ignored 1"}, + wantKept: []string{"groups/misc/foo", "child", "garbage", "secret"}, + wantRemoved: []string{"groups/misc/bar"}, + }, + { + name: "keep group helper when group installed", + profiles: map[string]string{ + "code": "profile code @{bin}/true {\n}\n", + "code-shells": "profile code-shells flags=(attach_disconnected) {\n}\n", + }, + groups: map[string]string{"code": "code", "code-shells": "code"}, + wantKept: []string{"code", "code-shells"}, + wantRemoved: []string{}, + }, + { + name: "drop group helper when group not installed", + profiles: map[string]string{ + "steam": "profile steam /usr/bin/no-such-program-zz {\n}\n", + "steam-runtime": "profile steam-runtime flags=(attach_disconnected) {\n}\n", + }, + groups: map[string]string{"steam": "steam", "steam-runtime": "steam"}, + wantRemoved: []string{"steam", "steam-runtime"}, + }, + { + name: "always-keep profiles survive uninstalled group", + profiles: map[string]string{ + "steam": "profile steam /usr/bin/no-such-program-zz {\n}\n", + "steam-runtime": "profile steam-runtime flags=(attach_disconnected) {\n}\n", + "child-pager": "profile child-pager flags=(attach_disconnected) {\n}\n", + "dbus-session": "profile dbus-session flags=(attach_disconnected) {\n}\n", + }, + groups: map[string]string{ + "steam": "steam", + "steam-runtime": "steam", + "child-pager": "children", + "dbus-session": "bus", + }, + wantKept: []string{"child-pager", "dbus-session"}, + wantRemoved: []string{"steam", "steam-runtime"}, + }, + { + name: "included profiles kept despite uninstalled attachment", + profiles: map[string]string{ + "steam": "profile steam /usr/bin/no-such-program-zz {\n}\n", + "steam-runtime": "profile steam-runtime flags=(attach_disconnected) {\n}\n", + "other": "profile other /usr/bin/no-such-program-zz {\n}\n", + }, + groups: map[string]string{"steam": "steam", "steam-runtime": "steam"}, + include: []string{"steam"}, + wantKept: []string{"steam", "steam-runtime"}, + wantRemoved: []string{"other"}, + }, + { + name: "namespaced profile kept despite uninstalled attachment", + profiles: map[string]string{ + ":podman:podman": "profile :podman:podman /usr/bin/no-such-program-zz {\n}\n", + }, + wantKept: []string{":podman:podman"}, + }, + { + name: "overwriting profile kept when installed", + profiles: map[string]string{ + "systemd-detect-virt.apparmor.d": "profile systemd-detect-virt @{bin}/true {\n}\n", + "absent.apparmor.d": "profile absent /usr/bin/no-such-program-zz {\n}\n", + }, + wantKept: []string{"systemd-detect-virt.apparmor.d"}, + wantRemoved: []string{"absent.apparmor.d"}, + }, + { + name: "read profiles error", + missingRoot: true, + wantErr: true, + }, + { + name: "remove profile error", + profiles: map[string]string{ + "sub/bar": "profile bar /usr/bin/no-such-program-zz {\n}\n", + }, + roDirs: []string{"sub"}, + wantErr: true, + }, + } + + setupTmp(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var c *tasks.TaskConfig + if tt.missingRoot { + c = tasks.NewTaskConfig(paths.New(t.TempDir()).Join("missing")) + } else { + c = newTaskConfigTmp(t) + } + seedFiles(t, c.RootApparmor, tt.profiles) + if tt.groups != nil { + c.Groups = tt.groups + } + for _, rel := range tt.noReadProfiles { + chmodNoAccess(t, c.RootApparmor.Join(rel)) + } + for _, rel := range tt.roDirs { + chmodRO(t, c.RootApparmor.Join(rel)) + } + + task := NewSelectInstalled(tt.include...) + task.SetConfig(c) + got, err := task.Apply() + if (err != nil) != tt.wantErr { + t.Errorf("Apply() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + for _, want := range tt.wantResult { + if !slices.Contains(got, want) { + t.Errorf("Apply() = %v, want %v", got, want) + } + } + for _, rel := range tt.wantKept { + if c.RootApparmor.Join(rel).NotExist() { + t.Errorf("Apply() kept %s = %v, want %v", rel, "removed", "exists") + } + } + for _, rel := range tt.wantRemoved { + if c.RootApparmor.Join(rel).Exist() { + t.Errorf("Apply() removed %s = %v, want %v", rel, "exists", "removed") + } + } + }) + } +} diff --git a/pkg/configure/merge.go b/pkg/configure/merge.go index a8d189e2e..d61e4f443 100644 --- a/pkg/configure/merge.go +++ b/pkg/configure/merge.go @@ -41,6 +41,11 @@ func (p Merge) Apply() ([]string, error) { return res, err } for _, file := range files { + // Record group membership before the groups/ tree is flattened: + // groups// -> Groups[] = . + if dirMoved == "groups/*/*" { + p.Groups[filepath.Base(file)] = filepath.Base(filepath.Dir(file)) + } err := os.Rename(file, p.RootApparmor.Join(filepath.Base(file)).String()) if err != nil { return res, err diff --git a/pkg/configure/merge_test.go b/pkg/configure/merge_test.go new file mode 100644 index 000000000..4bf4c7ca8 --- /dev/null +++ b/pkg/configure/merge_test.go @@ -0,0 +1,139 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package configure + +import ( + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/tasks" +) + +func TestMerge_Apply(t *testing.T) { + tests := []struct { + name string + dirs []string // apparmor.d relative directories to create + files map[string]string // apparmor.d relative file -> content + roDirs []string // apparmor.d relative dirs made read-only + noReadDirs []string // apparmor.d relative dirs made unreadable + badRoot bool // build root name is an invalid glob pattern + wantErr bool + wantFiles []string // apparmor.d relative paths expected to exist + wantNoFiles []string // apparmor.d relative paths expected to be gone + }{ + { + name: "glob error", + badRoot: true, + wantErr: true, + }, + { + name: "merge groups profiles and namespaces", + files: map[string]string{ + "groups/g1/prof1": "profile prof1 {\n}\n", + "profiles-a-f/prof2": "profile prof2 {\n}\n", + "namespaces/ns1/prof3": "profile prof3 {\n}\n", + "namespaces/ns2/prof4": "profile prof4 {\n}\n", + "groups/g2/local/prof5": "included\n", + }, + wantFiles: []string{ + "prof1", "prof2", ":ns1:prof3", ":ns2:prof4", "local/prof5", + }, + wantNoFiles: []string{"groups", "profiles-a-f", "namespaces"}, + }, + { + name: "no namespaces directory", + files: map[string]string{ + "groups/g1/prof1": "profile prof1 {\n}\n", + }, + wantFiles: []string{"prof1"}, + wantNoFiles: []string{"groups"}, + }, + { + name: "rename conflict error", + dirs: []string{"prof1"}, + files: map[string]string{ + "groups/g1/prof1": "profile prof1 {\n}\n", + }, + wantErr: true, + }, + { + name: "remove groups error", + files: map[string]string{ + "groups/readme": "not a group dir\n", + }, + roDirs: []string{"groups"}, + wantErr: true, + }, + { + name: "namespaces not a directory", + files: map[string]string{ + "namespaces": "not a dir\n", + }, + wantErr: true, + }, + { + name: "namespace read error", + dirs: []string{"namespaces/ns1"}, + noReadDirs: []string{"namespaces/ns1"}, + wantErr: true, + }, + { + name: "namespace rename conflict error", + dirs: []string{":ns1:prof3"}, + files: map[string]string{ + "namespaces/ns1/prof3": "profile prof3 {\n}\n", + }, + wantErr: true, + }, + { + name: "remove namespaces error", + dirs: []string{"namespaces/ns1"}, + roDirs: []string{"namespaces"}, + wantErr: true, + }, + } + + setupTmp(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var c *tasks.TaskConfig + if tt.badRoot { + c = tasks.NewTaskConfig(paths.New(t.TempDir()).Join("bad[")) + } else { + c = newTaskConfigTmp(t) + } + for _, dir := range tt.dirs { + if err := c.RootApparmor.Join(dir).MkdirAll(); err != nil { + t.Fatalf("mkdir: %v", err) + } + } + seedFiles(t, c.RootApparmor, tt.files) + for _, dir := range tt.roDirs { + chmodRO(t, c.RootApparmor.Join(dir)) + } + for _, dir := range tt.noReadDirs { + chmodNoAccess(t, c.RootApparmor.Join(dir)) + } + + task := NewMerge() + task.SetConfig(c) + _, err := task.Apply() + if (err != nil) != tt.wantErr { + t.Errorf("Apply() error = %v, wantErr %v", err, tt.wantErr) + return + } + for _, rel := range tt.wantFiles { + if c.RootApparmor.Join(rel).NotExist() { + t.Errorf("Apply() = %v, want %v", rel, "exist") + } + } + for _, rel := range tt.wantNoFiles { + if c.RootApparmor.Join(rel).Exist() { + t.Errorf("Apply() = %v, want %v", rel, "removed") + } + } + }) + } +} diff --git a/pkg/configure/overwrite.go b/pkg/configure/overwrite.go index f60b8e1d3..efeb50c50 100644 --- a/pkg/configure/overwrite.go +++ b/pkg/configure/overwrite.go @@ -5,64 +5,51 @@ package configure import ( - "fmt" "os" - "github.com/roddhjav/apparmor.d/pkg/prebuild" + "github.com/roddhjav/apparmor.d/pkg/aa" "github.com/roddhjav/apparmor.d/pkg/tasks" ) +// Overwrite replaces upstream profiles by our own at install time. For each +// listed profile present on the install target, the upstream profile is +// disabled with a disable/ link and our profile (if any) is renamed +// .. Profiles the target does not ship are left untouched. type Overwrite struct { tasks.BaseTask - Optional bool + entries []string } -// NewOverwrite creates a new Overwrite task with optional configuration. -func NewOverwrite(optional bool) *Overwrite { +// NewOverwrite creates an Overwrite task from the overwrite.d config entries. +func NewOverwrite(entries []string) *Overwrite { return &Overwrite{ BaseTask: tasks.BaseTask{ Keyword: "overwrite", Msg: "Overwrite dummy upstream profiles", }, - Optional: optional, + entries: entries, } } func (p Overwrite) Apply() ([]string, error) { res := []string{} - if p.ABI == 3 { - return res, nil - } - disableDir := p.RootApparmor.Join("disable") - if err := disableDir.Mkdir(); err != nil { - return res, err - } - - ext := "." + p.Pkgname - path := prebuild.DistDir.Join("overwrite") - if !path.Exist() { - return res, fmt.Errorf("%s not found", path) - } - for _, name := range path.MustReadFilteredFileAsLines() { - origin := p.RootApparmor.Join(name) - dest := p.RootApparmor.Join(name + ext) - if !dest.Exist() && p.Optional { + for _, name := range p.entries { + if !aa.MagicRoot.Join(name).Exist() { continue } - if origin.Exist() { - if err := origin.Rename(dest); err != nil { + if origin := p.RootApparmor.Join(name); origin.Exist() { + if err := origin.Rename(p.RootApparmor.Join(name + "." + p.Pkgname)); err != nil { return res, err } } - originRel, err := origin.RelFrom(dest) - if err != nil { + if err := disableDir.MkdirAll(); err != nil { return res, err } - if err := os.Symlink(originRel.String(), disableDir.Join(name).String()); err != nil { + if err := os.Symlink("../"+name, disableDir.Join(name).String()); err != nil { return res, err } + res = append(res, name) } - return res, nil } diff --git a/pkg/configure/overwrite_test.go b/pkg/configure/overwrite_test.go new file mode 100644 index 000000000..e649061e7 --- /dev/null +++ b/pkg/configure/overwrite_test.go @@ -0,0 +1,111 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package configure + +import ( + "testing" + + "github.com/roddhjav/apparmor.d/pkg/aa" + "github.com/roddhjav/apparmor.d/pkg/paths" +) + +func TestOverwrite_Apply(t *testing.T) { + tests := []struct { + name string + files map[string]string // root relative file -> content + entries []string // overwrite.d config entries + upstream []string // upstream profiles present on the install target + roRoot bool + want []string + wantErr bool + wantFiles []string + wantLinks []string // present as symlinks + wantNoFiles []string + }{ + { + name: "upstream present renames profile and creates link", + files: map[string]string{"hostname": "profile hostname {}\n"}, + entries: []string{"hostname"}, + upstream: []string{"hostname"}, + want: []string{"hostname"}, + wantFiles: []string{"hostname.apparmor.d"}, + wantLinks: []string{"disable/hostname"}, + wantNoFiles: []string{"hostname"}, + }, + { + name: "upstream absent keeps profile name and skips link", + files: map[string]string{"hostname": "profile hostname {}\n"}, + entries: []string{"hostname"}, + wantFiles: []string{"hostname"}, + wantNoFiles: []string{"hostname.apparmor.d", "disable/hostname"}, + }, + { + name: "upstream without our profile only gets the link", + files: map[string]string{"other": "profile other {}\n"}, + entries: []string{"usr.bin.foo"}, + upstream: []string{"usr.bin.foo"}, + want: []string{"usr.bin.foo"}, + wantFiles: []string{"other"}, + wantLinks: []string{"disable/usr.bin.foo"}, + }, + { + name: "no entries is noop", + files: map[string]string{"hostname": "profile hostname {}\n"}, + wantFiles: []string{"hostname"}, + wantNoFiles: []string{"disable"}, + }, + { + name: "rename error", + files: map[string]string{"hostname": "profile hostname {}\n"}, + entries: []string{"hostname"}, + upstream: []string{"hostname"}, + roRoot: true, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newTaskConfigTmp(t) + seedFiles(t, c.RootApparmor, tt.files) + target := paths.New(t.TempDir()) + for _, name := range tt.upstream { + if err := target.Join(name).WriteFile([]byte("profile x {\n}\n")); err != nil { + t.Fatalf("seed target: %v", err) + } + } + oldRoot := aa.MagicRoot + aa.MagicRoot = target + t.Cleanup(func() { aa.MagicRoot = oldRoot }) + if tt.roRoot { + chmodRO(t, c.RootApparmor) + } + + task := NewOverwrite(tt.entries) + task.SetConfig(c) + got, err := task.Apply() + if (err != nil) != tt.wantErr { + t.Fatalf("Apply() error = %v, wantErr %v", err, tt.wantErr) + } + if len(got) != len(tt.want) { + t.Errorf("Apply() = %v, want %v", got, tt.want) + } + for _, rel := range tt.wantFiles { + if c.RootApparmor.Join(rel).NotExist() { + t.Errorf("Apply() = %v, want %v", rel, "exist") + } + } + for _, rel := range tt.wantLinks { + if isLink, err := c.RootApparmor.Join(rel).IsSymlink(); err != nil || !isLink { + t.Errorf("Apply() IsSymlink(%v) = %v, %v, want true", rel, isLink, err) + } + } + for _, rel := range tt.wantNoFiles { + if c.RootApparmor.Join(rel).Exist() { + t.Errorf("Apply() = %v, want %v", rel, "removed") + } + } + }) + } +} diff --git a/pkg/configure/state.go b/pkg/configure/state.go new file mode 100644 index 000000000..ebd948384 --- /dev/null +++ b/pkg/configure/state.go @@ -0,0 +1,56 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package configure + +import ( + "fmt" + + "github.com/roddhjav/apparmor.d/pkg/state" + "github.com/roddhjav/apparmor.d/pkg/state/detector" + "github.com/roddhjav/apparmor.d/pkg/tasks" +) + +const ( + // stateRel is the state tunable, relative to relative to aa.Magic. + stateRel = "tunables/multiarch.d/state" + + // stateDropinRel is the generated detected state drop-in, relative to aa.Magic. + stateDropinRel = "tunables/multiarch.d/state.d/aa-install" +) + +type SetState struct { + tasks.BaseTask +} + +// NewSetState creates a new SetState task. +func NewSetState() *SetState { + return &SetState{ + BaseTask: tasks.BaseTask{ + Keyword: "state", + Msg: "Set detected system state in " + stateDropinRel, + }, + } +} + +func (p SetState) Apply() ([]string, error) { + res := []string{} + src := p.RootApparmor.Join(stateRel) + if !src.Exist() { + return res, nil + } + sys := detector.NewSystem(p.RootApparmor) + if !state.Enabled(sys) { + return res, nil + } + file, err := state.Detect(sys, src, p.RootApparmor.Join(stateDropinRel)) + if err != nil { + return res, err + } + for _, name := range file.Names() { + value, _ := file.Get(name) + res = append(res, fmt.Sprintf("%s = %s", name, value)) + } + return res, file.Save() +} diff --git a/pkg/configure/state_test.go b/pkg/configure/state_test.go new file mode 100644 index 000000000..e5a5ba885 --- /dev/null +++ b/pkg/configure/state_test.go @@ -0,0 +1,119 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package configure + +import ( + "errors" + "strings" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/state/detector" +) + +const stateTunable = `# System states + +# AppArmor version +@{VERSION} = 5 + +include if exists +` + +func TestSetState_Apply(t *testing.T) { + tests := []struct { + name string + hasState bool + abi string // abi version of abstractions/base-strict, "" means abi 5.0 + parserOut string + wantEmpty bool // Apply returns no result lines and no dropin + wantContains string // substring of the written dropin + wantNotContains string // substring the dropin must not have + wantErr bool + }{ + { + name: "writes detected values to the dropin", + hasState: true, + parserOut: "AppArmor parser version 4.0.2\n", + wantContains: "@{VERSION} = 4", + }, + { + name: "missing state file is a no-op", + hasState: false, + wantEmpty: true, + }, + { + name: "abi 4 tree is a no-op", + hasState: true, + abi: "4.0", + parserOut: "AppArmor parser version 4.0.2\n", + wantEmpty: true, + }, + { + name: "failed detection skips the variable", + hasState: true, + parserOut: "garbage", + wantNotContains: "@{VERSION}", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newTaskConfigTmp(t) + statePath := c.RootApparmor.Join(stateRel) + if tt.hasState { + abi := tt.abi + if abi == "" { + abi = "5.0" + } + seedFiles(t, c.RootApparmor, map[string]string{ + stateRel: stateTunable, + "abstractions/base-strict": "abi ,\n", + }) + } + oldRoot, oldRun := detector.Root, detector.Run + detector.Root = paths.New(t.TempDir()) + detector.Run = func(name string, arg ...string) (string, error) { + if name == "apparmor_parser" { + return tt.parserOut, nil + } + return "", errors.New("stubbed") + } + t.Cleanup(func() { detector.Root, detector.Run = oldRoot, oldRun }) + + task := NewSetState() + task.SetConfig(c) + got, err := task.Apply() + if (err != nil) != tt.wantErr { + t.Fatalf("Apply() error = %v, wantErr %v", err, tt.wantErr) + } + dropin := c.RootApparmor.Join(stateDropinRel) + if tt.wantEmpty { + if len(got) != 0 { + t.Errorf("Apply() = %v, want empty", got) + } + if dropin.Exist() { + t.Errorf("Apply() dropin = %v, want %v", "created", "absent") + } + return + } + content, err := dropin.ReadFileAsString() + if err != nil { + t.Fatalf("read dropin: %v", err) + } + if tt.wantContains != "" && !strings.Contains(content, tt.wantContains) { + t.Errorf("dropin = %q, want contains %q", content, tt.wantContains) + } + if tt.wantNotContains != "" && strings.Contains(content, tt.wantNotContains) { + t.Errorf("dropin = %q, want without %q", content, tt.wantNotContains) + } + state, err := statePath.ReadFileAsString() + if err != nil { + t.Fatalf("read state: %v", err) + } + if state != stateTunable { + t.Errorf("state file = %q, want untouched", state) + } + }) + } +} diff --git a/pkg/configure/synchronise_test.go b/pkg/configure/synchronise_test.go new file mode 100644 index 000000000..5d0d64fcd --- /dev/null +++ b/pkg/configure/synchronise_test.go @@ -0,0 +1,174 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2021-2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package configure + +import ( + "os" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/tasks" +) + +// chmodTemp temporarily changes the permission of a path, restoring a +// sane default on test cleanup. Skips the test when running as root as +// permissions are then not enforced. +func chmodTemp(t *testing.T, p *paths.Path, mode os.FileMode) { + t.Helper() + if os.Geteuid() == 0 { + t.Skip("running as root, permission based error paths cannot be tested") + } + if err := p.Chmod(mode); err != nil { + t.Fatalf("chmod %s: %v", p, err) + } + t.Cleanup(func() { _ = p.Chmod(0o755) }) +} + +// chmodRO makes a path read-only so that write operations inside it fail. +func chmodRO(t *testing.T, p *paths.Path) { + t.Helper() + chmodTemp(t, p, 0o555) +} + +// chmodNoAccess removes all permissions from a path. +func chmodNoAccess(t *testing.T, p *paths.Path) { + t.Helper() + chmodTemp(t, p, 0o000) +} + +// seedFiles writes the given root-relative files, creating parent +// directories as needed. +func seedFiles(t *testing.T, root *paths.Path, files map[string]string) { + t.Helper() + for rel, content := range files { + f := root.Join(rel) + if err := f.Parent().MkdirAll(); err != nil { + t.Fatalf("mkdir %s: %v", f.Parent(), err) + } + if err := f.WriteFile([]byte(content)); err != nil { + t.Fatalf("write %s: %v", f, err) + } + } +} + +func TestSynchronise_Apply(t *testing.T) { + tests := []struct { + name string + srcDirs []string // src root relative directories to create + srcFiles map[string]string // src root relative file -> content + sources []string // src root relative paths given to the task + rootFiles map[string]string // build root relative file -> content (pre-existing) + roRoot bool // make the build root read-only + missingRoot bool // build root under a read-only parent + wantErr bool + wantContent map[string]string // build root relative file -> expected content + }{ + { + name: "copy directory source", + srcFiles: map[string]string{"apparmor.d/groups/foo": "profile foo {\n}\n"}, + sources: []string{"apparmor.d"}, + wantContent: map[string]string{"apparmor.d/groups/foo": "profile foo {\n}\n"}, + }, + { + name: "copy multiple directory sources", + srcFiles: map[string]string{ + "apparmor.d/groups/foo": "profile foo {\n}\n", + "share/apparmor.d/bar": "profile bar {\n}\n", + }, + sources: []string{"apparmor.d", "share"}, + wantContent: map[string]string{ + "apparmor.d/groups/foo": "profile foo {\n}\n", + "share/apparmor.d/bar": "profile bar {\n}\n", + }, + }, + { + name: "copy file source over existing destination", + srcFiles: map[string]string{"single.conf": "new\n"}, + sources: []string{"single.conf"}, + rootFiles: map[string]string{"single.conf": "old\n"}, + wantContent: map[string]string{"single.conf": "new\n"}, + }, + { + name: "remove destination error", + srcFiles: map[string]string{"single.conf": "new\n"}, + sources: []string{"single.conf"}, + rootFiles: map[string]string{"single.conf": "old\n"}, + roRoot: true, + wantErr: true, + }, + { + name: "copy directory error", + srcDirs: []string{"data"}, + sources: []string{"data"}, + roRoot: true, + wantErr: true, + }, + { + name: "mkdir parent error", + srcFiles: map[string]string{"single.conf": "new\n"}, + sources: []string{"single.conf"}, + missingRoot: true, + wantErr: true, + }, + { + name: "copy file error", + srcFiles: map[string]string{"single.conf": "new\n"}, + sources: []string{"single.conf"}, + roRoot: true, + wantErr: true, + }, + } + + setupTmp(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var c *tasks.TaskConfig + if tt.missingRoot { + parent := paths.New(t.TempDir()) + chmodRO(t, parent) + c = tasks.NewTaskConfig(parent.Join("build")) + } else { + c = newTaskConfigTmp(t) + } + seedFiles(t, c.Root, tt.rootFiles) + + srcRoot := paths.New(t.TempDir()) + for _, dir := range tt.srcDirs { + if err := srcRoot.Join(dir).MkdirAll(); err != nil { + t.Fatalf("mkdir: %v", err) + } + } + seedFiles(t, srcRoot, tt.srcFiles) + if tt.roRoot { + chmodRO(t, c.Root) + } + + sources := make([]*paths.Path, 0, len(tt.sources)) + for _, src := range tt.sources { + sources = append(sources, srcRoot.Join(src)) + } + task := NewSynchronise(sources) + task.SetConfig(c) + got, err := task.Apply() + if (err != nil) != tt.wantErr { + t.Errorf("Apply() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && len(got) != len(tt.sources) { + t.Errorf("Apply() = %v, want %v entries", got, len(tt.sources)) + } + for rel, want := range tt.wantContent { + out, err := c.Root.Join(rel).ReadFileAsString() + if err != nil { + t.Errorf("read %s: %v", rel, err) + continue + } + if out != want { + t.Errorf("Apply() %s = %q, want %q", rel, out, want) + } + } + }) + } +} diff --git a/pkg/prebuild/cli/cli.go b/pkg/prebuild/cli/cli.go index c57e831cc..7f02a39c9 100644 --- a/pkg/prebuild/cli/cli.go +++ b/pkg/prebuild/cli/cli.go @@ -40,6 +40,7 @@ Options: -f, --fsp Configure AppArmor for full system policy and RBAC. -S, --src DIR Profile source directory (default: apparmor.d/). -b, --buildir DIR Destination root build directory (default: .build/). + --future Enable the future shipping layout. --test Enable test mode. --debug Enable debug mode. ` @@ -52,6 +53,7 @@ var ( status bool fsp bool debug bool + future bool test bool abi int version float64 @@ -70,6 +72,7 @@ func init() { flag.BoolVar(&status, "status", false, "Show the status of enabled build tasks.") flag.BoolVar(&fsp, "f", false, "Configure AppArmor for full system policy and RBAC.") flag.BoolVar(&fsp, "fsp", false, "Configure AppArmor for full system policy and RBAC.") + flag.BoolVar(&future, "future", false, "Enable the future shipping layout.") flag.IntVar(&abi, "a", nilABI, "Target apparmor ABI.") flag.IntVar(&abi, "abi", nilABI, "Target apparmor ABI.") flag.Float64Var(&version, "v", nilVer, "Target apparmor version.") @@ -99,6 +102,13 @@ func GetPrebuildRoot() *paths.Path { return paths.New(".build") } +// Future reports whether the `--future` flag is set. When true, the build +// uses the new shipping layout (e.g. profiles are not merged into a flat +// apparmor.d/ directory). +func Future() bool { + return future +} + func Configure(r *runtime.Runners) *runtime.Runners { // Register all directives (always available) r.Directives. diff --git a/pkg/prebuild/directories.go b/pkg/prebuild/directories.go index d6cca0d3e..7dc31a8c8 100644 --- a/pkg/prebuild/directories.go +++ b/pkg/prebuild/directories.go @@ -13,21 +13,17 @@ var ( // DistDir is the directory where the distribution specific files are stored DistDir *paths.Path = paths.New("dists") - // FlagDir is the directory where the flags are stored - FlagDir *paths.Path = DistDir.Join("flags") + // FlagDir is the directory where the distribution flags are stored. It + // mirrors the user flags.d structure: .conf drop-in files. + FlagDir *paths.Path = DistDir.Join("flags.d") - // IgnoreDir is the directory where the ignore files are stored - IgnoreDir *paths.Path = DistDir.Join("ignore") + // IgnoreDir is the directory where the distribution ignore files are + // stored. It mirrors the user ignore.d structure: .conf drop-ins. + IgnoreDir *paths.Path = DistDir.Join("ignore.d") // SystemdDir is the directory where the systemd drop-in files are stored SystemdDir *paths.Path = paths.New("systemd") - // DebianDir is the directory where the debian specific files are stored - DebianDir *paths.Path = paths.New("debian") - - // DebianHide is the path to the debian/common.hide file - DebianHide = DebianHider{path: DebianDir.Join("common.hide")} - Ignore = Ignorer{} Flags = Flagger{} ) diff --git a/pkg/prebuild/files.go b/pkg/prebuild/files.go index af37ba637..7793e47ad 100644 --- a/pkg/prebuild/files.go +++ b/pkg/prebuild/files.go @@ -5,61 +5,21 @@ package prebuild import ( - "strings" - - "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/util" ) -// Hide is the default content of debian/apparmor.d.hide. -var Hide = `# This file is generated by "just", all edit will be lost. - -/etc/apparmor.d/hostname -/etc/apparmor.d/usr.bin.firefox -/etc/apparmor.d/usr.bin.swtpm -/etc/apparmor.d/usr.bin.wsdd -/etc/apparmor.d/usr.libexec.geoclue -/etc/apparmor.d/usr.sbin.cups-browsed -/etc/apparmor.d/usr.sbin.cupsd -/etc/apparmor.d/usr.sbin.rsyslogd -` - type Flagger struct{} func (f Flagger) Read(name string) map[string][]string { - res := map[string][]string{} - path := FlagDir.Join(name + ".flags") - if !path.Exist() { - return res - } - - lines := path.MustReadFilteredFileAsLines() - for _, line := range lines { - manifest := strings.Split(line, " ") - profile := manifest[0] - flags := []string{} - if len(manifest) > 1 { - flags = strings.Split(manifest[1], ",") - } - res[profile] = flags - } - return res + return util.ReadFlagsFile(FlagDir.Join(name + ".conf")) } type Ignorer struct{} func (i Ignorer) Read(name string) []string { - path := IgnoreDir.Join(name + ".ignore") + path := IgnoreDir.Join(name + ".conf") if !path.Exist() { return []string{} } return path.MustReadFilteredFileAsLines() } - -type DebianHider struct { - path *paths.Path -} - -// Init initializes the file with content from Hide -func (d DebianHider) Init() error { - return d.path.WriteFile([]byte(Hide)) -} diff --git a/pkg/prebuild/files_test.go b/pkg/prebuild/files_test.go index 24d621fd6..d8a1b875e 100644 --- a/pkg/prebuild/files_test.go +++ b/pkg/prebuild/files_test.go @@ -48,7 +48,7 @@ gnome-disks complain FlagDir = paths.New("/tmp/") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := FlagDir.Join(tt.name + ".flags").WriteFile([]byte(tt.content)) + err := FlagDir.Join(tt.name + ".conf").WriteFile([]byte(tt.content)) if err != nil { return } @@ -62,7 +62,7 @@ gnome-disks complain func TestIgnore_Read(t *testing.T) { tests := []struct { name string - content string + content string // "" means the file is not created want []string }{ { @@ -72,6 +72,10 @@ func TestIgnore_Read(t *testing.T) { `, want: []string{}, }, + { + name: "missing", + want: []string{}, + }, { name: "main", content: ` @@ -92,9 +96,11 @@ code IgnoreDir = paths.New("/tmp/") for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := IgnoreDir.Join(tt.name + ".ignore").WriteFile([]byte(tt.content)) - if err != nil { - return + if tt.content != "" { + err := IgnoreDir.Join(tt.name + ".conf").WriteFile([]byte(tt.content)) + if err != nil { + return + } } if got := Ignore.Read(tt.name); !reflect.DeepEqual(got, tt.want) { t.Errorf("Ignore.Read() = %v, want %v", got, tt.want) diff --git a/pkg/runtime/runners_test.go b/pkg/runtime/runners_test.go index 12efb10ab..01c04bae6 100644 --- a/pkg/runtime/runners_test.go +++ b/pkg/runtime/runners_test.go @@ -5,10 +5,12 @@ package runtime import ( + "errors" "os" "os/exec" "testing" + "github.com/roddhjav/apparmor.d/pkg/builder" "github.com/roddhjav/apparmor.d/pkg/configure" "github.com/roddhjav/apparmor.d/pkg/directive" "github.com/roddhjav/apparmor.d/pkg/paths" @@ -27,6 +29,175 @@ func chdirGitRoot() { } } +// fakeConfigureTask is a minimal configure.Task used to drive the +// Runners.Configure logging and error branches. +type fakeConfigureTask struct { + tasks.BaseTask + msg []string + err error +} + +func (f *fakeConfigureTask) Apply() ([]string, error) { + return f.msg, f.err +} + +// newTestRunners returns Runners rooted in a temporary directory with an +// empty apparmor.d tree. +func newTestRunners(t *testing.T) *Runners { + t.Helper() + cfg := tasks.NewTaskConfig(paths.New(t.TempDir())) + if err := cfg.RootApparmor.MkdirAll(); err != nil { + t.Fatal(err) + } + return NewRunners(cfg) +} + +// writeProfile writes a profile file in the runner apparmor.d tree. +func writeProfile(t *testing.T, r *Runners, name string, content string) *paths.Path { + t.Helper() + file := r.RootApparmor.Join(name) + if err := file.WriteFile([]byte(content)); err != nil { + t.Fatal(err) + } + return file +} + +// chmodProfile changes the profile file mode, restored on test cleanup. +// Skips the test when run as root as file permissions are not enforced. +func chmodProfile(t *testing.T, file *paths.Path, mode os.FileMode) { + t.Helper() + if os.Geteuid() == 0 { + t.Skip("file permissions are not enforced for root") + } + if err := file.Chmod(mode); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = file.Chmod(0o644) }) +} + +func TestRunners_Configure(t *testing.T) { + tests := []struct { + name string + task func(r *Runners) configure.Task + wantErr bool + }{ + { + name: "log messages and warnings", + task: func(r *Runners) configure.Task { + return &fakeConfigureTask{ + BaseTask: tasks.BaseTask{Keyword: "fake", Msg: "fake configure task"}, + msg: []string{"profile foo not found", "regular message"}, + } + }, + wantErr: false, + }, + { + name: "task error", + task: func(r *Runners) configure.Task { + return &fakeConfigureTask{ + BaseTask: tasks.BaseTask{Keyword: "fake", Msg: "failing configure task"}, + err: errors.New("apply failed"), + } + }, + wantErr: true, + }, + { + name: "synchronise error", + task: func(r *Runners) configure.Task { + return configure.NewSynchronise([]*paths.Path{r.Root.Join("does-not-exist")}) + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := newTestRunners(t) + r.Configures.Add(tt.task(r)) + if err := r.Configure(); (err != nil) != tt.wantErr { + t.Errorf("Runners.Configure() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestRunners_BuildBranches(t *testing.T) { + tests := []struct { + name string + profile string + setup func(t *testing.T, r *Runners, file *paths.Path) + want string // expected profile content after Build, empty to skip the check + wantErr bool + }{ + { + name: "builder error", + profile: "profile foo /usr/bin/foo {\n}\n", + setup: func(t *testing.T, r *Runners, file *paths.Path) { + r.Builders.Add(builder.NewUserspace()) + }, + wantErr: true, + }, + { + name: "directive error", + profile: "profile foo /usr/bin/foo {\n #aa:unknown\n}\n", + wantErr: true, + }, + { + name: "unreadable file", + profile: "profile foo /usr/bin/foo {\n}\n", + setup: func(t *testing.T, r *Runners, file *paths.Path) { + chmodProfile(t, file, 0o000) + }, + wantErr: true, + }, + { + name: "unwritable file", + profile: "profile foo /usr/bin/foo {\n}\n", + setup: func(t *testing.T, r *Runners, file *paths.Path) { + chmodProfile(t, file, 0o444) + }, + wantErr: true, + }, + { + name: "skip missing file and list directives", + profile: "profile foo /usr/bin/foo {\n}\n", + setup: func(t *testing.T, r *Runners, file *paths.Path) { + // A dangling symlink is listed but must be skipped by Build. + dangling := r.RootApparmor.Join("dangling") + if err := os.Symlink(r.RootApparmor.Join("missing").String(), dangling.String()); err != nil { + t.Fatal(err) + } + r.Builders.Add(builder.NewComplain()) + r.Directives.Register(directive.NewDbus()) + }, + want: "profile foo /usr/bin/foo flags=(complain) {\n}\n", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := newTestRunners(t) + file := writeProfile(t, r, "foo", tt.profile) + if tt.setup != nil { + tt.setup(t, r, file) + } + if err := r.Build(); (err != nil) != tt.wantErr { + t.Errorf("Runners.Build() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.want == "" { + return + } + got, err := file.ReadFileAsString() + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Errorf("Runners.Build() profile = %v, want %v", got, tt.want) + } + }) + } +} + func TestRunners_Build(t *testing.T) { tests := []struct { name string diff --git a/pkg/state/detect.go b/pkg/state/detect.go new file mode 100644 index 000000000..33c6d46be --- /dev/null +++ b/pkg/state/detect.go @@ -0,0 +1,48 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package state + +import ( + "strconv" + "strings" + + "github.com/roddhjav/apparmor.d/pkg/logging" + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/state/detector" +) + +// Enabled reports whether the state system applies to the tree sys inspects: +// it is only enabled on abi 5 and over. +func Enabled(sys *detector.System) bool { + values := detector.Registry["ABI"].Detect(sys) + if len(values) == 0 { + return false + } + abi, err := strconv.Atoi(values[0]) + return err == nil && abi >= 5 +} + +// Detect runs the detector named after every variable defined in the src +// state file and returns the detected state as a file at dst, to be saved +// as a state.d drop-in. Variables without a detector are logged and +// skipped; detectors without a detected value are skipped. +func Detect(sys *detector.System, src *paths.Path, dst *paths.Path) (*File, error) { + stateFile, err := Load(src) + if err != nil { + return nil, err + } + res := NewFile(dst) + for _, key := range stateFile.Names() { + d, ok := detector.Registry[varName(key)] + if !ok { + logging.Warning("No state detector for %s", key) + continue + } + if values := d.Detect(sys); len(values) > 0 { + res.Add(key, strings.Join(values, " ")) + } + } + return res, nil +} diff --git a/pkg/state/detect_test.go b/pkg/state/detect_test.go new file mode 100644 index 000000000..8f4f047b0 --- /dev/null +++ b/pkg/state/detect_test.go @@ -0,0 +1,113 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package state + +import ( + "errors" + "strings" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/state/detector" +) + +func TestEnabled(t *testing.T) { + tests := []struct { + name string + abi string // base-strict abi rule; "" means no file + want bool + }{ + {name: "abi 5", abi: "abi ,\n", want: true}, + {name: "abi 4", abi: "abi ,\n", want: false}, + {name: "no abi", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sys := &detector.System{ + Root: paths.New(t.TempDir()), + Apparmor: paths.New(t.TempDir()), + } + if tt.abi != "" { + base := sys.Apparmor.Join("abstractions/base-strict") + if err := base.Parent().MkdirAll(); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := base.WriteFile([]byte(tt.abi)); err != nil { + t.Fatalf("write base-strict: %v", err) + } + } + if got := Enabled(sys); got != tt.want { + t.Errorf("Enabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDetect(t *testing.T) { + tests := []struct { + name string + state string // src state file content; "" means missing + wantErr bool + wantDropin string // dropin content after Save + }{ + { + name: "detected variables written to dropin", + state: "# AppArmor version\n@{VERSION} = 5\n\n" + + "# No detector for this one\n@{NO_DETECTOR_HERE} = x\n\n" + + "include if exists \n", + wantDropin: "@{VERSION} = 4\n", + }, + { + name: "undetected value skipped", + state: "@{DS} = wayland\n", + wantDropin: "", + }, + { + name: "missing state file", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := paths.New(t.TempDir()) + src := root.Join("state") + if tt.state != "" { + if err := src.WriteFile([]byte(tt.state)); err != nil { + t.Fatalf("write state: %v", err) + } + } + sys := &detector.System{ + Root: paths.New(t.TempDir()), + Apparmor: paths.New(t.TempDir()), + Run: func(name string, arg ...string) (string, error) { + if name == "apparmor_parser" { + return "AppArmor parser version 4.0.2\n", nil + } + return "", errors.New("stubbed: " + name) + }, + } + + dst := root.Join("state.d/aa-install") + file, err := Detect(sys, src, dst) + if (err != nil) != tt.wantErr { + t.Errorf("Detect() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if err := file.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + got, err := dst.ReadFileAsString() + if err != nil { + t.Fatalf("read dropin: %v", err) + } + if strings.TrimSpace(got) != strings.TrimSpace(tt.wantDropin) { + t.Errorf("Detect() dropin = %q, want %q", got, tt.wantDropin) + } + }) + } +} diff --git a/pkg/state/detector/abi.go b/pkg/state/detector/abi.go new file mode 100644 index 000000000..0c55c7bc3 --- /dev/null +++ b/pkg/state/detector/abi.go @@ -0,0 +1,46 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package detector + +import ( + "path" + "strings" + + "github.com/roddhjav/apparmor.d/pkg/aa" +) + +func init() { + Register(abi{}) +} + +// abi detects @{ABI} from the abi rule of the abstractions/base-strict file +// in the tree being written. +type abi struct{} + +func (abi) Name() string { return "ABI" } + +func (abi) Detect(sys *System) []string { + lines, err := sys.Apparmor.Join("abstractions/base-strict").ReadFileAsLines() + if err != nil { + return nil + } + // The aa parser only consumes preamble rules, not an abstraction + // body: feed it the abi lines only. + var abiLines []string + for _, line := range lines { + if line = strings.TrimSpace(line); strings.HasPrefix(line, "abi ") { + abiLines = append(abiLines, line) + } + } + f := &aa.AppArmorProfileFile{} + if _, err := f.Parse(strings.Join(abiLines, "\n") + "\n"); err != nil { + return nil + } + for _, rule := range f.Preamble.Filter(aa.ABI) { + major, _, _ := strings.Cut(path.Base(rule.(*aa.Abi).Path), ".") + return []string{major} + } + return nil +} diff --git a/pkg/state/detector/desktop.go b/pkg/state/detector/desktop.go new file mode 100644 index 000000000..859b77f87 --- /dev/null +++ b/pkg/state/detector/desktop.go @@ -0,0 +1,81 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package detector + +import ( + "os" + "strings" +) + +func init() { + Register(desktop{}) +} + +var ( + // deNames normalizes desktop names as reported by logind + deNames = []struct{ substr, de string }{ + {"gnome", "gnome"}, + {"plasma", "kde"}, + {"kde", "kde"}, + {"xfce", "xfce"}, + {"cosmic", "cosmic"}, + } + + // deShells maps a desktop shell process name to its desktop environment. + deShells = map[string]string{ + "gnome-shell": "gnome", + "plasmashell": "kde", + "xfce4-session": "xfce", + "cosmic-session": "cosmic", + } +) + +// desktop detects @{DE} from the desktops the logind sessions report, +// falling back to the desktop shell processes running on the system. +type desktop struct{} + +func (desktop) Name() string { return "DE" } + +func (desktop) Detect(sys *System) []string { + var values []string + for _, name := range sys.sessionProperty("Desktop") { + values = append(values, desktopNames(name)...) + } + if len(values) == 0 { + values = runningDesktops(sys) + } + return dedup(values) +} + +// desktopNames normalizes a logind Desktop value, a colon separated list of +// desktop names, to the documented @{DE} values. +func desktopNames(value string) []string { + var des []string + for field := range strings.SplitSeq(strings.ToLower(value), ":") { + for _, m := range deNames { + if strings.Contains(field, m.substr) { + des = append(des, m.de) + break + } + } + } + return des +} + +// runningDesktops identifies the running desktop shell processes. Not every +// session reports its desktop to logind. +func runningDesktops(sys *System) []string { + var des []string + for _, comm := range sortedGlob(sys.Root.Join("proc/*/comm").String()) { + data, err := os.ReadFile(comm) + if err != nil { + continue + } + if de := deShells[strings.TrimSpace(string(data))]; de != "" { + des = append(des, de) + } + } + return des +} diff --git a/pkg/state/detector/detector.go b/pkg/state/detector/detector.go new file mode 100644 index 000000000..124513f3c --- /dev/null +++ b/pkg/state/detector/detector.go @@ -0,0 +1,120 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +// Package detector provides the state variable detectors. Each detector +// lives in its own file and registers itself under the name of the state +// variable it detects. +package detector + +import ( + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/roddhjav/apparmor.d/pkg/paths" +) + +var ( + // Root is the default filesystem root of the system to inspect. Tests + // override it. + Root = paths.New("/") + + // Run is the default command runner, returning the command standard + // output. Tests override it. + Run = func(name string, arg ...string) (string, error) { + out, err := exec.Command(name, arg...).Output() + return string(out), err + } +) + +// System is the handle detectors inspect the running system through. +type System struct { + // Root is the filesystem root of the system. + Root *paths.Path + + // Apparmor is the apparmor.d tree being written: the build tree for + // aa-install, the installed tree for aa-config. + Apparmor *paths.Path + + // Run executes a command and returns its standard output. + Run func(name string, arg ...string) (string, error) +} + +// NewSystem returns a System for the running host. +func NewSystem(rootApparmor *paths.Path) *System { + return &System{Root: Root, Apparmor: rootApparmor, Run: Run} +} + +// sessions returns the logind session ids. +func (sys *System) sessions() []string { + out, err := sys.Run("loginctl", "list-sessions", "--no-legend") + if err != nil { + return nil + } + var ids []string + for line := range strings.SplitSeq(out, "\n") { + if fields := strings.Fields(line); len(fields) > 0 { + ids = append(ids, fields[0]) + } + } + return ids +} + +// sessionProperty returns the non-empty values of a logind session property +// across all sessions. +func (sys *System) sessionProperty(prop string) []string { + var values []string + for _, id := range sys.sessions() { + out, err := sys.Run("loginctl", "show-session", id, "-p", prop, "--value") + if err != nil { + continue + } + if value := strings.TrimSpace(out); value != "" { + values = append(values, value) + } + } + return values +} + +// Detector detects the values of one state variable from the enabled system +// state only, never from installed files or distribution specific paths. +type Detector interface { + // Name is the name of the state variable, without `@{}`. + Name() string + + // Detect returns the detected values, or nil when they cannot be + // determined. + Detect(sys *System) []string +} + +// Registry maps a state variable name to its detector. Each detector file +// registers itself on init. +var Registry = map[string]Detector{} + +// Register adds a detector to the registry. +func Register(detectors ...Detector) { + for _, d := range detectors { + Registry[d.Name()] = d + } +} + +// dedup removes duplicates, preserving order. +func dedup(values []string) []string { + var res []string + seen := map[string]bool{} + for _, value := range values { + if !seen[value] { + seen[value] = true + res = append(res, value) + } + } + return res +} + +func sortedGlob(pattern string) []string { + files, _ := filepath.Glob(pattern) + sort.Strings(files) + return files +} diff --git a/pkg/state/detector/detector_test.go b/pkg/state/detector/detector_test.go new file mode 100644 index 000000000..38f6c4c0e --- /dev/null +++ b/pkg/state/detector/detector_test.go @@ -0,0 +1,304 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package detector + +import ( + "errors" + "os" + "slices" + "strings" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" + "github.com/roddhjav/apparmor.d/pkg/tasks" +) + +// newTestSystem returns a System over temp trees, with a Run stub serving +// the given canned outputs, keyed by the full command line. Commands without +// a canned output fail. +func newTestSystem(t *testing.T, commands map[string]string) *System { + t.Helper() + return &System{ + Root: paths.New(t.TempDir()), + Apparmor: paths.New(t.TempDir()), + Run: func(name string, arg ...string) (string, error) { + cmdline := strings.Join(append([]string{name}, arg...), " ") + out, ok := commands[cmdline] + if !ok { + return "", errors.New("stubbed: " + cmdline) + } + return out, nil + }, + } +} + +// loginctlCommands returns the Run outputs for a set of logind sessions, +// given per session id property values. +func loginctlCommands(sessions map[string]map[string]string) map[string]string { + commands := map[string]string{} + var list []string + ids := make([]string, 0, len(sessions)) + for id := range sessions { + ids = append(ids, id) + } + slices.Sort(ids) + for _, id := range ids { + list = append(list, id+" 1000 alex seat0 42 user tty2 no -") + for prop, value := range sessions[id] { + commands["loginctl show-session "+id+" -p "+prop+" --value"] = value + "\n" + } + } + commands["loginctl list-sessions --no-legend"] = strings.Join(list, "\n") + "\n" + return commands +} + +// setupOSRelease stubs the tasks os-release globals. +func setupOSRelease(t *testing.T, family string, release map[string]string) { + t.Helper() + oldFamily, oldRelease := tasks.Family, tasks.Release + tasks.Family, tasks.Release = family, release + t.Cleanup(func() { tasks.Family, tasks.Release = oldFamily, oldRelease }) +} + +func symlink(t *testing.T, target string, link *paths.Path) { + t.Helper() + if err := link.Parent().MkdirAll(); err != nil { + t.Fatalf("mkdir %s: %v", link.Parent(), err) + } + if err := os.Symlink(target, link.String()); err != nil { + t.Fatalf("symlink %s: %v", link, err) + } +} + +func writeFile(t *testing.T, p *paths.Path, content string) { + t.Helper() + if err := p.Parent().MkdirAll(); err != nil { + t.Fatalf("mkdir %s: %v", p.Parent(), err) + } + if err := p.WriteFile([]byte(content)); err != nil { + t.Fatalf("write %s: %v", p, err) + } +} + +func TestRegister(t *testing.T) { + for _, name := range []string{ + "ABI", "VERSION", "OS_FAMILY", "OS_ID", "OS_VERSION_ID", + "DE", "DM", "DS", "GPU", + } { + t.Run(name, func(t *testing.T) { + if _, ok := Registry[name]; !ok { + t.Errorf("Registry[%s] = %v, want %v", name, "missing", "registered") + } + }) + } +} + +func TestVersion_Detect(t *testing.T) { + tests := []struct { + name string + out string // apparmor_parser -V output; "" = command error + want []string + }{ + {name: "release", out: "AppArmor parser version 4.0.2\n...", want: []string{"4"}}, + {name: "beta", out: "AppArmor parser version 5.0.0~beta1\n", want: []string{"5"}}, + {name: "command error", want: nil}, + {name: "garbage output", out: "no digits here", want: nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + commands := map[string]string{} + if tt.out != "" { + commands["apparmor_parser -V"] = tt.out + } + sys := newTestSystem(t, commands) + if got := (version{}).Detect(sys); !slices.Equal(got, tt.want) { + t.Errorf("Detect() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAbi_Detect(t *testing.T) { + tests := []struct { + name string + content string + missing bool + want []string + }{ + {name: "abi 5.0", content: "# header\n abi ,\n\n /usr/** r,\n", want: []string{"5"}}, + {name: "abi 5", content: "abi ,\n", want: []string{"5"}}, + {name: "no abi line", content: "# nothing\n", want: nil}, + {name: "missing file", missing: true, want: nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sys := newTestSystem(t, nil) + if !tt.missing { + writeFile(t, sys.Apparmor.Join("abstractions/base-strict"), tt.content) + } + if got := (abi{}).Detect(sys); !slices.Equal(got, tt.want) { + t.Errorf("Detect() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestOsRelease_Detect(t *testing.T) { + tests := []struct { + name string + detector osRelease + family string + release map[string]string + want []string + }{ + {name: "family", detector: osRelease{name: "OS_FAMILY"}, family: "pacman", want: []string{"pacman"}}, + {name: "id", detector: osRelease{name: "OS_ID", key: "ID"}, release: map[string]string{"ID": "arch"}, want: []string{"arch"}}, + {name: "version id", detector: osRelease{name: "OS_VERSION_ID", key: "VERSION_ID"}, release: map[string]string{"VERSION_ID": "rolling"}, want: []string{"rolling"}}, + {name: "unknown", detector: osRelease{name: "OS_ID", key: "ID"}, want: nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupOSRelease(t, tt.family, tt.release) + if got := tt.detector.Detect(newTestSystem(t, nil)); !slices.Equal(got, tt.want) { + t.Errorf("Detect() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDisplayManager_Detect(t *testing.T) { + tests := []struct { + name string + target string // display-manager.service symlink target; "" = no symlink + want []string + }{ + {name: "gdm enabled", target: "/usr/lib/systemd/system/gdm.service", want: []string{"gdm"}}, + {name: "sddm enabled", target: "/usr/lib/systemd/system/sddm.service", want: []string{"sddm"}}, + {name: "gdm3 unit name normalized", target: "/lib/systemd/system/gdm3.service", want: []string{"gdm"}}, + {name: "no display manager", want: []string{"none"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sys := newTestSystem(t, nil) + if tt.target != "" { + symlink(t, tt.target, sys.Root.Join("etc/systemd/system/display-manager.service")) + } + if got := (displayManager{}).Detect(sys); !slices.Equal(got, tt.want) { + t.Errorf("Detect() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDesktop_Detect(t *testing.T) { + tests := []struct { + name string + sessions map[string]map[string]string // session id -> property -> value + processes []string // comm of running processes + want []string + }{ + { + name: "session desktop", + sessions: map[string]map[string]string{"2": {"Desktop": "KDE"}}, + want: []string{"kde"}, + }, + { + name: "colon separated desktop list", + sessions: map[string]map[string]string{"2": {"Desktop": "ubuntu:GNOME"}}, + want: []string{"gnome"}, + }, + { + name: "multiple sessions deduped", + sessions: map[string]map[string]string{ + "2": {"Desktop": "GNOME"}, + "3": {"Desktop": "gnome"}, + "4": {"Desktop": "plasma"}, + }, + want: []string{"gnome", "kde"}, + }, + { + name: "empty session desktop falls back to running shell", + sessions: map[string]map[string]string{"2": {"Desktop": ""}}, + processes: []string{"systemd", "gnome-shell"}, + want: []string{"gnome"}, + }, + { + name: "no logind falls back to running shell", + processes: []string{"cosmic-session"}, + want: []string{"cosmic"}, + }, + {name: "nothing detected", want: nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sys := newTestSystem(t, loginctlCommands(tt.sessions)) + for i, comm := range tt.processes { + writeFile(t, sys.Root.Join("proc", string(rune('1'+i)), "comm"), comm+"\n") + } + if got := (desktop{}).Detect(sys); !slices.Equal(got, tt.want) { + t.Errorf("Detect() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDisplayServer_Detect(t *testing.T) { + tests := []struct { + name string + sessions map[string]map[string]string // session id -> property -> value + want []string + }{ + { + name: "wayland session", + sessions: map[string]map[string]string{"2": {"Type": "wayland"}}, + want: []string{"wayland"}, + }, + { + name: "tty sessions filtered, list deduped", + sessions: map[string]map[string]string{ + "1": {"Type": "tty"}, + "2": {"Type": "wayland"}, + "3": {"Type": "x11"}, + "4": {"Type": "wayland"}, + }, + want: []string{"wayland", "x11"}, + }, + {name: "no session", want: nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sys := newTestSystem(t, loginctlCommands(tt.sessions)) + if got := (displayServer{}).Detect(sys); !slices.Equal(got, tt.want) { + t.Errorf("Detect() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGpu_Detect(t *testing.T) { + tests := []struct { + name string + vendors map[string]string // card name -> vendor file content + want []string + }{ + {name: "nvidia", vendors: map[string]string{"card0": "0x10de\n"}, want: []string{"nvidia"}}, + {name: "hybrid intel nvidia", vendors: map[string]string{"card0": "0x8086\n", "card1": "0x10de\n"}, want: []string{"intel", "nvidia"}}, + {name: "amd", vendors: map[string]string{"card0": "0x1002\n"}, want: []string{"amd"}}, + {name: "duplicated vendor", vendors: map[string]string{"card0": "0x1002\n", "card1": "0x1002\n"}, want: []string{"amd"}}, + {name: "unknown vendor skipped", vendors: map[string]string{"card0": "0xdead\n"}, want: []string{"none"}}, + {name: "no drm", want: []string{"none"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sys := newTestSystem(t, nil) + for card, vendor := range tt.vendors { + writeFile(t, sys.Root.Join("sys/class/drm/"+card+"/device/vendor"), vendor) + } + if got := (gpu{}).Detect(sys); !slices.Equal(got, tt.want) { + t.Errorf("Detect() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/state/detector/display_manager.go b/pkg/state/detector/display_manager.go new file mode 100644 index 000000000..ac032ded9 --- /dev/null +++ b/pkg/state/detector/display_manager.go @@ -0,0 +1,37 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package detector + +import ( + "os" + "path/filepath" + "strings" +) + +func init() { + Register(displayManager{}) +} + +var dmAliases = map[string]string{ + "gdm3": "gdm", +} + +// displayManager detects @{DM} from the enabled display-manager.service +// systemd unit. +type displayManager struct{} + +func (displayManager) Name() string { return "DM" } + +func (displayManager) Detect(sys *System) []string { + target, err := os.Readlink(sys.Root.Join("etc/systemd/system/display-manager.service").String()) + if err != nil { + return []string{"none"} + } + name := strings.TrimSuffix(filepath.Base(target), ".service") + if alias, ok := dmAliases[name]; ok { + name = alias + } + return []string{name} +} diff --git a/pkg/state/detector/display_server.go b/pkg/state/detector/display_server.go new file mode 100644 index 000000000..9c8bfdd42 --- /dev/null +++ b/pkg/state/detector/display_server.go @@ -0,0 +1,24 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package detector + +func init() { + Register(displayServer{}) +} + +// displayServer detects @{DS} from the type of the logind sessions. +type displayServer struct{} + +func (displayServer) Name() string { return "DS" } + +func (displayServer) Detect(sys *System) []string { + var values []string + for _, value := range sys.sessionProperty("Type") { + if value == "wayland" || value == "x11" { + values = append(values, value) + } + } + return dedup(values) +} diff --git a/pkg/state/detector/gpu.go b/pkg/state/detector/gpu.go new file mode 100644 index 000000000..abb516555 --- /dev/null +++ b/pkg/state/detector/gpu.go @@ -0,0 +1,42 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package detector + +import ( + "os" + "strings" +) + +func init() { + Register(gpu{}) +} + +var gpuVendors = map[string]string{ + "0x10de": "nvidia", + "0x1002": "amd", + "0x8086": "intel", +} + +// gpu detects @{GPU} from the vendor of the cards in /sys/class/drm. +type gpu struct{} + +func (gpu) Name() string { return "GPU" } + +func (gpu) Detect(sys *System) []string { + var vendors []string + for _, card := range sortedGlob(sys.Root.Join("sys/class/drm/*/device/vendor").String()) { + data, err := os.ReadFile(card) + if err != nil { + continue + } + if vendor := gpuVendors[strings.TrimSpace(string(data))]; vendor != "" { + vendors = append(vendors, vendor) + } + } + if len(vendors) == 0 { + return []string{"none"} + } + return dedup(vendors) +} diff --git a/pkg/state/detector/os_release.go b/pkg/state/detector/os_release.go new file mode 100644 index 000000000..d6eb12e51 --- /dev/null +++ b/pkg/state/detector/os_release.go @@ -0,0 +1,35 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package detector + +import "github.com/roddhjav/apparmor.d/pkg/tasks" + +func init() { + Register( + osRelease{name: "OS_FAMILY"}, + osRelease{name: "OS_ID", key: "ID"}, + osRelease{name: "OS_VERSION_ID", key: "VERSION_ID"}, + ) +} + +// osRelease detects an @{OS_*} variable from os-release. With no key it +// reports the distribution family. +type osRelease struct { + name string + key string +} + +func (d osRelease) Name() string { return d.name } + +func (d osRelease) Detect(*System) []string { + value := tasks.Family + if d.key != "" { + value = tasks.Release[d.key] + } + if value == "" { + return nil + } + return []string{value} +} diff --git a/pkg/state/detector/version.go b/pkg/state/detector/version.go new file mode 100644 index 000000000..b0f1d7cf6 --- /dev/null +++ b/pkg/state/detector/version.go @@ -0,0 +1,29 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package detector + +import "regexp" + +func init() { + Register(version{}) +} + +var regParserVersion = regexp.MustCompile(`version\s+(\d+)`) + +// version detects @{VERSION} from the running `apparmor_parser -V`. +type version struct{} + +func (version) Name() string { return "VERSION" } + +func (version) Detect(sys *System) []string { + out, err := sys.Run("apparmor_parser", "-V") + if err != nil { + return nil + } + if m := regParserVersion.FindStringSubmatch(out); m != nil { + return []string{m[1]} + } + return nil +} diff --git a/pkg/state/file.go b/pkg/state/file.go new file mode 100644 index 000000000..c31869915 --- /dev/null +++ b/pkg/state/file.go @@ -0,0 +1,107 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +// Package state detects system state values and edits the state tunable files +package state + +import ( + "strings" + + "github.com/roddhjav/apparmor.d/pkg/aa" + "github.com/roddhjav/apparmor.d/pkg/paths" +) + +// Key normalizes a variable name to its `@{NAME}` form. +func Key(name string) string { + if strings.HasPrefix(name, "@{") { + return name + } + return "@{" + name + "}" +} + +// varName returns the aa.Variable name form of a key, without `@{}`. +func varName(key string) string { + return strings.TrimSuffix(strings.TrimPrefix(key, "@{"), "}") +} + +// File is a state tunable file +type File struct { + path *paths.Path + file *aa.AppArmorProfileFile +} + +// Load reads and parses a tunable file. +func Load(path *paths.Path) (*File, error) { + content, err := path.ReadFileAsString() + if err != nil { + return nil, err + } + file := &aa.AppArmorProfileFile{} + if _, err := file.Parse(content); err != nil { + return nil, err + } + return &File{path: path, file: file}, nil +} + +// NewFile returns an empty in-memory tunable file, created on Save. +func NewFile(path *paths.Path) *File { + return &File{path: path, file: &aa.AppArmorProfileFile{}} +} + +// variable returns the definition of a variable, nil when not defined. +func (f *File) variable(key string) *aa.Variable { + name := varName(key) + for _, rule := range f.file.Preamble { + if v, ok := rule.(*aa.Variable); ok && v.Name == name { + return v + } + } + return nil +} + +// Get returns the value of a variable definition. +func (f *File) Get(name string) (string, bool) { + if v := f.variable(name); v != nil { + return strings.Join(v.Values, " "), true + } + return "", false +} + +// Names returns the defined variable names, in file order. +func (f *File) Names() []string { + var names []string + for _, rule := range f.file.Preamble { + if v, ok := rule.(*aa.Variable); ok { + names = append(names, Key(v.Name)) + } + } + return names +} + +// Set replaces the value of an existing definition in place. It reports +// whether the variable was found. +func (f *File) Set(name, value string) bool { + if v := f.variable(name); v != nil { + v.Values = strings.Fields(value) + return true + } + return false +} + +// Add appends a definition at the end of the file. +func (f *File) Add(name, value string) { + f.file.Preamble = append(f.file.Preamble, &aa.Variable{ + Name: varName(name), + Values: strings.Fields(value), + Define: true, + }) +} + +// Save writes the file back, creating parent directories as needed. +func (f *File) Save() error { + if err := f.path.Parent().MkdirAll(); err != nil { + return err + } + return f.path.WriteFile([]byte(f.file.String())) +} diff --git a/pkg/state/file_test.go b/pkg/state/file_test.go new file mode 100644 index 000000000..345065516 --- /dev/null +++ b/pkg/state/file_test.go @@ -0,0 +1,234 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package state + +import ( + "slices" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" +) + +const stateFixture = `# System states +# +# Comment block. + +# AppArmor ABI +@{ABI} = 5 + +# The display server in use. +@{DS} = wayland + +#${FSP} = false + +include if exists +` + +func writeState(t *testing.T, content string) *paths.Path { + t.Helper() + p := paths.New(t.TempDir()).Join("state") + if err := p.WriteFile([]byte(content)); err != nil { + t.Fatalf("write %s: %v", p, err) + } + return p +} + +func TestKey(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "bare name", in: "DS", want: "@{DS}"}, + {name: "wrapped name", in: "@{DS}", want: "@{DS}"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Key(tt.in); got != tt.want { + t.Errorf("Key() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLoad(t *testing.T) { + tests := []struct { + name string + missing bool + wantErr bool + }{ + {name: "existing file"}, + {name: "missing file", missing: true, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := writeState(t, stateFixture) + if tt.missing { + p = paths.New(t.TempDir()).Join("absent") + } + _, err := Load(p) + if (err != nil) != tt.wantErr { + t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFile_Get(t *testing.T) { + tests := []struct { + name string + varName string + want string + wantOk bool + }{ + {name: "existing bare", varName: "ABI", want: "5", wantOk: true}, + {name: "existing wrapped", varName: "@{DS}", want: "wayland", wantOk: true}, + {name: "commented-out is not defined", varName: "FSP"}, + {name: "unknown", varName: "NOPE"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := Load(writeState(t, stateFixture)) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + got, ok := f.Get(tt.varName) + if got != tt.want || ok != tt.wantOk { + t.Errorf("Get() = (%v, %v), want (%v, %v)", got, ok, tt.want, tt.wantOk) + } + }) + } +} + +func TestFile_Names(t *testing.T) { + f, err := Load(writeState(t, stateFixture)) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + want := []string{"@{ABI}", "@{DS}"} + if got := f.Names(); !slices.Equal(got, want) { + t.Errorf("Names() = %v, want %v", got, want) + } +} + +func TestFile_SetSave(t *testing.T) { + tests := []struct { + name string + varName string + value string + wantFound bool + want string // full content after Save, in the aa canonical format + }{ + { + name: "replace value in place", varName: "DS", value: "x11", wantFound: true, + want: `# System states + +# Comment block. +# AppArmor ABI +@{ABI} = 5 +# The display server in use. +@{DS} = x11 +#${FSP} = false + +include if exists +`, + }, + { + name: "unknown name untouched", varName: "NOPE", value: "1", + want: `# System states + +# Comment block. +# AppArmor ABI +@{ABI} = 5 +# The display server in use. +@{DS} = wayland +#${FSP} = false + +include if exists +`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := writeState(t, stateFixture) + f, err := Load(p) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if found := f.Set(tt.varName, tt.value); found != tt.wantFound { + t.Fatalf("Set() = %v, want %v", found, tt.wantFound) + } + if err := f.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + want := tt.want + if want == "" { + want = stateFixture + } + got, err := p.ReadFileAsString() + if err != nil { + t.Fatalf("read state: %v", err) + } + if got != want { + t.Errorf("Save() wrote %q, want %q", got, want) + } + }) + } +} + +func TestFile_Add(t *testing.T) { + tests := []struct { + name string + initial string // "" means NewFile + varName string + value string + want string + }{ + { + name: "append to new file", varName: "with_heroic", value: "true", + want: "@{with_heroic} = true\n", + }, + { + name: "append keeps trailing newline", initial: "@{A} = 1\n", + varName: "B", value: "2", want: "@{A} = 1\n@{B} = 2\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := paths.New(t.TempDir()).Join("state.d/aa-config") + var f *File + if tt.initial == "" { + f = NewFile(p) + } else { + writeFileHelper(t, p, tt.initial) + var err error + if f, err = Load(p); err != nil { + t.Fatalf("Load() error = %v", err) + } + } + f.Add(tt.varName, tt.value) + if err := f.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + got, err := p.ReadFileAsString() + if err != nil { + t.Fatalf("read: %v", err) + } + if got != tt.want { + t.Errorf("Add()+Save() wrote %q, want %q", got, tt.want) + } + }) + } +} + +func writeFileHelper(t *testing.T, p *paths.Path, content string) { + t.Helper() + if err := p.Parent().MkdirAll(); err != nil { + t.Fatalf("mkdir %s: %v", p.Parent(), err) + } + if err := p.WriteFile([]byte(content)); err != nil { + t.Fatalf("write %s: %v", p, err) + } +} diff --git a/pkg/tasks/config.go b/pkg/tasks/config.go index 26ceaa117..d158ab6bb 100644 --- a/pkg/tasks/config.go +++ b/pkg/tasks/config.go @@ -30,6 +30,9 @@ type TaskConfig struct { // RootApparmor is the source apparmor.d directory (e.g. .build/apparmor.d) RootApparmor *paths.Path + + // Groups maps a profile basename to the groups/ subdirectory it came from + Groups map[string]string } func NewTaskConfig(root *paths.Path) *TaskConfig { @@ -42,5 +45,6 @@ func NewTaskConfig(root *paths.Path) *TaskConfig { Pkgname: "apparmor.d", Root: root, RootApparmor: root.Join("apparmor.d"), + Groups: map[string]string{}, } } diff --git a/pkg/tasks/os.go b/pkg/tasks/os.go index 6d86de33e..c7b6ff1b2 100644 --- a/pkg/tasks/os.go +++ b/pkg/tasks/os.go @@ -13,15 +13,14 @@ import ( ) var ( + // Distribution is the name of the distribution, such as "debian", "ubuntu", "arch", "opensuse", etc. Distribution = getDistribution() - Release = getOSRelease() - Family = getFamily() - // DebianDir is the directory where the debian specific files are stored - // DebianDir *paths.Path = paths.New("debian") + // Release is the content of the /etc/os-release file as a map of key-value pairs + Release = getOSRelease() - // // DebianHide is the path to the debian/common.hide file - // DebianHide = DebianHider{path: DebianDir.Join("common.hide")} + // Family is the family of the distribution, such as "apt", "pacman", "zypper", etc. + Family = getFamily() // Ignore = Ignorer{} // Flags = Flagger{} diff --git a/pkg/util/conf.go b/pkg/util/conf.go new file mode 100644 index 000000000..5e7f9705c --- /dev/null +++ b/pkg/util/conf.go @@ -0,0 +1,85 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package util + +import ( + "maps" + "strings" + + "github.com/roddhjav/apparmor.d/pkg/paths" +) + +// ReadFlagsFile parses a flags manifest file: one "profile flag1,flag2" per +// line, # comments filtered. A profile without flags maps to an empty list. +func ReadFlagsFile(path *paths.Path) map[string][]string { + res := map[string][]string{} + if !path.Exist() { + return res + } + for _, line := range path.MustReadFilteredFileAsLines() { + manifest := strings.Split(line, " ") + profile := manifest[0] + flags := []string{} + if len(manifest) > 1 { + flags = strings.Split(manifest[1], ",") + } + res[profile] = flags + } + return res +} + +// effectiveConfFiles returns the *.conf files of the given files or +// directories, in order. A same-named file from a later source fully +// replaces an earlier one (admin config overrides vendor config). +func effectiveConfFiles(sources ...*paths.Path) paths.PathList { + var order []string + byName := map[string]*paths.Path{} + add := func(file *paths.Path) { + if _, seen := byName[file.Base()]; !seen { + order = append(order, file.Base()) + } + byName[file.Base()] = file + } + for _, src := range sources { + if src == nil { + continue + } + if src.IsDir() { + files, err := src.ReadDir(paths.FilterOutDirectories(), paths.FilterSuffixes(".conf")) + if err != nil { + continue + } + for _, file := range files { + add(file) + } + } else if src.Exist() { + add(src) + } + } + res := make(paths.PathList, 0, len(order)) + for _, name := range order { + res = append(res, byName[name]) + } + return res +} + +// ReadFlagDirs merges the flags manifests from the effective files of +// sources; a later file overrides an earlier one per profile. +func ReadFlagDirs(sources ...*paths.Path) map[string][]string { + res := map[string][]string{} + for _, file := range effectiveConfFiles(sources...) { + maps.Copy(res, ReadFlagsFile(file)) + } + return res +} + +// ReadConfDirs returns the filtered lines of the effective *.conf files in dirs. +func ReadConfDirs(dirs ...*paths.Path) []string { + var res []string + for _, file := range effectiveConfFiles(dirs...) { + res = append(res, file.MustReadFilteredFileAsLines()...) + } + return res +} diff --git a/pkg/util/conf_test.go b/pkg/util/conf_test.go new file mode 100644 index 000000000..6459d0d3b --- /dev/null +++ b/pkg/util/conf_test.go @@ -0,0 +1,204 @@ +// apparmor.d - Full set of apparmor profiles +// Copyright (C) 2026 Alexandre Pujol +// SPDX-License-Identifier: GPL-2.0-only + +package util + +import ( + "os" + "reflect" + "slices" + "testing" + + "github.com/roddhjav/apparmor.d/pkg/paths" +) + +// writeConfDir creates a temporary directory seeded with the given files. +func writeConfDir(t *testing.T, files map[string]string) *paths.Path { + t.Helper() + dir := paths.New(t.TempDir()) + for name, content := range files { + if err := dir.Join(name).WriteFile([]byte(content)); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + return dir +} + +// chmodNoRead removes the read permission from a path, restoring a sane +// mode on cleanup. Skipped for root, which bypasses file modes. +func chmodNoRead(t *testing.T, p *paths.Path) { + t.Helper() + if os.Geteuid() == 0 { + t.Skip("running as root: permission errors cannot be provoked") + } + if err := os.Chmod(p.String(), 0o000); err != nil { + t.Fatalf("chmod %s: %v", p, err) + } + t.Cleanup(func() { _ = os.Chmod(p.String(), 0o755) }) +} + +func TestReadFlagsFile(t *testing.T) { + tests := []struct { + name string + content string // "" means the file is not created + want map[string][]string + }{ + { + name: "missing file", + content: "", + want: map[string][]string{}, + }, + { + name: "flags and comments", + content: "# flags\n\nfoo complain\nbar attach_disconnected,complain\nbaz\n", + want: map[string][]string{ + "foo": {"complain"}, + "bar": {"attach_disconnected", "complain"}, + "baz": {}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := paths.New(t.TempDir()).Join("flags.conf") + if tt.content != "" { + if err := path.WriteFile([]byte(tt.content)); err != nil { + t.Fatalf("write flags: %v", err) + } + } + if got := ReadFlagsFile(path); !reflect.DeepEqual(got, tt.want) { + t.Errorf("ReadFlagsFile() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestReadFlagDirs(t *testing.T) { + tests := []struct { + name string + vendor map[string]string // nil means the directory does not exist + admin map[string]string + unreadable bool // remove read permission from the vendor directory + want map[string][]string + }{ + { + name: "unreadable dir skipped", + vendor: map[string]string{"a.conf": "foo complain\n"}, + admin: map[string]string{"a.conf": "bar enforce\n"}, + unreadable: true, + want: map[string][]string{"bar": {"enforce"}}, + }, + { + name: "no dirs", + want: map[string][]string{}, + }, + { + name: "vendor only", + vendor: map[string]string{"a.conf": "foo complain\n"}, + want: map[string][]string{"foo": {"complain"}}, + }, + { + name: "same name admin file replaces vendor file", + vendor: map[string]string{"a.conf": "foo complain\nbar complain\n"}, + admin: map[string]string{"a.conf": "foo enforce\n"}, + want: map[string][]string{"foo": {"enforce"}}, + }, + { + name: "different names merge per profile", + vendor: map[string]string{"00-main.conf": "foo complain\nbar complain\n"}, + admin: map[string]string{"10-user.conf": "foo enforce\n"}, + want: map[string][]string{"foo": {"enforce"}, "bar": {"complain"}}, + }, + { + name: "later file overrides within a dir", + vendor: map[string]string{"a.conf": "foo complain\n", "b.conf": "foo enforce\n"}, + want: map[string][]string{"foo": {"enforce"}}, + }, + { + name: "non conf file ignored", + vendor: map[string]string{"a.conf": "foo complain\n", "b.txt": "bar enforce\n"}, + want: map[string][]string{"foo": {"complain"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var dirs []*paths.Path + for _, files := range []map[string]string{tt.vendor, tt.admin} { + if files == nil { + dirs = append(dirs, paths.New(t.TempDir()).Join("missing")) + continue + } + dirs = append(dirs, writeConfDir(t, files)) + } + if tt.unreadable { + chmodNoRead(t, dirs[0]) + } + if got := ReadFlagDirs(dirs...); !reflect.DeepEqual(got, tt.want) { + t.Errorf("ReadFlagDirs() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestReadConfDirs(t *testing.T) { + tests := []struct { + name string + vendor map[string]string // nil means the directory does not exist + admin map[string]string + unreadable bool // remove read permission from the vendor directory + want []string + }{ + { + name: "unreadable dir skipped", + vendor: map[string]string{"a.conf": "foo\n"}, + admin: map[string]string{"a.conf": "bar\n"}, + unreadable: true, + want: []string{"bar"}, + }, + { + name: "no dirs", + want: nil, + }, + { + name: "same name admin file replaces vendor file", + vendor: map[string]string{"a.conf": "# vendor\nfoo\n"}, + admin: map[string]string{"a.conf": "bar\n"}, + want: []string{"bar"}, + }, + { + name: "different names concatenated", + vendor: map[string]string{"00-main.conf": "foo\n"}, + admin: map[string]string{"10-user.conf": "bar\n"}, + want: []string{"foo", "bar"}, + }, + { + name: "files read in alphabetical order", + vendor: map[string]string{"b.conf": "second\n", "a.conf": "first\n"}, + want: []string{"first", "second"}, + }, + { + name: "missing vendor dir skipped", + admin: map[string]string{"a.conf": "bar\n"}, + want: []string{"bar"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var dirs []*paths.Path + for _, files := range []map[string]string{tt.vendor, tt.admin} { + if files == nil { + dirs = append(dirs, paths.New(t.TempDir()).Join("missing")) + continue + } + dirs = append(dirs, writeConfDir(t, files)) + } + if tt.unreadable { + chmodNoRead(t, dirs[0]) + } + if got := ReadConfDirs(dirs...); !slices.Equal(got, tt.want) { + t.Errorf("ReadConfDirs() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/util/profiles.go b/pkg/util/profiles.go index 0069bb5cd..fe128145e 100644 --- a/pkg/util/profiles.go +++ b/pkg/util/profiles.go @@ -54,6 +54,30 @@ func IsUnconfined(profile string) bool { return false } +// ApplyFlags merges flags into a profile, mode-aware: non-mode flags are +// added to the ones the profile already has (e.g. attach_disconnected is +// preserved), and a mode flag (enforce, complain, ...) is applied via SetMode +// so modes never stack into an invalid combo such as (enforce,complain). The +// last mode listed wins. +func ApplyFlags(profile string, flags []string) (string, error) { + current := GetFlags(profile) + var mode string + for _, f := range flags { + if slices.Contains(ProfileModes, f) { + mode = f + continue + } + if !slices.Contains(current, f) { + current = append(current, f) + } + } + profile = SetFlags(profile, current) + if mode == "" { + return profile, nil + } + return SetMode(profile, mode) +} + // SetMode sets the given mode in the profile string, removing any conflicting mode flags. func SetMode(profile string, mode string) (string, error) { if !slices.Contains(ProfileModes, mode) { @@ -62,6 +86,12 @@ func SetMode(profile string, mode string) (string, error) { flags := GetFlags(profile) + // An explicitly unconfined profile stays unconfined: never replace it. + // unconfined is never a target mode, but it can already exist in a profile. + if slices.Contains(flags, "unconfined") { + return profile, nil + } + // Remove all conflicting mode flags flags = slices.DeleteFunc(flags, func(f string) bool { return slices.Contains(ProfileModes, f) diff --git a/pkg/util/profiles_test.go b/pkg/util/profiles_test.go index 430450109..50c3685d6 100644 --- a/pkg/util/profiles_test.go +++ b/pkg/util/profiles_test.go @@ -182,6 +182,12 @@ func TestSetMode(t *testing.T) { mode: "kill", want: "profile foo /usr/bin/foo flags=(attach_disconnected,kill) {\n", }, + { + name: "unconfined profile stays unconfined", + profile: "profile foo /usr/bin/foo flags=(unconfined) {\n", + mode: "complain", + want: "profile foo /usr/bin/foo flags=(unconfined) {\n", + }, { name: "unknown mode returns error", profile: "profile foo /usr/bin/foo {\n", @@ -203,3 +209,74 @@ func TestSetMode(t *testing.T) { }) } } + +func TestApplyFlags(t *testing.T) { + tests := []struct { + name string + profile string + flags []string + want string + wantErr bool + }{ + { + name: "mode flag applied via SetMode", + profile: "profile foo /usr/bin/foo {\n", + flags: []string{"complain"}, + want: "profile foo /usr/bin/foo flags=(complain) {\n", + }, + { + name: "enforce is the default, no flags clause", + profile: "profile foo /usr/bin/foo {\n", + flags: []string{"enforce"}, + want: "profile foo /usr/bin/foo {\n", + }, + { + name: "enforce clears a pre-existing complain", + profile: "profile foo /usr/bin/foo flags=(complain) {\n", + flags: []string{"enforce"}, + want: "profile foo /usr/bin/foo {\n", + }, + { + name: "mode never stacks with itself", + profile: "profile foo /usr/bin/foo flags=(enforce) {\n", + flags: []string{"complain"}, + want: "profile foo /usr/bin/foo flags=(complain) {\n", + }, + { + name: "enforce preserves an existing attach_disconnected", + profile: "profile foo /usr/bin/foo flags=(attach_disconnected) {\n", + flags: []string{"enforce"}, + want: "profile foo /usr/bin/foo flags=(attach_disconnected) {\n", + }, + { + name: "complain keeps an existing attach_disconnected", + profile: "profile foo /usr/bin/foo flags=(attach_disconnected) {\n", + flags: []string{"complain"}, + want: "profile foo /usr/bin/foo flags=(attach_disconnected,complain) {\n", + }, + { + name: "non-mode flag combined with mode", + profile: "profile foo /usr/bin/foo {\n", + flags: []string{"attach_disconnected", "complain"}, + want: "profile foo /usr/bin/foo flags=(attach_disconnected,complain) {\n", + }, + { + name: "only non-mode flags", + profile: "profile foo /usr/bin/foo {\n", + flags: []string{"attach_disconnected"}, + want: "profile foo /usr/bin/foo flags=(attach_disconnected) {\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ApplyFlags(tt.profile, tt.flags) + if (err != nil) != tt.wantErr { + t.Errorf("ApplyFlags() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ApplyFlags() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/dists/flags/main.flags b/share/apparmor/flags.d/00-main.conf similarity index 100% rename from dists/flags/main.flags rename to share/apparmor/flags.d/00-main.conf diff --git a/share/apparmor/flags.d/10-arch.conf b/share/apparmor/flags.d/10-arch.conf new file mode 100644 index 000000000..10dfaf958 --- /dev/null +++ b/share/apparmor/flags.d/10-arch.conf @@ -0,0 +1,7 @@ +# Profile modes for Arch based distributions +# File format: one profile mode by line using the format: ' ' + +aurpublish complain +makepkg complain +mkinitcpio complain +pacman complain diff --git a/dists/flags/debian.flags b/share/apparmor/flags.d/10-debian.conf similarity index 100% rename from dists/flags/debian.flags rename to share/apparmor/flags.d/10-debian.conf diff --git a/dists/flags/opensuse.flags b/share/apparmor/flags.d/10-opensuse.conf similarity index 100% rename from dists/flags/opensuse.flags rename to share/apparmor/flags.d/10-opensuse.conf diff --git a/dists/flags/ubuntu.flags b/share/apparmor/flags.d/10-ubuntu.conf similarity index 100% rename from dists/flags/ubuntu.flags rename to share/apparmor/flags.d/10-ubuntu.conf diff --git a/dists/ignore/main.ignore b/share/apparmor/ignore.d/00-main.conf similarity index 77% rename from dists/ignore/main.ignore rename to share/apparmor/ignore.d/00-main.conf index 44fda8f0a..5f51229b3 100644 --- a/dists/ignore/main.ignore +++ b/share/apparmor/ignore.d/00-main.conf @@ -5,16 +5,24 @@ # when built with 'just fsp' apparmor.d/groups/_full -# Experimental system states -apparmor.d/tunables/multiarch.d/state - # Provided by other packages man -# Work in progress profiles +# Breaks POLA apparmor.d/groups/code -apparmor.d/groups/cosmic -apparmor.d/groups/steam claude + +# Kernel panic +# podman +# conmon +# crun +# toolbox + +# Work in progress profiles +aa-autodep +aa-logprof +aa-mergeprof +aa-remove-unknown +apparmor.d/groups/steam dunst plasma-discover diff --git a/share/apparmor/ignore.d/10-arch.conf b/share/apparmor/ignore.d/10-arch.conf new file mode 100644 index 000000000..1179f3053 --- /dev/null +++ b/share/apparmor/ignore.d/10-arch.conf @@ -0,0 +1,2 @@ +# Ignore file for Arch based distributions +# File format: one ignore by line, it can be a profile name or a directory to ignore diff --git a/share/apparmor/ignore.d/10-debian.conf b/share/apparmor/ignore.d/10-debian.conf new file mode 100644 index 000000000..3fc8c07c8 --- /dev/null +++ b/share/apparmor/ignore.d/10-debian.conf @@ -0,0 +1,2 @@ +# Ignore file for Debian based distributions +# File format: one ignore by line, it can be a profile name or a directory to ignore diff --git a/share/apparmor/ignore.d/10-opensuse.conf b/share/apparmor/ignore.d/10-opensuse.conf new file mode 100644 index 000000000..b8857bb84 --- /dev/null +++ b/share/apparmor/ignore.d/10-opensuse.conf @@ -0,0 +1,2 @@ +# Ignore file for OpenSUSE based distributions +# File format: one ignore by line, it can be a profile name or a directory to ignore diff --git a/share/apparmor/ignore.d/10-ubuntu.conf b/share/apparmor/ignore.d/10-ubuntu.conf new file mode 100644 index 000000000..cb4ebcc61 --- /dev/null +++ b/share/apparmor/ignore.d/10-ubuntu.conf @@ -0,0 +1,2 @@ +# Ignore file for Ubuntu based distributions +# File format: one ignore by line, it can be a profile name or a directory to ignore diff --git a/share/apparmor/modes b/share/apparmor/modes new file mode 100644 index 000000000..45dbda580 --- /dev/null +++ b/share/apparmor/modes @@ -0,0 +1,2 @@ +default complain +include default diff --git a/dists/overwrite b/share/apparmor/overwrite.d/00-main.conf similarity index 56% rename from dists/overwrite rename to share/apparmor/overwrite.d/00-main.conf index efa2ac7ba..18c10350a 100644 --- a/dists/overwrite +++ b/share/apparmor/overwrite.d/00-main.conf @@ -1,5 +1,5 @@ # Apparmor 4.0 and over ships a few profiles that can conflict with apparmor.d -# This file keeps track of them and allow apparmor.d to replace them by our own. +# This file keeps track of them and allow aa-install to replace them by our own. # File format: one profile name by line. # Overwrite unconfined upstream profiles that only allow userns @@ -8,12 +8,16 @@ brave-browser-stable chrome chromium cockpit-desktop +code +crun +deezer-desktop element-desktop epiphany firefox flatpak foliate font-manager +lens-desktop loupe msedge mullvad @@ -21,8 +25,11 @@ nautilus opera os-prober plasmashell +podman +runc signal-desktop slirp4netns +steam systemd-coredump thunderbird virtiofsd @@ -30,14 +37,16 @@ virtiofsd # Overwrite upstreamed profiles, our local version may be more up to date unix-chkpwd -# Overwrite some profiles recently added in apparmor while being already present in apparmor.d for a while -# They can be multiple justification for keeping our profiles here, or or the contrary using upstream ones: -# - Keep ours: If we/they use abstractions, tunable, rules, and integration with apparmor.d that would break if using the upstream profile -# - Drop ours: when upstream profiles is better (see pkg/prebuild/prepare/configure.go) +# Overwrite some profiles present both in apparmor and apparmor.d +# They can be multiple justification for keeping our profiles here: +# - Keep ours: +# - If we/they use abstractions, tunable, rules, and integration with apparmor.d that would break if using the upstream profile +# - If upstream profile is based on apparmor.d version, apparmor.d version will be more up to date +# - When our profile is more restricted +# - Drop ours: when upstream profiles is better # # It is more and more common that these profiles simply present incompatibilies with apparmor.d in general. fusermount3 -hostname # Has @{bin} defined in header, conflict with apparmor.d's @{bin} tunables lsblk lsusb openvpn @@ -45,11 +54,24 @@ pollinate remmina systemd-detect-virt # Missing integration with @{p_systemd} transmission +usr.bin.man usr.bin.papers usr.bin.passt wg-quick who +wpa_supplicant # Cannot be used as libreoffice handle this path. Conflict with apparmor.d due to a missing abi version. usr.lib.libreoffice.program.soffice.bin +# apparmor.d version are way more restricted +usr.bin.firefox +usr.bin.swtpm +usr.bin.wsdd +usr.libexec.geoclue +usr.sbin.cups-browsed +usr.sbin.cupsd +usr.sbin.rsyslogd + +# Profile provided by the guestfs-tools package, has issues. +guestfs-tools diff --git a/share/bash-completion/completions/aa-install b/share/bash-completion/completions/aa-install new file mode 100644 index 000000000..c1509e14a --- /dev/null +++ b/share/bash-completion/completions/aa-install @@ -0,0 +1,29 @@ +# aa-install completion + +_aa-install() { + COMPREPLY=() + local cur="${COMP_WORDS[COMP_CWORD]}" + local args=( + -s --status + -l --list + -i --install + -a --all + -c --complain + -e --enforce + -u --uninstall + --no-reload + --config --magic --src + -h --help + ) + local lastarg="${COMP_WORDS[$COMP_CWORD-1]}" + case "$lastarg" in + --config | --magic | --src) + COMPREPLY+=($(compgen -o dirnames -A directory -- ${cur})) + ;; + *) + COMPREPLY+=($(compgen -W "${args[*]}" -- ${cur})) + ;; + esac +} + +complete -F _aa-install aa-install diff --git a/share/libalpm/hooks/apparmor.hook b/share/libalpm/hooks/apparmor.hook index 5d0dc2006..e60d87ce9 100644 --- a/share/libalpm/hooks/apparmor.hook +++ b/share/libalpm/hooks/apparmor.hook @@ -1,11 +1,11 @@ [Trigger] -Type = Path +Type = Package Operation = Install Operation = Upgrade Operation = Remove -Target = etc/apparmor.d/* +Target = * [Action] -Description = Reloading AppArmor service... +Description = Updating AppArmor profiles... When = PostTransaction -Exec = /usr/bin/systemctl reload apparmor.service \ No newline at end of file +Exec = /usr/bin/aa-install --install diff --git a/share/man/man1/aa-install.1 b/share/man/man1/aa-install.1 new file mode 100644 index 000000000..d4d7e31d6 --- /dev/null +++ b/share/man/man1/aa-install.1 @@ -0,0 +1,145 @@ +.\" Automatically generated by Pandoc 3.7.0.2 +.\" +.TH "aa\-install" "1" "July 2026" "" +.SH NAME +aa\-install \- Install and manage AppArmor profiles from apparmor.d. +.SH SYNOPSIS +\f[B]aa\-install\f[R] [\f[I]options\&...\f[R]] +[\f[B]\-s\f[R]|\f[B]\-l\f[R]|\f[B]\-i\f[R]|\f[B]\-u\f[R]] +[\f[B]\-a\f[R]] [\f[B]\-e\f[R]|\f[B]\-c\f[R]] +.SH DESCRIPTION +Install and manage the AppArmor profiles shipped by apparmor.d. +Profiles are built from a source directory, filtered and flagged +according to the configuration, then deployed to the AppArmor policy +directory and apparmor is reloaded. +.PP +Installed profiles are recorded in a manifest so that later runs can +report the installation status, list the deployed profiles, or uninstall +them. +.PP +With no action flag, print the installation status summary. +.SH OPTIONS +.TP +\f[CR]\-\-status\f[R], \f[CR]\-s\f[R] +Show the installation status summary. +This is the default action. +It reports how many profiles are recorded in the manifest, and how many +are up\-to\-date, drifted (modified since install), or missing. +.TP +\f[CR]\-\-list\f[R], \f[CR]\-l\f[R] +List the installed profile paths from the manifest, one per line, +sorted. +.TP +\f[CR]\-\-install\f[R], \f[CR]\-i\f[R] +Build and install the profiles, then reload them. +.TP +\f[CR]\-\-all\f[R], \f[CR]\-a\f[R] +Install all the profiles, including the ones of programs not installed +on the system. +.TP +\f[CR]\-\-complain\f[R], \f[CR]\-c\f[R] +Set the \f[B]complain\f[R] flag on all installed profiles. +Combined with an action, it is the default deploy mode. +Mutually exclusive with \f[B]\(enenforce\f[R]. +.TP +\f[CR]\-\-enforce\f[R], \f[CR]\-e\f[R] +Set the \f[B]enforce\f[R] flag on all installed profiles. +Combined with an action, it is the default deploy mode. +Mutually exclusive with \f[B]\(encomplain\f[R]. +.TP +\f[CR]\-\-uninstall\f[R], \f[CR]\-u\f[R] +Remove all profiles recorded in the manifest, then remove the manifest. +.TP +\f[CR]\-\-no\-reload\f[R] +Do not reload the profiles after modifying them. +.TP +\f[CR]\-\-config DIR\f[R] +Select an alternate configuration directory (default: +\f[CR]/etc/apparmor/\f[R]). +.TP +\f[CR]\-\-magic DIR\f[R] +Select an alternate apparmor.d policy directory (default: +\f[CR]/etc/apparmor.d\f[R]). +.TP +\f[CR]\-\-src DIR\f[R] +Select an alternate source directory (default: +\f[CR]/usr/share/apparmor.d\f[R]). +.TP +\f[CR]\-\-help\f[R], \f[CR]\-h\f[R] +Print the program usage. +.SH CONFIGURATION +Configuration is read from two tiers. +Vendor defaults in \f[CR]/usr/share/apparmor/\f[R] are read first, then +the local admin configuration in \f[CR]/etc/apparmor/\f[R] (or the +directory given by \f[B]\(enconfig\f[R]), which overrides the vendor +defaults. +.PP +Each tier may contain: +.TP +\f[I]modes\f[R] +General installation settings, one \f[CR]key value\f[R] per line. +The \f[CR]default\f[R] key sets the default deploy mode +(\f[B]enforce\f[R] or \f[B]complain\f[R]). +Overridden by \f[B]\(enenforce\f[R] or \f[B]\(encomplain\f[R]. +Defaults to \f[B]complain\f[R]. +The \f[CR]include\f[R] key sets how the \f[I]include.d\f[R] files are +applied: \f[B]default\f[R] or \f[B]full\f[R]. +.TP +\f[I]flags.d/*.conf\f[R] +Set per\-profile flags. +.TP +\f[I]ignore.d/*.conf\f[R] +Set the (groups of) profiles to ignore. +.TP +\f[I]include.d/*.conf\f[R] +List of (groups of) profiles to install. +With \f[CR]include default\f[R], the list is applied after +\f[I]ignore.d\f[R]: it re\-applies ignored profiles, and installs the +listed profiles even when their program is not installed. +With \f[CR]include full\f[R], only the listed profiles are installed and +all other profiles are excluded; the profiles always required by +apparmor.d are installed in both modes. +.TP +\f[I]overwrite.d/*.conf\f[R] +List of upstream profiles to disable and replace. +For each listed profile present on the target, the upstream profile is +disabled with a \f[I]disable/\f[R] link and the apparmor.d profile (if +any) is installed under the \f[I].apparmor.d\f[R] name. +Profiles the target does not ship are left untouched. +.SH USAGE +To install all profiles in the default (complain) mode: +.IP +.EX +aa\-install \-\-install +.EE +.PP +To install and enforce all profiles: +.IP +.EX +aa\-install \-\-install \-\-enforce +.EE +.PP +To show the installation status: +.IP +.EX +aa\-install +.EE +.PP +To list the installed profiles: +.IP +.EX +aa\-install \-\-list +.EE +.PP +To remove all installed profiles: +.IP +.EX +aa\-install \-\-uninstall +.EE +.SH SEE ALSO +\f[CR]apparmor_parser(8)\f[R], \f[CR]apparmor(7)\f[R], +\f[CR]apparmor.d(5)\f[R], \f[CR]aa\-log(1)\f[R], \f[CR]aa\-mode(1)\f[R], +\f[CR]aa\-enforce(1)\f[R], \f[CR]aa\-complain(1)\f[R], and +https://apparmor.pujol.io. +.SH AUTHORS +aa\-install was written by Alexandre Pujol (alexandre\(atpujol.io). diff --git a/share/man/man1/aa-install.md b/share/man/man1/aa-install.md new file mode 100644 index 000000000..3ff98e692 --- /dev/null +++ b/share/man/man1/aa-install.md @@ -0,0 +1,150 @@ +% aa-install(1) +% aa-install was written by Alexandre Pujol (alexandre@pujol.io) +% July 2026 + +# NAME + +aa-install - Install and manage AppArmor profiles from apparmor.d. + +# SYNOPSIS + +**aa-install** [*options...*] [**-s**|**-l**|**-i**|**-u**] [**-a**] [**-e**|**-c**] + +# DESCRIPTION + +Install and manage the AppArmor profiles shipped by apparmor.d. Profiles are +built from a source directory, filtered and flagged according to the +configuration, then deployed to the AppArmor policy directory and apparmor is +reloaded. + +Installed profiles are recorded in a manifest so that later runs can report the +installation status, list the deployed profiles, or uninstall them. + +With no action flag, print the installation status summary. + +# OPTIONS + +`--status`, `-s` + +: Show the installation status summary. This is the default action. It reports + how many profiles are recorded in the manifest, and how many are up-to-date, + drifted (modified since install), or missing. + +`--list`, `-l` + +: List the installed profile paths from the manifest, one per line, sorted. + +`--install`, `-i` + +: Build and install the profiles, then reload them. + +`--all`, `-a` + +: Install all the profiles, including the ones of programs not installed on + the system. + +`--complain`, `-c` + +: Set the **complain** flag on all installed profiles. Combined with an action, + it is the default deploy mode. Mutually exclusive with **--enforce**. + +`--enforce`, `-e` + +: Set the **enforce** flag on all installed profiles. Combined with an action, + it is the default deploy mode. Mutually exclusive with **--complain**. + +`--uninstall`, `-u` + +: Remove all profiles recorded in the manifest, then remove the manifest. + +`--no-reload` + +: Do not reload the profiles after modifying them. + +`--config DIR` + +: Select an alternate configuration directory (default: `/etc/apparmor/`). + +`--magic DIR` + +: Select an alternate apparmor.d policy directory (default: `/etc/apparmor.d`). + +`--src DIR` + +: Select an alternate source directory (default: `/usr/share/apparmor.d`). + +`--help`, `-h` + +: Print the program usage. + +# CONFIGURATION + +Configuration is read from two tiers. Vendor defaults in `/usr/share/apparmor/` +are read first, then the local admin configuration in `/etc/apparmor/` (or the +directory given by **--config**), which overrides the vendor defaults. + +Each tier may contain: + +*modes* + +: General installation settings, one `key value` per line. The `default` key + sets the default deploy mode (**enforce** or **complain**). Overridden by + **--enforce** or **--complain**. Defaults to **complain**. The `include` + key sets how the *include.d* files are applied: **default** or **full**. + +*flags.d/\*.conf* + +: Set per-profile flags. + +*ignore.d/\*.conf* + +: Set the (groups of) profiles to ignore. + +*include.d/\*.conf* + +: List of (groups of) profiles to install. With `include default`, the list + is applied after *ignore.d*: it re-applies ignored profiles, and installs + the listed profiles even when their program is not installed. With + `include full`, only the listed profiles are installed and all other + profiles are excluded; the profiles always required by apparmor.d are + installed in both modes. + +*overwrite.d/\*.conf* + +: List of upstream profiles to disable and replace. For each listed profile + present on the target, the upstream profile is disabled with a + *disable/* link and the apparmor.d profile (if any) is installed under + the *\.apparmor.d* name. Profiles the target does not ship are + left untouched. + +# USAGE + +To install all profiles in the default (complain) mode: +```sh +aa-install --install +``` + +To install and enforce all profiles: +```sh +aa-install --install --enforce +``` + +To show the installation status: +```sh +aa-install +``` + +To list the installed profiles: +```sh +aa-install --list +``` + +To remove all installed profiles: +```sh +aa-install --uninstall +``` + +# SEE ALSO + +`apparmor_parser(8)`, `apparmor(7)`, `apparmor.d(5)`, `aa-log(1)`, `aa-mode(1)`, `aa-enforce(1)`, `aa-complain(1)`, and +https://apparmor.pujol.io. diff --git a/share/man/man1/aa-mode.1 b/share/man/man1/aa-mode.1 index 5ec024e55..806bfb257 100644 --- a/share/man/man1/aa-mode.1 +++ b/share/man/man1/aa-mode.1 @@ -1,6 +1,6 @@ -.\" Automatically generated by Pandoc 3.1.11.1 +.\" Automatically generated by Pandoc 3.6.1 .\" -.TH "aa\-mode" "1" "April 2026" "" "" +.TH "aa\-mode" "1" "April 2026" "" .SH NAME aa\-mode \- Switch AppArmor profiles mode. .SH SYNOPSIS @@ -66,7 +66,7 @@ aa\-mode \-\-complain \-\-no\-reload /etc/apparmor.d/ .EE .SH SEE ALSO \f[CR]apparmor_parser(8)\f[R], \f[CR]apparmor(7)\f[R], -\f[CR]apparmor.d(5)\f[R],\f[CR]aa\-log(1)\f[R], +\f[CR]apparmor.d(5)\f[R], \f[CR]aa\-log(1)\f[R], \f[CR]aa\-enforce(1)\f[R], \f[CR]aa\-complain(1)\f[R], and https://apparmor.pujol.io. .SH AUTHORS diff --git a/share/man/man8/aa-log.8 b/share/man/man8/aa-log.8 index f19f669dc..6c5ffe05b 100644 --- a/share/man/man8/aa-log.8 +++ b/share/man/man8/aa-log.8 @@ -1,4 +1,4 @@ -.\" Automatically generated by Pandoc 3.5 +.\" Automatically generated by Pandoc 3.6.1 .\" .TH "aa\-log" "8" "December 2025" "" .SH NAME @@ -6,14 +6,14 @@ aa\-log \- Review AppArmor generated messages in a colorful way. .SH SYNOPSIS \f[B]aa\-log\f[R] [\f[I]options\&...\f[R]] [\f[I]profile\f[R]] .SH DESCRIPTION -Review AppArmor generated messages in a colourful way. +Review AppArmor generated messages in a colorful way. Support logs from \f[I]auditd\f[R], \f[I]systemd\f[R], \f[I]syslog\f[R] as well as \f[I]dbus session\f[R] events. .PP It can be given an optional profile name to filter the output with. .PP -It can be used to generate AppArmor rules from the logs and it therefore -an alternative to \f[CR]aa\-logprof(8)\f[R]. +It can be used to generate AppArmor rules from the logs and it is +therefore an alternative to \f[CR]aa\-logprof(8)\f[R]. The generated rules should be manually reviewed and inserted into the profile. .PP diff --git a/share/zsh/site-functions/_aa-install.zsh b/share/zsh/site-functions/_aa-install.zsh new file mode 100644 index 000000000..51eab5357 --- /dev/null +++ b/share/zsh/site-functions/_aa-install.zsh @@ -0,0 +1,21 @@ +#compdef aa-install +#autoload + +_aa-install() { + local IFS=$'\n' + _arguments : \ + {-s,--status}'[show the installation status summary]' \ + {-l,--list}'[list installed profile paths from the manifest]' \ + {-i,--install}'[install the profiles]' \ + {-a,--all}'[install all profiles, even for programs not installed]' \ + {-c,--complain}'[set complain flag on all the profiles]' \ + {-e,--enforce}'[set enforce flag on all the profiles]' \ + {-u,--uninstall}'[remove all profiles installed]' \ + '--no-reload[do not reload the profiles after modifying them]' \ + '--config[select an alternate configuration directory]:DIR:_files -/' \ + '--magic[select an alternate apparmor.d directory]:DIR:_files -/' \ + '--src[select an alternate source directory]:DIR:_files -/' \ + {-h,--help}'[display help information]' +} + +_aa-install