Phase 12: Security Hardening

Phase 12: Security Hardening

Security hardening is multi-day operational work with phased rollout. This phase covers Mandatory Access Control (AppArmor), host firewall (UFW), SSH lockdown, and open ports audit.

Automated Security Assessment

Run the unified security dashboard to measure current posture (requires sudo for LUKS and AppArmor visibility):

sudo MPLBACKEND=Agg python3 docs/modules/ROOT/examples/codex/python/security-dashboard.py

Dashboard output: /tmp/security-dashboard.png — 18 panels covering attack surface, authentication (802.1X EAP-TLS), encryption (LUKS), MAC (AppArmor), kernel hardening (8 sysctl checks), systemd sandboxing, and unified score.

Baseline score (2026-04-10): 59/100 (Grade D) — LUKS and AppArmor Phase 1-2 complete, firewall and kernel hardening not yet deployed.

Additional dashboards for hardware and system health:

python3 docs/modules/ROOT/examples/codex/python/system-dashboard.py    # 12 panels
python3 docs/modules/ROOT/examples/codex/python/hardware-dashboard.py  # 12 panels

See Python Codex for full script inventory.

Mandatory Access Control (AppArmor)

AppArmor deployment follows a 4-phase rollout tracked as SEC-001. Full implementation details and rationale are in the change request:

Phase 1: Install & Enable (Complete)

Check Status

apparmor package installed (pacman -Q apparmor)

[x]

Boot params updated on all 3 entries (arch.conf, arch-fallback.conf, arch-lts.conf)

[x]

LSM stack: lsm=landlock,lockdown,yama,integrity,apparmor,bpf apparmor=1 security=apparmor

[x]

apparmor.service enabled (systemctl is-enabled apparmor)

[x]

Restored missing acpi_mask_gpe=0x6E on fallback + LTS entries

[x]

Phase 1d: Apply Boot Parameters to ESP

Boot entries live on the ESP at /boot/efi/loader/entries/, NOT /boot/loader/entries/ (ext4 shadow). The CR-2026-04-04 sed commands targeted the wrong path. This section targets the correct ESP path with an idempotent, variable-driven approach.

# === Single source of truth ===
APPARMOR_PARAMS="lsm=landlock,lockdown,yama,integrity,apparmor,bpf apparmor=1 security=apparmor"
ESP_ENTRIES="/boot/efi/loader/entries"
GUARD="apparmor=1"
# === Pre-flight: confirm ESP is mounted ===
mountpoint -q /boot/efi && echo "ESP mounted" || echo "FATAL: ESP not mounted"
# === BEFORE state ===
for entry in "${ESP_ENTRIES}"/*.conf; do
    printf "%-25s " "$(basename "$entry")"
    grep -q "${GUARD}" "$entry" && echo "✓ has AppArmor" || echo "✗ MISSING"
done
# === APPLY (idempotent — skips if already present) ===
for entry in "${ESP_ENTRIES}"/*.conf; do
    name=$(basename "$entry")
    if grep -q "${GUARD}" "$entry"; then
        echo "SKIP  ${name}"
    else
        sudo sed -i "/^options/s|$| ${APPARMOR_PARAMS}|" "$entry"
        echo "PATCH ${name}"
    fi
done
# === AFTER state (full verification) ===
for entry in "${ESP_ENTRIES}"/*.conf; do
    printf "\n=== %s ===\n" "$(basename "$entry")"
    grep '^options' "$entry"
done

Phase 1e: Reboot and Verify

sudo reboot

After reboot:

# Verify LSM stack includes AppArmor
cat /sys/kernel/security/lsm
Expected output
landlock,lockdown,capability,yama,integrity,apparmor,bpf
aa-enabled
sudo aa-status | head -15
systemctl is-active apparmor
grep apparmor /proc/cmdline
Check Status

ESP mounted at /boot/efi

[x]

All 3 ESP entries patched (BEFORE/AFTER verified)

[x]

AppArmor in LSM stack (cat /sys/kernel/security/lsm)

[x]

aa-enabled returns Yes

[x]

Default profiles loaded (sudo aa-status) — 162 profiles, 79 enforce

[x]

apparmor.service active

[x]

Boot params include apparmor=1 security=apparmor in /proc/cmdline

[x]

Phase 2: Credential Store Lockdown (Immediate)

Decision (2026-04-05): Skipping the 2-3 day complain-mode baseline. Credential stores (~/.secrets/, ~/.gnupg/, ~/.age/, gopass) are exposed NOW. A broken browser is a 2-second fix (aa-complain). Exfiltrated keys are permanent.

Discovery: flags=(unconfined) renders local/ rules inert

The default Arch apparmor package ships browser profiles (firefox, chrome, chromium) with flags=(unconfined). This means the profile labels the process but enforces nothing — deny rules in local/ are ignored. We must modify the profiles to remove the unconfined flag and add an allow-all baseline so that explicit deny rules take effect.

Issue Impact

flags=(unconfined) in profile

All rules (including deny) are ignored

local/ deny rules alone

Useless while parent profile is unconfined

Modifying upstream profiles

pacman -Syu may overwrite — documented in maintenance

2a. Create credential store deny rules

The deny rules are identical for all three browsers. Create them in local/ overrides.

# Define the deny rules (credential stores + SSH keys)
DENY_RULES='  # Credential store lockdown (CR-2026-04-04)
  deny owner @{HOME}/.secrets/ rw,
  deny owner @{HOME}/.secrets/** rw,
  deny owner @{HOME}/.gnupg/ rw,
  deny owner @{HOME}/.gnupg/** rw,
  deny owner @{HOME}/.age/ rw,
  deny owner @{HOME}/.age/** rw,
  deny owner @{HOME}/.local/share/gopass/ rw,
  deny owner @{HOME}/.local/share/gopass/** rw,
  deny owner @{HOME}/.ssh/id_* rw,'
# Write deny rules to all three browser local overrides
for browser in firefox chrome chromium; do
    echo "${DENY_RULES}" | sudo tee /etc/apparmor.d/local/${browser} > /dev/null
    echo "WROTE /etc/apparmor.d/local/${browser}"
done
# Verify all three files
for browser in firefox chrome chromium; do
    printf "\n=== %s ===\n" "${browser}"
    cat /etc/apparmor.d/local/${browser}
done
2b. Convert browser profiles from unconfined to confined

Remove flags=(unconfined) and add an allow-all baseline. Deny rules in local/ then override specific paths.

# BEFORE -- confirm current state (all should show "unconfined")
for browser in firefox chrome chromium; do
    printf "%-12s " "${browser}:"
    grep 'flags=' /etc/apparmor.d/${browser}
done
# APPLY -- remove flags=(unconfined), add allow-all baseline rules
# Firefox
sudo sed -i 's| flags=(unconfined) {| {|' /etc/apparmor.d/firefox
sudo sed -i '/userns,/a\  # Allow-all baseline (confined — deny rules now enforced)\n  capability,\n  network,\n  signal,\n  ptrace,\n  dbus,\n  unix,\n  mount,\n  umount,\n  pivot_root,\n  / r,\n  /** rwlkm,\n  /** ix,' /etc/apparmor.d/firefox
# Chrome
sudo sed -i 's| flags=(unconfined) {| {|' /etc/apparmor.d/chrome
sudo sed -i '/userns,/a\  # Allow-all baseline (confined — deny rules now enforced)\n  capability,\n  network,\n  signal,\n  ptrace,\n  dbus,\n  unix,\n  mount,\n  umount,\n  pivot_root,\n  / r,\n  /** rwlkm,\n  /** ix,' /etc/apparmor.d/chrome
# Chromium
sudo sed -i 's| flags=(unconfined) {| {|' /etc/apparmor.d/chromium
sudo sed -i '/userns,/a\  # Allow-all baseline (confined — deny rules now enforced)\n  capability,\n  network,\n  signal,\n  ptrace,\n  dbus,\n  unix,\n  mount,\n  umount,\n  pivot_root,\n  / r,\n  /** rwlkm,\n  /** ix,' /etc/apparmor.d/chromium
# VERIFY -- confirm unconfined flag removed, baseline rules added
for browser in firefox chrome chromium; do
    printf "\n=== %s ===\n" "${browser}"
    cat /etc/apparmor.d/${browser}
done
2c. Reload and enforce browser profiles
# Reload modified profiles into kernel
for browser in firefox chrome chromium; do
    sudo apparmor_parser -r /etc/apparmor.d/${browser}
    echo "RELOADED ${browser}"
done
# Force enforce mode
for browser in firefox chrome chromium; do
    sudo aa-enforce /etc/apparmor.d/${browser}
    echo "ENFORCED ${browser}"
done
# Verify -- browsers should appear under enforce, NOT unconfined
sudo aa-status | awk '/in enforce/{p="enforce"} /in complain/{p="complain"} /in unconfined/{p="unconfined"} /firefox|chrome|chromium/{print p": "$0}'
2d. Fix bwrap sandbox denials (attach_disconnected)

Lesson learned (2026-04-05): Browsers use bubblewrap (bwrap) for internal sandboxing. bwrap creates namespaces with disconnected paths (file descriptors without resolvable filesystem paths). Without flags=(attach_disconnected), AppArmor denies these operations — ironically weakening the browser’s own sandbox. The fix: add the flag to allow disconnected-path operations while keeping all deny rules active.

# APPLY -- add attach_disconnected flag (idempotent: skips if already present)
for browser in firefox chrome chromium; do
    if grep -q 'attach_disconnected' /etc/apparmor.d/${browser}; then
        echo "SKIP  ${browser} (already has flag)"
    else
        sudo sed -i "s|^profile ${browser} \(.*\) {|profile ${browser} \1 flags=(attach_disconnected) {|" /etc/apparmor.d/${browser}
        echo "PATCH ${browser}"
    fi
done
# VERIFY -- all three should show flags=(attach_disconnected)
grep '^profile' /etc/apparmor.d/{firefox,chrome,chromium}
# Reload + re-enforce all three
for browser in firefox chrome chromium; do
    sudo apparmor_parser -r /etc/apparmor.d/${browser} && sudo aa-enforce /etc/apparmor.d/${browser}
    echo "DONE ${browser}"
done
# Confirm no more bwrap denials (launch a browser first, browse for 30s)
sudo journalctl -k --since "1 minute ago" | grep -i 'apparmor.*DENIED' | tail -5
2e. Smoke test
# Launch Firefox and verify it works
firefox &
# Check for denials in audit log (should see denies for credential paths IF Firefox tried)
sudo journalctl -k --since "5 minutes ago" | grep -i 'apparmor.*DENIED' | tail -10
# If Firefox breaks: emergency rollback to complain mode (2-second fix)
# sudo aa-complain /etc/apparmor.d/firefox
Check Status

Deny rules written to local/{firefox,chrome,chromium}

[x]

flags=(unconfined) removed from all 3 browser profiles

[x]

Allow-all baseline added to all 3 profiles

[x]

flags=(attach_disconnected) added for bwrap sandbox

[x]

Profiles reloaded with apparmor_parser -r

[x]

All 3 browsers in enforce mode (aa-status)

[x]

Firefox smoke test passes (launches, browses)

[x]

No bwrap denials after attach_disconnected fix

[x]

Maintenance note

Modifying /etc/apparmor.d/{firefox,chrome,chromium} means pacman -Syu may prompt about .pacnew files when the apparmor package updates. When this happens:

# After pacman update: check for .pacnew files
find /etc/apparmor.d/ -name "*.pacnew" | head
# Re-apply our changes (remove unconfined, add baseline)
# Or diff the .pacnew against our version and merge

Future improvement: create a pacman hook or stow package (dots-quantum) to automate re-application.

Current State Snapshot (2026-04-27)

aa-status summary
162 profiles loaded
 82 enforce    — actively blocking violations
  5 complain   — logging only (learning mode)
 75 unconfined — profile exists but not loaded
 23 processes in enforce mode
Enforce highlights
Firefox

8 processes enforced with credential store deny rules

Docker

Chromium, node, Java containers under docker-default

System

avahi, dnsmasq, nscd, ntpd, nvidia_modprobe, ping, traceroute

Table 1. Complain mode (candidates for enforce)
Profile Notes

Xorg

Not relevant — running Hyprland/Wayland, not Xorg

transmission-cli

Not actively used — low priority

transmission-daemon

Not actively used — low priority

transmission-gtk

Not actively used — low priority

transmission-qt

Not actively used — low priority

Table 2. Unconfined (75 profiles — triage needed)
Category Profiles Action

Not installed / not running

apache2, dovecot, samba, sbuild, lxc-*, mmdebstrap, rpm

Ignore — package deps, not running

Desktop apps (not used)

goldendict, epiphany, evolution, geary, devhelp, foliate, loupe, nautilus, wike

Ignore — not running

Desktop apps (used)

obsidian, vscode, slack, signal-desktop, discord, 1password

Candidates for enforce — high-value targets

Development tools

podman, buildah, crun, flatpak

Evaluate — container runtimes need careful profiling

Browsers (unused)

brave, msedge, opera, vivaldi-bin, polypane, qutebrowser

Low priority — not running

Security-sensitive

steam, element-desktop, github-desktop, keybase

Medium priority

Table 3. Next enforcement candidates (priority order)
Priority Profile Rationale

P1

obsidian

Electron app with full filesystem access — reads your knowledge base

P1

vscode

Electron + extensions = large attack surface

P1

1password

Password manager — should be tightly confined

P2

slack

Electron — network access + file sharing

P2

signal-desktop

Electron — messaging app

P2

discord

Electron — high-risk community platform

P3

steam

Game client — broad hardware access

Phase 3: Node/Python Custom Profiles (Future)

Target applications without upstream profiles:

Application Risk Priority

node / npm

Supply chain attacks via npm packages

High

python / pip

Malicious packages

Medium

Claude Code (node-based)

Broad filesystem access by design

Medium — profile carefully

These require writing custom profiles from scratch. Deferred until browser lockdown is validated.

Check Status

node/npm custom profile created

[ ]

python custom profile created

[ ]

Claude Code profiled (broad access by design — careful)

[ ]

Phase 4: Docker Confinement (Day 7+)

# Verify Docker uses AppArmor by default
docker info | grep -i apparmor
# Run test container and verify AppArmor profile applied
docker run --rm alpine cat /proc/self/attr/current
Check Status

Docker AppArmor integration verified (docker info)

[ ]

Test container shows AppArmor profile applied

[ ]

Firewall (UFW)

Install and Configure

sudo pacman -S ufw
# Set default policies
sudo ufw default deny incoming
sudo ufw default allow outgoing

Allow Rules

# SSH access (required for remote management from Razer)
sudo ufw allow ssh
# Enable firewall
sudo ufw enable
# Enable at boot
sudo systemctl enable ufw

Verify

sudo ufw status verbose
# Confirm service active
systemctl is-active ufw
Check Status

UFW installed

[ ]

Default deny incoming / allow outgoing

[ ]

SSH allowed

[ ]

UFW enabled and active

[ ]

ufw.service enabled at boot

[ ]

SSH Hardening

Disable Root Login

# BEFORE -- verify current state
sudo sshd -T | grep -i permitrootlogin
# APPLY
sudo sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
# VERIFY
sudo sshd -T | grep -i permitrootlogin

Enforce Pubkey-Only Authentication

# BEFORE
sudo sshd -T | grep -i passwordauthentication
# APPLY
sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?KbdInteractiveAuthentication.*/KbdInteractiveAuthentication no/' /etc/ssh/sshd_config
# VERIFY
sudo sshd -T | grep -iE 'passwordauth|kbdinteractive'

Limit Auth Attempts

sudo sed -i 's/^#\?MaxAuthTries.*/MaxAuthTries 3/' /etc/ssh/sshd_config

Restart sshd

sudo systemctl restart sshd
# Confirm new settings active
sudo sshd -T | grep -iE 'permitroot|passwordauth|maxauthtries|kbdinteractive'
Check Status

PermitRootLogin no

[ ]

PasswordAuthentication no

[ ]

KbdInteractiveAuthentication no

[ ]

MaxAuthTries 3

[ ]

sshd restarted, config validated with sshd -T

[ ]

Open Ports Audit

# List all listening TCP ports
ss -tlnp | awk '{print $4}' | sort -u
# Full audit with process names
ss -tlnp

Expected: Only sshd listening. Flag anything unexpected.

Check Status

Open ports audited with ss -tlnp

[ ]

Only expected services listening

[ ]