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

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.

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

[ ]