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 |
|---|---|
|
[x] |
Boot params updated on all 3 entries ( |
[x] |
LSM stack: |
[x] |
|
[x] |
Restored missing |
[x] |
Phase 1d: Apply Boot Parameters to ESP
|
Boot entries live on the ESP at |
# === 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
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 |
[x] |
All 3 ESP entries patched (BEFORE/AFTER verified) |
[x] |
AppArmor in LSM stack ( |
[x] |
|
[x] |
Default profiles loaded ( |
[x] |
|
[x] |
Boot params include |
[x] |
Phase 2: Credential Store Lockdown (Immediate)
|
Decision (2026-04-05): Skipping the 2-3 day complain-mode baseline. Credential stores ( |
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 |
|---|---|
|
All rules (including deny) are ignored |
|
Useless while parent profile is unconfined |
Modifying upstream profiles |
|
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 ( |
# 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 |
[x] |
|
[x] |
Allow-all baseline added to all 3 profiles |
[x] |
|
[x] |
Profiles reloaded with |
[x] |
All 3 browsers in enforce mode ( |
[x] |
Firefox smoke test passes (launches, browses) |
[x] |
No bwrap denials after |
[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)
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
| Firefox |
8 processes enforced with credential store deny rules |
| Docker |
Chromium, node, Java containers under |
| System |
avahi, dnsmasq, nscd, ntpd, nvidia_modprobe, ping, traceroute |
| Profile | Notes |
|---|---|
|
Not relevant — running Hyprland/Wayland, not Xorg |
|
Not actively used — low priority |
|
Not actively used — low priority |
|
Not actively used — low priority |
|
Not actively used — low priority |
| 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) |
|
Candidates for enforce — high-value targets |
Development tools |
|
Evaluate — container runtimes need careful profiling |
Browsers (unused) |
|
Low priority — not running |
Security-sensitive |
|
Medium priority |
| Priority | Profile | Rationale |
|---|---|---|
P1 |
|
Electron app with full filesystem access — reads your knowledge base |
P1 |
|
Electron + extensions = large attack surface |
P1 |
|
Password manager — should be tightly confined |
P2 |
|
Electron — network access + file sharing |
P2 |
|
Electron — messaging app |
P2 |
|
Electron — high-risk community platform |
P3 |
|
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 ( |
[ ] |
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 |
[ ] |
|
[ ] |
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 |
[ ] |
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 |
[ ] |
Only expected services listening |
[ ] |