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.
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.
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 |
[ ] |