Apparmor profile for Qubes available!

So after many test and reading documentation about the syntax for apparmor i finally create some apparmor profiles specially for Qubes to improve the security, privacy and anonymity. dkzkz/apparmor-qubes: Apparmor profile for debian template should work on whonix and kicksecure template - Codeberg.org

There is a bug in the apparmor package shipped by debian on the stable version i opened a issue about that Apparmor 4.1.0-1 in Qubes OS Profile does not conform to protocol error (#592) · Issues · AppArmor / apparmor · GitLab this bug can lead to security issue according to the dev. And it will not be fixed soon by debian so we will use the unstable version of apparmor to get the last fix and features. I will try to ask the Qubes dev to make every debian templat use the unstable version of apparmor in the future instead of the stable version

I didn’t get any issue by using the unstable version it works far better than the stable version.

What’s the main advantage of using those apparmor profiles ?”

:white_check_mark: By default every apps who reach internet can’t read the machine-id of the users so it should spoof the fact you’re using Qubes but they can detect that in a other way
:white_check_mark: By default every apps can’t access to the filesystem of the users (except file-manager because they do not have internet access i removed that so it’s ok to let them access this i think)
:white_check_mark: By default apps can’t read and write in every home directories including QubesIncoming they can only access to the Downloads folder
:white_check_mark: By default you will NOT be able to use extension like keepassxc or bitwarden and a lot of others extensions in the Brave-browser and probably the other browser too because those extension need to use /bin command in order to work correctly and i didn’t add those commands. ublock is working fine. Why i’ve done that ? Because EVERY extensions are insecure read this post and also this one if you take a look at the Mullvad-Browser they removed the abilitie to easily install extensions in their Browser.

  1. Start your debian template
  2. I recommend to use the kicksecure repository to get their apparmor package or you could also use the kicksecure template on Qubes
  3. Now do those commands
 # 1. Add the unstable repository 

sudo sh -c 'cat <<EOF >>/etc/apt/sources.list
deb https://deb.debian.org/debian/ unstable main
EOF'

# 2. Create the pinning file – high priority for the AppArmor packages,
#    very low priority for everything else from the unstable suite
sudo sh -c 'cat <<EOF >/etc/apt/preferences.d/unstable-pin
# ---- AppArmor packages (high priority) ----
Package: apparm* python*-appar* python*-libappar*
Pin: release a=unstable
Pin-Priority: 990

# ---- All other packages from unstable (very low priority) ----
Package: *
Pin: release a=unstable
Pin-Priority: 1
EOF'

# 3. Refresh the package indexes
sudo apt update

# 4. Install (or upgrade) the AppArmor packages from the unstable repository
sudo apt install -y apparmor apparmor-utils apparmor-profiles apparmor-profiles-extra

The command earlier will setup the unstable debian repository and it will install only the package needed to run apparmor.

 echo "Types: deb
URIs: https://deb.kicksecure.com
Suites: trixie
Components: main contrib non-free
Enabled: yes
Signed-By: /usr/share/keyrings/derivative.asc" | sudo tee /etc/apt/sources.list.d/derivative.sources

To get the apparmor profiles from kicksecure

echo "Types: deb
URIs: tor+https://deb.kicksecure.com
Suites: trixie
Components: main contrib non-free
Enabled: yes
Signed-By: /usr/share/keyrings/derivative.asc" | sudo tee /etc/apt/sources.list.d/derivative.sources

You could use their onion repository here but updating packages in the future will be very slow.

By using the tor repository you will need sys-whonix or the tor package installed in your system to install their packages.

  1. Shutdown the template

In order to make apparmor works correctly in Qubes you need to run this commands in dom0 :

qvm-prefs x kernelopts "swiotlb=2048 security=apparmor"

Replace “x” by the name of your template

  1. Start your templateVM open a terminal and confirm apparmor is running by doing as root
sudo aa-enabled

It must say “Yes” in that case you can continue the guide otherwise you have done something wrong.

  1. I provide a python3 GUI script to automatically install the apparmor profiles from my repository you have the choice betweeen a local installation or a internet installation you will need “git” installed on you system if you pick the internet script. Or you can also unpack the release tar.gz file and do it manually here

  2. Do

 sudo apt-get -y install git
installation.py

#!/usr/bin/env python3

import os
import sys
import subprocess
import shutil
import pathlib
import getpass
from tkinter import (
    Tk, Toplevel, StringVar, BooleanVar, Listbox, Scrollbar,
    Entry, Frame, messagebox
)
from tkinter import ttk

# -------------------------------------------------
# Configuration (mirrors the Bash variables)
# -------------------------------------------------
BASE_DIR = pathlib.Path("/home/user")
REPO_URL = "https://codeberg.org/dkzkz/apparmor-qubes"
REPO_DIR = BASE_DIR / "repo"

TARGET_DIRS = [
    "stable", "browser", "email", "xdg-open", "metadata", "multimedia",
    "password-manager", "selfhost", "utilities", "com",
    "torrent-gui", "display-vm", "qubes-scripts",
    "file-manager", "monero"
]

SKIP_EXT = {"md", "py" "sh", "png", "jpeg", "jpg"}
SKIP_PATTERNS = {"*.sample", "*.bak", "README*"}
SKIP_DIRS = {"install", "beta-profile", "screenshot"}

HIDE = {""}
NO_CHOICE = {"open"}
AUTO_VLC = {"vlc", "vlc-network"}

# -------------------------------------------------
# Global reference to the Tk root (used for abort)
# -------------------------------------------------
ROOT = None          # will be set in __main__

# -------------------------------------------------
# Helper utilities
# -------------------------------------------------
def run_cmd(cmd, check=False, capture=False, env=None):
    """Thin wrapper around subprocess.run."""
    result = subprocess.run(
        cmd,
        shell=True,
        check=check,
        stdout=subprocess.PIPE if capture else None,
        stderr=subprocess.PIPE if capture else None,
        env=env,
    )
    if capture:
        return result.stdout.decode().strip()
    return None


def sudo_check():
    """Abort if the script is not run via sudo."""
    if os.geteuid() != 0:
        messagebox.showerror(
            "Permission Denied",
            "This script must be run with sudo privileges."
        )
        sys.exit(1)


def get_x11_info():
    """Determine the X‑user, DISPLAY and XAUTHORITY."""
    try:
        xuser = run_cmd(
            "who | awk '{ if ($2 ~ /^:/) { print $1; exit } }'",
            capture=True,
        )
    except Exception:
        xuser = ""

    if not xuser:
        try:
            xuser = run_cmd(
                "getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 { print $1; exit }'",
                capture=True,
            )
        except Exception:
            xuser = ""

    if not xuser:
        xuser = os.getenv("SUDO_USER") or getpass.getuser()

    uhome = pathlib.Path(os.path.expanduser(f"~{xuser}"))
    display = run_cmd(
        "ps e | grep -Pz 'DISPLAY=:[0-9]+' | grep -oP 'DISPLAY=\\K:[0-9]+' | head -n1",
        capture=True,
    )
    display = display or ":0"
    xauth = uhome / ".Xauthority"
    return xuser, uhome, display, xauth


def run_as_user(cmd, xuser, display, xauth):
    """Execute a command as the X‑user with proper env."""
    env = os.environ.copy()
    env.update({"DISPLAY": display, "XAUTHORITY": str(xauth)})
    return run_cmd(f"sudo -u {xuser} {cmd}", env=env)


def skip_file(path: pathlib.Path) -> bool:
    """Return True if the file should be ignored."""
    name = path.name
    if name in HIDE:
        return True
    if name.startswith("."):
        return True
    if path.suffix.lstrip(".") in SKIP_EXT:
        return True
    for pat in SKIP_PATTERNS:
        if pathlib.Path(name).match(pat):
            return True
    return False


def purge_vlc_profiles(root: Tk):
    """Ask user and delete any /etc/apparmor.d/vlc* files."""
    if not messagebox.askyesno(
        "Purge VLC Profiles",
        "This will remove all existing VLC‑related profiles. Continue?",
        parent=root,
    ):
        return
    for f in pathlib.Path("/etc/apparmor.d").glob("vlc*"):
        try:
            f.unlink()
        except PermissionError:
            pass
    messagebox.showinfo("VLC Profiles", "All VLC profiles have been purged.", parent=root)


def enforce_profile(src_file: pathlib.Path, enforce_cmd: str, root: Tk):
    """Copy the profile to /etc/apparmor.d and enforce it with a progress bar."""
    fname = src_file.name

    if fname in AUTO_VLC:
        purge_vlc_profiles(root)

    dst = pathlib.Path("/etc/apparmor.d") / fname

    # ----- move the file (still running as root) -----
    try:
        if dst.exists():
            dst.unlink()
        shutil.move(str(src_file), str(dst))
    except Exception as e:
        messagebox.showerror(
            "Enforce error",
            f"Failed to move {src_file} → {dst}:\n{e}",
            parent=root,
        )
        return

    # ----- progress window (no OK button) -----
    prog_win = Toplevel(root)
    prog_win.title("Enforcing Profile")
    prog_win.resizable(False, False)

    ttk.Label(prog_win, text=f"Enforcing {fname}…").pack(padx=10, pady=(10, 5))

    bar = ttk.Progressbar(prog_win, mode="indeterminate", length=300)
    bar.pack(padx=10, pady=5)
    bar.start(10)

    prog_win.update()

    # enforce **as root**
    run_cmd(f"sudo {enforce_cmd} {dst}", check=False)

    bar.stop()
    prog_win.destroy()


def abort_installation():
    """Remove cloned repo (if any), close the GUI, and exit."""
    if REPO_DIR.exists():
        shutil.rmtree(REPO_DIR)

    global ROOT
    if ROOT is not None:
        try:
            ROOT.destroy()
        except Exception:
            pass

    sys.exit(0)


class ModernApp:
    def __init__(self, master):
        self.master = master
        master.title("AppArmor Installer")
        master.geometry("460x180")
        master.resizable(False, False)

        ttk.Style().theme_use("clam")
        ttk.Label(master, text="AppArmor profile installer",
                  font=("Helvetica", 14)).pack(pady=12)

        master.after(100, self.start)

    # -------------------------------------------------
    # 1️⃣  Template‑mode dialog
    # -------------------------------------------------
    def ask_template_mode(self):
        dlg = Toplevel(self.master)
        dlg.title("Template Check")
        dlg.resizable(False, False)

        ttk.Label(dlg, text="Is this script running in a template?").pack(padx=20, pady=10)

        answer = BooleanVar(value=False)

        def set_yes():
            answer.set(True)
            dlg.destroy()

        def set_no():
            answer.set(False)
            dlg.destroy()

        btns = Frame(dlg)
        btns.pack(pady=8)
        ttk.Button(btns, text="Yes", command=set_yes).grid(row=0, column=0, padx=5)
        ttk.Button(btns, text="No", command=set_no).grid(row=0, column=1, padx=5)

        # Cancel → abort the whole installation
        ttk.Button(
            btns,
            text="Cancel",
            command=lambda: (dlg.destroy(), abort_installation()),
        ).grid(row=0, column=2, padx=5)

        dlg.wait_window()
        return answer.get()

    # -------------------------------------------------
    # 2️⃣  Enforcement‑method dialog
    # -------------------------------------------------
    def choose_enforce_method(self):
        dlg = Toplevel(self.master)
        dlg.title("Enforcement method")
        dlg.resizable(False, False)

        ttk.Label(dlg, text="Select the enforcement command:").pack(padx=20, pady=10)

        choice = StringVar()
        combo = ttk.Combobox(
            dlg,
            textvariable=choice,
            state="readonly",
            values=[
                "aa-enforce (default)",
                "apparmor_parser -r (loads profile in the kernel)",
            ],
        )
        combo.current(0)
        combo.pack(padx=20, pady=5)

        btn_frame = Frame(dlg)
        btn_frame.pack(pady=10)

        ttk.Button(btn_frame, text="OK", command=dlg.destroy).grid(row=0, column=0, padx=5)

        # Cancel → abort the whole installation
        ttk.Button(
            btn_frame,
            text="Cancel",
            command=lambda: (dlg.destroy(), abort_installation()),
        ).grid(row=0, column=1, padx=5)

        dlg.wait_window()

        method = "apparmor_parser -r" if "parser" in choice.get() else "aa-enforce"
        if method == "apparmor_parser -r":
            messagebox.showwarning(
                "Removal Warning",
                "DO NOT remove a profile by using \"rm -f /etc/apparmor.d/x\".\n"
                "You must use \"sudo apparmor_parser -R /etc/apparmor.d/x\" before removing a profile.",
                parent=self.master,
            )
        return method

    # -------------------------------------------------
    # 3️⃣  Profile‑selection dialog
    # -------------------------------------------------
    def select_profiles_dialog(self, top, files):
        """
        Returns:
            None   – user cancelled → abort installation
            "ALL"  – user pressed “Select ALL displayed”
            set()  – indices of chosen files
        """
        dlg = Toplevel(self.master)
        dlg.title(f"Profiles in {top}")
        dlg.geometry("820x560")
        dlg.resizable(True, True)

        search_var = StringVar()
        ttk.Label(dlg, text="Filter profiles:").pack(anchor="w", padx=10, pady=(10, 0))
        ttk.Entry(dlg, textvariable=search_var).pack(fill="x", padx=10, pady=5)

        frame = Frame(dlg)
        frame.pack(fill="both", expand=True, padx=10, pady=5)

        sb = Scrollbar(frame)
        sb.pack(side="right", fill="y")

        lb = Listbox(frame, selectmode="extended", yscrollcommand=sb.set,
                     font=("Helvetica", 10))
        lb.pack(fill="both", expand=True)
        sb.config(command=lb.yview)

        select_all_var = BooleanVar(value=False)

        def toggle_select_all():
            if select_all_var.get():
                lb.select_set(0, "end")
            else:
                lb.select_clear(0, "end")

        ttk.Checkbutton(
            dlg,
            text="Select ALL displayed",
            variable=select_all_var,
            command=toggle_select_all,
        ).pack(pady=5)

        def refresh_list(*_):
            term = search_var.get().lower()
            lb.delete(0, "end")
            for idx, p in enumerate(files):
                name = p.name
                if not term or term in name.lower():
                    lb.insert("end", f"{idx+1}. {name}")

        search_var.trace_add("write", refresh_list)
        refresh_list()

        result = {"value": None}

        def ok():
            sel = {
                int(lb.get(i).split(".", 1)[0]) - 1
                for i in lb.curselection()
            }
            result["value"] = "ALL" if select_all_var.get() else sel
            dlg.destroy()

        def cancel():
            dlg.destroy()
            abort_installation()          # abort instead of just returning

        btns = Frame(dlg)
        btns.pack(pady=10)
        ttk.Button(btns, text="OK", command=ok).grid(row=0, column=0, padx=8)
        ttk.Button(btns, text="Cancel", command=cancel).grid(row=0, column=1, padx=8)

        dlg.wait_window()
        return result["value"]

    # -------------------------------------------------
    # 4️⃣  Process a single top‑level directory
    # -------------------------------------------------
    def process_directory(self, top, enforce_cmd):
        src_dir = REPO_DIR / top
        if not src_dir.is_dir():
            return

        candidates = sorted(p for p in src_dir.rglob("*") if p.is_file())
        filtered = [p for p in candidates if not skip_file(p)]

        if not filtered:
            messagebox.showinfo(
                "No Profiles",
                f"No enforceable files in {top} – skipping.",
                parent=self.master,
            )
            return

        remaining = []
        for f in filtered:
            if f.name in NO_CHOICE:
                enforce_profile(f, enforce_cmd, self.master)
            else:
                remaining.append(f)

        if not remaining:
            messagebox.showinfo(
                "Auto Enforcement",
                f"All profiles in {top} were automatically enforced – moving to next directory.",
                parent=self.master,
            )
            return

        while remaining:
            choice = self.select_profiles_dialog(top, remaining)

            # If the user hit Cancel, `select_profiles_dialog` already called
            # abort_installation(), so execution never reaches here.
            if choice is None:          # safety net
                abort_installation()

            if choice == "ALL":
                for p in remaining:
                    enforce_profile(p, enforce_cmd, self.master)
                remaining.clear()
                break

            selected_files = [remaining[i] for i in choice]

            # Special handling for nautilus/thunar and prefix‑matching
            extra = []
            for src in selected_files:
                if src.name.lower() in {"nautilus", "thunar"}:
                    extra.extend([p for p in remaining if p.name.startswith("qubes")])
                else:
                    prefix = src.name.split("-")[0]
                    extra.extend(
                        [p for p in remaining
                         if p.name.startswith(prefix) and p not in selected_files]
                    )
            to_enforce = list(dict.fromkeys(selected_files + extra))

            for p in to_enforce:
                enforce_profile(p, enforce_cmd, self.master)
                if p in remaining:
                    remaining.remove(p)

            if not remaining:
                messagebox.showinfo(
                    "Profiles Enforced",
                    f"All profiles in {top} have been enforced – moving to next directory.",
                    parent=self.master,
                )
                break

    # -------------------------------------------------
    # 5️⃣  Main workflow
    # -------------------------------------------------
    def start(self):
        if self.ask_template_mode():
            os.environ["all_proxy"] = "http://127.0.0.1:8082/"

        global XUSER, UHOME, DISPLAY, XAUTHORITY
        XUSER, UHOME, DISPLAY, XAUTHORITY = get_x11_info()
        if not XUSER or not DISPLAY:
            messagebox.showerror(
                "X11 Error",
                "Unable to determine X11 display or user. Cannot continue.",
                parent=self.master,
            )
            sys.exit(1)

        # add Xauthority entry (ignore errors)
        run_as_user(
            f"xauth add $(sudo -u {XUSER} xauth -f {XAUTHORITY} list | tail -1)",
            XUSER,
            DISPLAY,
            XAUTHORITY,
        )

        enforce_cmd = self.choose_enforce_method()

        # ----- clean previous clone if present -----
        if REPO_DIR.exists():
            shutil.rmtree(REPO_DIR)

        # ----- clone repo with progress bar -----
        prog = Toplevel(self.master)
        prog.title("Cloning repository")
        prog.resizable(False, False)
        ttk.Label(prog, text="Cloning repository…").pack(padx=20, pady=10)
        bar = ttk.Progressbar(prog, mode="indeterminate", length=300)
        bar.pack(padx=20, pady=10)
        bar.start(10)
        prog.update()
        try:
            run_cmd(f"git clone {REPO_URL} {REPO_DIR}", check=True)
        except subprocess.CalledProcessError as e:
            prog.destroy()
            messagebox.showerror(
                "Clone failed",
                f"Git clone failed:\n{e}",
                parent=self.master,
            )
            abort_installation()
        bar.stop()
        prog.destroy()

        # ----- clean up possible conflicts -----
        for usr_bin in pathlib.Path("/etc/apparmor.d").glob("usr.bin.*"):
            plain = usr_bin.name.split(".", 2)[-1]
            plain_path = pathlib.Path("/etc/apparmor.d") / plain
            if plain_path.is_file():
                usr_bin.unlink()

        # ----- process each directory -----
        for top in TARGET_DIRS:
            self.process_directory(top, enforce_cmd)

        # ----- final clean‑up -----
        if REPO_DIR.exists():
            shutil.rmtree(REPO_DIR)

        messagebox.showinfo(
            "Installation Complete",
            "All selected profiles have been installed and enforced.",
            parent=self.master,
        )
        self.master.destroy()


if __name__ == "__main__":
    sudo_check()
    root = Tk()
    root.withdraw()          # hide the empty root window; dialogs are modal
    ROOT = root               # store global reference for abort_installation()
    ModernApp(root)
    root.mainloop()
localinstallation.py
#!/usr/bin/env python3


import os
import sys
import subprocess
import shutil
import pathlib
import getpass
from tkinter import (
    Tk, Toplevel, StringVar, BooleanVar, Listbox, Scrollbar,
    Entry, Frame, messagebox
)
from tkinter import ttk

SCRIPT_DIR = pathlib.Path(__file__).resolve().parent   # directory that contains this script
DIR_PREFIX = "apparmor"                                 # look for dirs named/starting with this
TARGET_DIRS = []                                        # will hold the found directories

SKIP_EXT = {"md", "py", "sh", "png", "jpeg", "jpg"}
SKIP_PATTERNS = {"*.sample", "*.bak", "README*"}
SKIP_DIRS = {"install", "beta-profile", "screenshot"}

HIDE = {""}
NO_CHOICE = {"open"}
AUTO_VLC = {"vlc", "vlc-network"}

ROOT = None  # global reference for abort_installation()


def run_cmd(cmd, check=False, capture=False, env=None):
    """Thin wrapper around subprocess.run."""
    result = subprocess.run(
        cmd,
        shell=True,
        check=check,
        stdout=subprocess.PIPE if capture else None,
        stderr=subprocess.PIPE if capture else None,
        env=env,
    )
    if capture:
        return result.stdout.decode().strip()
    return None


def sudo_check():
    """Abort if the script is not run via sudo."""
    if os.geteuid() != 0:
        messagebox.showerror(
            "Permission denied",
            "This script must be run with sudo privileges."
        )
        sys.exit(1)


def get_x11_info():
    """Determine the X‑user, DISPLAY and XAUTHORITY."""
    try:
        xuser = run_cmd(
            "who | awk '{ if ($2 ~ /^:/) { print $1; exit } }'",
            capture=True,
        )
    except Exception:
        xuser = ""

    if not xuser:
        try:
            xuser = run_cmd(
                "getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 { print $1; exit }'",
                capture=True,
            )
        except Exception:
            xuser = ""

    if not xuser:
        xuser = os.getenv("SUDO_USER") or getpass.getuser()

    uhome = pathlib.Path(os.path.expanduser(f"~{xuser}"))
    display = run_cmd(
        "ps e | grep -Pz 'DISPLAY=:[0-9]+' | grep -oP 'DISPLAY=\\K:[0-9]+' | head -n1",
        capture=True,
    )
    display = display or ":0"
    xauth = uhome / ".Xauthority"
    return xuser, uhome, display, xauth


def run_as_user(cmd, xuser, display, xauth):
    """Execute a command as the X‑user with proper env."""
    env = os.environ.copy()
    env.update({"DISPLAY": display, "XAUTHORITY": str(xauth)})
    return run_cmd(f"sudo -u {xuser} {cmd}", env=env)


def skip_file(path: pathlib.Path) -> bool:
    """Return True if the file should be ignored."""
    name = path.name
    if name in HIDE:
        return True
    if name.startswith("."):
        return True
    if path.suffix.lstrip(".") in SKIP_EXT:
        return True
    for pat in SKIP_PATTERNS:
        if pathlib.Path(name).match(pat):
            return True
    return False


def purge_vlc_profiles(root: Tk):
    """Ask user and delete any /etc/apparmor.d/vlc* files."""
    if not messagebox.askyesno(
        "Purge VLC Profiles",
        "This will remove all existing VLC‑related profiles. Continue?",
        parent=root,
    ):
        return
    for f in pathlib.Path("/etc/apparmor.d").glob("vlc*"):
        try:
            f.unlink()
        except PermissionError:
            pass
    messagebox.showinfo("VLC Profiles", "All VLC profiles have been purged.", parent=root)


def enforce_profile(src_file: pathlib.Path, enforce_cmd: str, root: Tk):
    """Copy the profile to /etc/apparmor.d and enforce it with a progress bar."""
    fname = src_file.name

    if fname in AUTO_VLC:
        purge_vlc_profiles(root)

    dst = pathlib.Path("/etc/apparmor.d") / fname

    try:
        if dst.exists():
            dst.unlink()
        shutil.move(str(src_file), str(dst))
    except Exception as e:
        messagebox.showerror(
            "Enforce error",
            f"Failed to move {src_file} → {dst}:\n{e}",
            parent=root,
        )
        return

    prog_win = Toplevel(root)
    prog_win.title("Enforcing Profile")
    prog_win.resizable(False, False)

    ttk.Label(prog_win, text=f"Enforcing {fname}…").pack(padx=10, pady=(10, 5))

    bar = ttk.Progressbar(prog_win, mode="indeterminate", length=300)
    bar.pack(padx=10, pady=5)
    bar.start(10)

    prog_win.update()
    run_cmd(f"sudo {enforce_cmd} {dst}", check=False)
    bar.stop()
    prog_win.destroy()


def clean_apparmor_dirs():
    """Delete every directory that was added to TARGET_DIRS."""
    for d in TARGET_DIRS:
        try:
            shutil.rmtree(d)
        except Exception:
            pass
    TARGET_DIRS.clear()


def abort_installation():
    """Close the GUI, clean up, and exit."""
    clean_apparmor_dirs()
    global ROOT
    if ROOT is not None:
        try:
            ROOT.destroy()
        except Exception:
            pass
    sys.exit(0)


class ModernApp:
    def __init__(self, master):
        self.master = master
        master.title("AppArmor Installer")
        master.geometry("460x180")
        master.resizable(False, False)

        ttk.Style().theme_use("clam")
        ttk.Label(master, text="AppArmor profile installer",
                  font=("Helvetica", 14)).pack(pady=12)

        master.after(100, self.start)

    def choose_enforce_method(self):
        dlg = Toplevel(self.master)
        dlg.title("Enforcement method")
        dlg.resizable(False, False)

        ttk.Label(dlg, text="Select the enforcement command:").pack(padx=20, pady=10)

        choice = StringVar()
        combo = ttk.Combobox(
            dlg,
            textvariable=choice,
            state="readonly",
            values=[
                "aa-enforce (default)",
                "apparmor_parser -r (loads profile in the kernel)",
            ],
        )
        combo.current(0)
        combo.pack(padx=20, pady=5)

        btn_frame = Frame(dlg)
        btn_frame.pack(pady=10)

        ttk.Button(btn_frame, text="OK", command=dlg.destroy).grid(row=0, column=0, padx=5)

        ttk.Button(
            btn_frame,
            text="Cancel",
            command=lambda: (dlg.destroy(), abort_installation()),
        ).grid(row=0, column=1, padx=5)

        dlg.wait_window()

        method = "apparmor_parser -r" if "parser" in choice.get() else "aa-enforce"
        if method == "apparmor_parser -r":
            messagebox.showwarning(
                "Removal Warning",
                "DO NOT remove a profile by using \"rm -f /etc/apparmor.d/x\".\n"
                "You must use \"sudo apparmor_parser -R /etc/apparmor.d/x\" before removing a profile.",
                parent=self.master,
            )
        return method

    def select_profiles_dialog(self, top, files):
        dlg = Toplevel(self.master)
        dlg.title(f"Profiles in {top}")
        dlg.geometry("820x560")
        dlg.resizable(True, True)

        search_var = StringVar()
        ttk.Label(dlg, text="Filter profiles:").pack(anchor="w", padx=10, pady=(10, 0))
        ttk.Entry(dlg, textvariable=search_var).pack(fill="x", padx=10, pady=5)

        frame = Frame(dlg)
        frame.pack(fill="both", expand=True, padx=10, pady=5)

        sb = Scrollbar(frame)
        sb.pack(side="right", fill="y")

        lb = Listbox(frame, selectmode="extended", yscrollcommand=sb.set,
                     font=("Helvetica", 10))
        lb.pack(fill="both", expand=True)
        sb.config(command=lb.yview)

        select_all_var = BooleanVar(value=False)

        def toggle_select_all():
            if select_all_var.get():
                lb.select_set(0, "end")
            else:
                lb.select_clear(0, "end")

        ttk.Checkbutton(
            dlg,
            text="Select ALL displayed",
            variable=select_all_var,
            command=toggle_select_all,
        ).pack(pady=5)

        def refresh_list(*_):
            term = search_var.get().lower()
            lb.delete(0, "end")
            for idx, p in enumerate(files):
                name = p.name
                if not term or term in name.lower():
                    lb.insert("end", f"{idx+1}. {name}")

        search_var.trace_add("write", refresh_list)
        refresh_list()

        result = {"value": None}

        def ok():
            sel = {
                int(lb.get(i).split(".", 1)[0]) - 1
                for i in lb.curselection()
            }
            result["value"] = "ALL" if select_all_var.get() else sel
            dlg.destroy()

        def cancel():
            dlg.destroy()
            abort_installation()

        btns = Frame(dlg)
        btns.pack(pady=10)
        ttk.Button(btns, text="OK", command=ok).grid(row=0, column=0, padx=8)
        ttk.Button(btns, text="Cancel", command=cancel).grid(row=0, column=1, padx=8)

        dlg.wait_window()
        return result["value"]

    def process_directory(self, top, enforce_cmd):
        src_dir = pathlib.Path(top)
        if not src_dir.is_dir():
            return

        candidates = sorted(p for p in src_dir.rglob("*") if p.is_file())
        filtered = [p for p in candidates if not skip_file(p)]

        if not filtered:
            messagebox.showinfo(
                "No Profiles",
                f"No enforceable files in {top} – skipping.",
                parent=self.master,
            )
            return

        remaining = []
        for f in filtered:
            if f.name in NO_CHOICE:
                enforce_profile(f, enforce_cmd, self.master)
            else:
                remaining.append(f)

        if not remaining:
            messagebox.showinfo(
                "Auto Enforcement",
                f"All profiles in {top} were automatically enforced – moving to next directory.",
                parent=self.master,
            )
            return

        while remaining:
            choice = self.select_profiles_dialog(top, remaining)

            if choice is None:          # safety net
                abort_installation()

            if choice == "ALL":
                for p in remaining:
                    enforce_profile(p, enforce_cmd, self.master)
                remaining.clear()
                break

            selected_files = [remaining[i] for i in choice]

            # Prefix‑matching logic (same as original)
            extra = []
            for src in selected_files:
                if src.name.lower() in {"nautilus", "thunar"}:
                    extra.extend([p for p in remaining if p.name.startswith("qubes")])
                else:
                    prefix = src.name.split("-")[0]
                    extra.extend(
                        [p for p in remaining
                         if p.name.startswith(prefix) and p not in selected_files]
                    )
            to_enforce = list(dict.fromkeys(selected_files + extra))

            for p in to_enforce:
                enforce_profile(p, enforce_cmd, self.master)
                if p in remaining:
                    remaining.remove(p)

            if not remaining:
                messagebox.showinfo(
                    "Profiles Enforced",
                    f"All profiles in {top} have been enforced – moving to next directory.",
                    parent=self.master,
                )
                break

    def start(self):
        global XUSER, UHOME, DISPLAY, XAUTHORITY
        XUSER, UHOME, DISPLAY, XAUTHORITY = get_x11_info()
        if not XUSER or not DISPLAY:
            messagebox.showerror(
                "X11 Error",
                "Unable to determine X11 display or user. Cannot continue.",
                parent=self.master,
            )
            sys.exit(1)

        run_as_user(
            f"xauth add $(sudo -u {XUSER} xauth -f {XAUTHORITY} list | tail -1)",
            XUSER,
            DISPLAY,
            XAUTHORITY,
        )

        enforce_cmd = self.choose_enforce_method()

        for entry in SCRIPT_DIR.iterdir():
            if entry.is_dir() and entry.name.startswith(DIR_PREFIX):
                TARGET_DIRS.append(str(entry))

        if not TARGET_DIRS:
            messagebox.showerror(
                "No AppArmor directories",
                f'No sub‑directory starting with "{DIR_PREFIX}" found in {SCRIPT_DIR}.',
                parent=self.master,
            )
            abort_installation()

        for top in TARGET_DIRS:
            self.process_directory(top, enforce_cmd)

        clean_apparmor_dirs()

        messagebox.showinfo(
            "Installation Complete",
            "All selected profiles have been installed and enforced.",
            parent=self.master,
        )
        self.master.destroy()


if __name__ == "__main__":
    sudo_check()
    root = Tk()
    root.withdraw()          # hide the empty root window; dialogs are modal
    ROOT = root               # store global reference for abort_installation()
    ModernApp(root)
    root.mainloop()

!! BEFORE INSTALLING THE APPARMOR PROFILE INSTALL THE APP FIRST !! When you install the app debian overwrite the apparmor profile of the app. But not when the app update itself. So that mean if you run the script and you install the app then the apparmor profile from the repository will be removed automatically by debian (i don’t know if it’s normal or not)

To use the scripts do python3.py (name of the script)

I’ve made the installation process fast and easy if you click on “firefox-glx” it will automatically enforce every firefox files. It’s the same thing for Mullvad-Browser , brave etc

If you click on “Nautilus” or “Thunar” it will automatically install every qubes files it’s needed to use qvm-convert , qvm-convert-img etc…

It should take 20 seconds to install.
F.A.Q

“Do Qubes copy-vm menu entry works ?”

Yes i’ve tried and it works without any issue

Can i use firejail with your profiles ?

You shouldn’t do it in fact using firejail will only increase the possibility of an attack the creator of Firejail has said himself this

Don't use this on enterprise servers, or any other multiuser system. Firejail was built for single-user desktops.

Maybe you could use firejail with my apparmor profiles but i didn’t test to see if it works and i will never do it because i’ve tested firejail for a long time with Qubes and some applications wasn’t properly starting with firejail so it was running without any protection which make firejail useless for example Nautilus wasn’t launching with firejail so i have to find a trick to force Nautilus to run under firejail which is frustrating to do. There is a good reason why Tails , Whonix or Secureblue do not rely on firejail to secure a system they rely only on Apparmor or Selinux or Secureblue.

8 Likes

Update : I added “Deny network,” in the nautilus profile the first profile was allowing network now it doesn’t. So now it’s impossible nautilus reach the network.

I’ve seen apparmor have a lot of rules to manage network connection here we could use that later to make sys-net more secure in the future i won’t lie i will need help for that my technical knowledge about how linux network work is limited specially on Qubes

Also i think we could use those rules to make browser such as Firefox and Brave more private by denying any telemetry from Brave and Firefox ? But it’s unclear to me what could happen in the future if we block know telemetry maybe the user could not anymore reach internet ?

Maybe you could take a look at apparmor profiles from ezjail too, it’s not easy to get a profile done right.

1 Like

Here are also good examples for apparmor profiles.

1 Like

Yeah apparmor is not easy it took me so many hours to create the apparmor profile for firefox and make some test… Have you tried my apparmor profiles ? Have you met any issue ? I’d like to have some feeback it would be great

Thanks i have tried multiples times his apparmor profile but it wasn’t working for me on Qubes but i forget to take a look at the syntax he’s using i will copy some value from him

It would be nice if Debian and Kicksecure templates already came with AppArmor. Or does it work by default in Kicksecure template?

Kicksecure came with apparmor and some profile enabled by default but there is no profile configured properly for Qubes

Update i have created a codeberg repository dkzkz/apparmor-qubes: Apparmor profile for debian template in Qubes - Codeberg.org

I will put any profile in the repository before put a profile in the repository i will test it to make sure no one have any problem

1 Like

I checked Kicksecure template – it includes apparmor profiles for all browsers from Browser Choice: Brave, Chromium, Mullvad, Firefox, and Tor.
But these profiles have no restrictions.
Essentially, this indicates that apparmor is hardly used in Kicksecure - out of more than 100 profiles, only 25 are in enforce mode.
Therefore, this guide is very important and useful.

Can you do as sudo cat /etc/apparmor.d/brave and sudo cat /etc/apparmor.d/firefox ? Then copy and paste the content here of both i need to see the content because i don’t think they’re enabled and configured or maybe i’m wrong ? I’m not using kicksecure so i don’t know.

Update brave apparmor profile is finished codeberg seem to be down so i uploaded the brave profile here

PS : The brave apparmor profile can’t run some extensions like keepassxc because brave is using command like @{bin}/touch and the cat command to make some extensions work but i think it’s insecure to allow a browser to do such things so i removed them. If you think it’s a bad idea from me to do that then tell me i will change that

I think i will create a another brave apparmor profile with those permissions to let a user use extensions i don’t know if it’s a good idea to do…

I could disable tor in brave with the apparmor profile but i don’t know if i should do it or not we could talk about that in the future

Like the firefox profile the brave profile can’t see the content of every home folder including QubesIncoming except Downloads brave can’t access to the content of all root filesystems

Next profile coming up : Librewolf , Bitwarden , Proton-pass

include <tunables/global>

@{name}            = brave{,-beta,-dev,-bin}
@{domain}          = com.brine.Brave org.chromium.Chromium
@{lib_dirs}        = /opt/brave{-bin,.com}{,/@{name}}
@{USER}            = user                # ← replace with the actual login name
@{config_dirs}     = @{HOME}/.config/BraveSoftware/Brave-Browser{,-Beta,-Dev}
@{cache_dirs}      = @{HOME}/.cache/BraveSoftware/Brave-Browser{,-Beta,-Dev}
@{user_config_dirs}= @{HOME}/.config
@{exec_path}       = @{lib_dirs}/@{name}

profile brave @{exec_path} flags=(attach_disconnected) {
  include <abstractions/audio>
  include <abstractions/bash>
  include <abstractions/postfix-common>
  include <abstractions/totem>
  include if exists <local/brave>

  deny capability dac_override,

  capability sys_admin,
  capability sys_ptrace,

  ptrace read peer=Xorg,
  ptrace read peer=unconfined,
  ptrace trace peer=brave,

  deny /* r,
  deny @{HOME}/ r,
  deny @{HOME}/*/ r,
  deny @{HOME}/.* r,

  /dev/udmabuf                         rw,
  /etc/dconf/db/local                  r,
  /etc/dconf/profile/*                 r,
  /etc/ld.so.cache                     r,
  /etc/ld.so.preload                   r,
  /etc/opt/chrome/native-messaging-hosts/* r,
  @{HOME}/.config/BraveSoftware/Brave-Browser/biahpgbdmdkfgndcmfiipgcebobojjkp/** mrix,
  /opt/brave.com/brave/brave           mrix,
  /opt/brave.com/brave/chrome-sandbox mrix,
  /opt/brave.com/brave/chrome_crashpad_handler mrix,

  @{PROC}/*/stat          r,
  @{PROC}/*/statm         r,
  @{PROC}/*/task/**      r,
  /proc/sys/fs/inotify/max_user_watches r,
  /proc/sys/kernel/yama/ptrace_scope    r,

  /sys/devices/system/cpu/*   r,
  /sys/devices/system/cpu/**  r,
  /sys/devices/virtual/tty/** r,

  /usr/bin/basename          mrix,
  /usr/bin/cut               mrix,
  /usr/bin/dash              ix,
  /usr/bin/gawk              mrix,
  /usr/bin/grep              mrix,
  /usr/bin/head              mrix,
  /usr/bin/ln                mrix,
  /usr/bin/mkdir             mrix,
  /usr/bin/mktemp            mrix,
  /usr/bin/realpath          mrix,
  /usr/bin/sed               mrix,
  /usr/bin/tr                mrix,
  /usr/bin/update-desktop-database mrix,
  /usr/bin/xdg-desktop-menu mrix,
  /usr/bin/xdg-icon-resource mrix,
  /usr/bin/xdg-mime          mrix,
  /usr/bin/xdg-settings      mrix,

  /usr/share/chromium/extensions/ r,
  /var/cache/fontconfig/          rw,
  /{media,mnt,opt,srv}/**        mr,

  @{PROC}                         r,
  @{PROC}/@{pids}/                r,
  @{exec_path}                    mrix,

  owner "@{HOME}/.config/BraveSoftware/Brave-Browser/Crash Reports/*" k,
  owner "@{HOME}/.config/BraveSoftware/Brave-Browser/Crash Reports/*" rw,

  owner /dev/shm/*               r,
  owner /dev/shm/*               w,
  owner /etc/opt/chrome/         rw,
  owner @{HOME}/.cache/BraveSoftware/          rw,
  owner @{HOME}/.cache/BraveSoftware/**        rw,
  owner @{HOME}/.cache/BraveSoftware/**/       rw,
  owner @{HOME}/.cache/mesa_shader_cache/*     rw,
  owner @{HOME}/.cache/mesa_shader_cache/**    r,
  owner @{HOME}/.cache/mesa_shader_cache/**    w,

  owner @{HOME}/.config/BraveSoftware/Brave-Browser/          rw,
  owner @{HOME}/.config/BraveSoftware/Brave-Browser/*        w,
  owner @{HOME}/.config/BraveSoftware/Brave-Browser/**       k,
  owner @{HOME}/.config/BraveSoftware/Brave-Browser/**       rw,
  owner @{HOME}/.config/BraveSoftware/Brave-Browser/*/       rw,
  owner @{HOME}/.config/dconf/*                               r,

  owner /opt/brave.com/brave/extensions/ mrw,

  owner @{PROC}/*/clear_refs   w,
  owner @{PROC}/*/cmdline      r,
  owner @{PROC}/*/gid_map      w,
  owner @{PROC}/*/mem          r,
  owner @{PROC}/*/setgroups    w,
  owner @{PROC}/*/smaps_rollup r,
  owner @{PROC}/*/uid_map      w,

  owner /run/user/1000/dconf/* rw,
  owner /run/user/1000/pulse/* rw,

  owner @{HOME}/.pki/               rw,
  owner @{HOME}/.pki/*/             rw,
  owner @{HOME}/.pki/nssdb/*        k,
  owner @{HOME}/.pki/nssdb/*        r,
  owner @{HOME}/.pki/nssdb/*        w,

  owner @{HOME}/Desktop/*          w,
  owner @{HOME}/Downloads/*        r,
  owner @{HOME}/Downloads/*        w,

  owner @{PROC}/@{pid}/fd/          r,
  owner @{PROC}/@{pid}/oom_{,score_}adj rw,

  owner @{cache_dirs}/BraveSoftware/ rw,
  owner @{config_dirs}/BraveSoftware/ rw,
  owner @{config_dirs}/WidevineCdm/libwidevinecdm.so mrw,
  owner @{user_config_dirs}/BraveSoftware/ rw,

  userns,
}


2 Likes

It’s unclear to me if it is a good idea to tell the user to do

apparmor_parser -r /path/to/your_profile

This load the apparmor policy we are using into the kernel to enforce the security at 100% but i don’t know if a future update from Debian could break the system of user template so i would not recommend to do it. But i will use the apparmor_parser -r myself and make some test for 1 month to see if it’s ok to do

Uploaded in the repository the brave profile

Added in the repository a explanation to install the apparmor profile

I would like some people test the profile to tell me if it’s working for you correctly

It seems it works

1 Like

By the way, friend. Please try creating an apparmor for anti-detect Donut Browser and Camoufox (from donat), after popular browsers

1 Like

@adrelanos Hi. Why not set apparmor to enforce mode for all browsers in the Browser Choice list for extra security in Kicksecure? And add a warning when updating the Tor browser reminding users to modify apparmor profile. Maybe there are reasons or nuances why this isn’t done? Or perhaps apparmor isn’t needed with sysmaint?

Thanks for testing ! I updated the firefox profile and the brave profile in the codeberg repository and in the post as well. They cannot anymore read and access any file or folder inside the home directory except the Downloads folder so make sure to use the new version.

Explanation of the update :

In the previous version i was using deny /home/QubesIncoming deny /home/user/Documents but it was not good. A user or program could create a folder and write data there and in that case the browser could access this folder because the apparmor profile in the previous version was only denying from specific folder. Now in this new version we deny access to every folders and files in home directory even hidden files in home such as .ssh .bash_history is denied access.

PS : Firefox will throw you a error “Error cannot access to Downloads” but you can download files there don’t worry it will work i need to fix this but i don’t know how for now. It’s because i added the “deny @home” rules

The brave profile is considered finish i won’t touch it anymore i think
The firefox profile need to be modified i need to remove the “/home/user/” and replace it by @home syntax like i did in the brave profile i will do it today.
The nautilus profile need to be modified i’d like to apply the “deny network,” rule but it break the app nautilus need to connect to something called “org.freedesktop” i need to find a workaround i will do it today.

I will do it but it’s not my priority. For now i’m trying to make the current profile perfect i need to create librewolf profile , thunar and profile for sys-net. Sys-net will be my #1 priority after librewolf and thunar

1 Like

Okay. Thanks. Don’t forget about Mullvad browser and Tor :slightly_smiling_face: I will be using your profiles on debian and kicksecure templates

I uploded the mullvad-browser profile on the repository it isn’t finish but it can be used but i don’t understand why ur asking a tor profile i thought whonix was already providing a TB browser profile installed and configured

1 Like