AppArmor and SELinux: Testing Mandatory Access Control Policies
Mandatory Access Control (MAC) is what runs underneath discretionary permissions. Even if a process runs as root, AppArmor and SELinux can restrict which files it reads, which network ports it uses, and which capabilities it exercises. For containers, these are critical layers of defense-in-depth.
The gap most teams fall into: they deploy AppArmor or SELinux, check a compliance box, and never verify the policies actually restrict what they're supposed to restrict. This guide covers how to write and test MAC policies properly.
AppArmor vs SELinux: Quick Comparison
| AppArmor | SELinux | |
|---|---|---|
| Model | Path-based | Label-based (type enforcement) |
| Complexity | Lower | Higher |
| Default on | Debian, Ubuntu | RHEL, CentOS, Fedora |
| Kubernetes support | Annotations or SecurityContext | SecurityContext (SELinuxOptions) |
| Profile format | Human-readable text | Policy module files (.te, .fc, .if) |
| Tooling | apparmor_parser, aa-genprof |
semanage, audit2allow, checkpolicy |
Both are effective. Choose based on your OS. On Ubuntu/Debian nodes, AppArmor is the standard. On RHEL/CentOS/Fedora, SELinux is built-in and enabled by default.
Part 1: AppArmor
Profile Structure
An AppArmor profile looks like this:
#include <tunables/global>
profile my-container /usr/bin/my-app flags=(attach_disconnected) {
#include <abstractions/base>
#include <abstractions/nameservice>
# Allow reading config
/etc/myapp/** r,
/etc/myapp/secrets/** r,
# Allow writing logs
/var/log/myapp/** w,
/var/log/myapp/*.log wk,
# Allow network
network tcp,
network udp,
# Allow required capabilities
capability net_bind_service,
capability setuid,
capability setgid,
# Deny everything else explicitly
deny /etc/shadow r,
deny /proc/*/mem rw,
deny /sys/** w,
deny ptrace,
}Key permission letters:
r= read,w= write,a= append,x= executek= file locking,l= link,m= mmapd= delete,c= create
Generating a Profile with aa-genprof
aa-genprof runs your application in learning mode and captures all access events:
# Install AppArmor utilities
apt-get install apparmor-utils
<span class="hljs-comment"># Start profiling (application runs in complain mode — logs but doesn't block)
aa-genprof /usr/bin/my-app
<span class="hljs-comment"># In another terminal, run your app through its full normal operation
/usr/bin/my-app --run-full-test-suite
<span class="hljs-comment"># Back in aa-genprof, press 'S' to scan events and answer prompts
<span class="hljs-comment"># Press 'F' when done to save the profileThe generated profile at /etc/apparmor.d/usr.bin.my-app is your starting point. Review and tighten it.
Testing an AppArmor Profile
Test 1: Complain Mode (Discovery)
Before enforcing, run in complain mode to see what would be blocked:
# Load profile in complain mode
apparmor_parser -C /etc/apparmor.d/my-app
<span class="hljs-comment"># Run your app and full test suite
/usr/bin/my-app --run-integration-tests
<span class="hljs-comment"># Check what would have been blocked
grep <span class="hljs-string">"ALLOWED" /var/log/syslog <span class="hljs-pipe">| grep <span class="hljs-string">"my-app" <span class="hljs-pipe">| <span class="hljs-built_in">tail -20
grep <span class="hljs-string">"DENIED" /var/log/syslog <span class="hljs-pipe">| grep <span class="hljs-string">"my-app" <span class="hljs-pipe">| <span class="hljs-built_in">tail -20Test 2: Enforce Mode Functional Test
Switch to enforce mode and run tests — they must all pass:
# Load in enforce mode
apparmor_parser -r /etc/apparmor.d/my-app
<span class="hljs-comment"># Verify status
aa-status <span class="hljs-pipe">| grep <span class="hljs-string">"my-app"
<span class="hljs-comment"># Output: my-app (enforce)
<span class="hljs-comment"># Run functional tests
/usr/bin/my-app --run-integration-tests
<span class="hljs-built_in">echo <span class="hljs-string">"Exit code: $?"Test 3: Verify Restrictions
Explicitly test that the profile blocks what it should:
#!/bin/bash
<span class="hljs-comment"># test-apparmor-restrictions.sh
PROFILE_NAME=<span class="hljs-string">"my-container"
PASS=0
FAIL=0
<span class="hljs-function">check_blocked() {
<span class="hljs-built_in">local test_name=<span class="hljs-string">"$1"
<span class="hljs-built_in">local cmd=<span class="hljs-string">"$2"
<span class="hljs-comment"># Run in a process confined by the profile
aa-exec -p <span class="hljs-string">"$PROFILE_NAME" -- bash -c <span class="hljs-string">"$cmd" 2>&1
<span class="hljs-built_in">local exit_code=$?
<span class="hljs-keyword">if [ <span class="hljs-variable">$exit_code -ne 0 ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"PASS: '$test_name' was blocked (exit <span class="hljs-variable">$exit_code)"
PASS=$((PASS + <span class="hljs-number">1))
<span class="hljs-keyword">else
<span class="hljs-built_in">echo <span class="hljs-string">"FAIL: '$test_name' was NOT blocked"
FAIL=$((FAIL + <span class="hljs-number">1))
<span class="hljs-keyword">fi
}
<span class="hljs-function">check_allowed() {
<span class="hljs-built_in">local test_name=<span class="hljs-string">"$1"
<span class="hljs-built_in">local cmd=<span class="hljs-string">"$2"
aa-exec -p <span class="hljs-string">"$PROFILE_NAME" -- bash -c <span class="hljs-string">"$cmd" 2>&1
<span class="hljs-built_in">local exit_code=$?
<span class="hljs-keyword">if [ <span class="hljs-variable">$exit_code -eq 0 ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"PASS: '$test_name' was allowed (exit 0)"
PASS=$((PASS + <span class="hljs-number">1))
<span class="hljs-keyword">else
<span class="hljs-built_in">echo <span class="hljs-string">"FAIL: '$test_name' was blocked unexpectedly (exit <span class="hljs-variable">$exit_code)"
FAIL=$((FAIL + <span class="hljs-number">1))
<span class="hljs-keyword">fi
}
<span class="hljs-comment"># Security restrictions
check_blocked <span class="hljs-string">"read /etc/shadow" <span class="hljs-string">"cat /etc/shadow"
check_blocked <span class="hljs-string">"read /proc/1/mem" <span class="hljs-string">"cat /proc/1/mem"
check_blocked <span class="hljs-string">"write to /tmp (if denied)" <span class="hljs-string">"touch /tmp/test-file"
<span class="hljs-comment"># Functionality preserved
check_allowed <span class="hljs-string">"read config" <span class="hljs-string">"cat /etc/myapp/config.yaml"
check_allowed <span class="hljs-string">"write logs" <span class="hljs-string">"echo test >> /var/log/myapp/test.log"
check_allowed <span class="hljs-string">"network access" <span class="hljs-string">"curl -s --max-time 5 https://example.com"
<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"Results: $PASS passed, <span class="hljs-variable">$FAIL failed"
[ <span class="hljs-variable">$FAIL -eq 0 ]AppArmor in Kubernetes
Apply an AppArmor profile to a pod with annotations (deprecated in 1.30+) or SecurityContext:
# Kubernetes 1.30+ (SecurityContext approach)
apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
containers:
- name: app
image: my-app:latest
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: my-containerPre-1.30 annotation approach:
apiVersion: v1
kind: Pod
metadata:
name: my-app
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/my-containerTesting AppArmor Profiles in Kubernetes
import subprocess
import pytest
import time
from kubernetes import client, config
config.load_kube_config()
v1 = client.CoreV1Api()
def create_test_pod(name, image, command, apparmor_profile=None):
annotations = {}
if apparmor_profile:
annotations[f"container.apparmor.security.beta.kubernetes.io/{name}"] = \
f"localhost/{apparmor_profile}"
pod = client.V1Pod(
metadata=client.V1ObjectMeta(
name=name,
namespace="default",
annotations=annotations
),
spec=client.V1PodSpec(
restart_policy="Never",
containers=[client.V1Container(
name=name,
image=image,
command=["sh", "-c", command]
)]
)
)
v1.create_namespaced_pod("default", pod)
# Wait for completion
for _ in range(30):
pod_status = v1.read_namespaced_pod("default", name)
phase = pod_status.status.phase
if phase in ("Succeeded", "Failed"):
return pod_status
time.sleep(1)
raise TimeoutError(f"Pod {name} did not complete in 30s")
@pytest.fixture(autouse=True)
def cleanup():
yield
for name in ["test-shadow", "test-allowed"]:
try:
v1.delete_namespaced_pod(name, "default")
except Exception:
pass
def test_apparmor_blocks_sensitive_file():
status = create_test_pod(
name="test-shadow",
image="alpine:latest",
command="cat /etc/shadow && echo ALLOWED || echo BLOCKED",
apparmor_profile="my-container"
)
logs = v1.read_namespaced_pod_log("test-shadow", "default")
assert "BLOCKED" in logs or status.status.phase == "Failed"
def test_apparmor_allows_normal_operation():
status = create_test_pod(
name="test-allowed",
image="my-app:latest",
command="./health-check.sh && echo OK",
apparmor_profile="my-container"
)
logs = v1.read_namespaced_pod_log("test-allowed", "default")
assert "OK" in logs
assert status.status.phase == "Succeeded"Part 2: SELinux
SELinux uses a label system. Every process, file, socket, and device has a security context (label) in the format user:role:type:level. Policy rules say which types can access which other types.
Understanding SELinux Contexts
# Show process contexts
ps -eZ <span class="hljs-pipe">| grep nginx
<span class="hljs-comment"># Show file contexts
<span class="hljs-built_in">ls -Z /var/www/html/
<span class="hljs-comment"># Show current context
<span class="hljs-built_in">id -ZSELinux Modes
# Check current mode
getenforce
<span class="hljs-comment"># Enforcing | Permissive <span class="hljs-pipe">| Disabled
<span class="hljs-comment"># Temporarily switch to permissive (logging only)
setenforce 0
<span class="hljs-comment"># Check with audit enabled
setenforce PermissiveGenerating a Custom Policy
Use audit2allow to generate policy from denial messages:
# 1. Set to permissive mode to capture denials without blocking
setenforce 0
<span class="hljs-comment"># 2. Clear audit log
> /var/log/audit/audit.log
<span class="hljs-comment"># 3. Run your application
/usr/bin/my-app --run-integration-tests
<span class="hljs-comment"># 4. Check what would be denied
ausearch -m avc --start recent <span class="hljs-pipe">| audit2why
<span class="hljs-comment"># 5. Generate a policy module
ausearch -m avc --start recent <span class="hljs-pipe">| audit2allow -M my-app-policy
<span class="hljs-comment"># 6. Review the generated policy
<span class="hljs-built_in">cat my-app-policy.te
<span class="hljs-comment"># 7. Install and activate
semodule -i my-app-policy.ppSELinux Policy Module Structure
A .te (type enforcement) file:
module my-app 1.0;
require {
type my_app_t;
type httpd_sys_content_t;
type var_log_t;
type proc_t;
class file { read write open getattr };
class dir { read search };
class process { signal };
}
# Allow my app to read web content
allow my_app_t httpd_sys_content_t:file { read open getattr };
allow my_app_t httpd_sys_content_t:dir { read search };
# Allow my app to write logs
allow my_app_t var_log_t:file { write open getattr };
# Deny reading /proc (not listed = denied in SELinux)
# No rule needed — absence of allow = denyTesting SELinux Policies
Test 1: Audit Log Analysis
#!/bin/bash
<span class="hljs-comment"># test-selinux-policy.sh
PROCESS_TYPE=<span class="hljs-string">"my_app_t"
PASS=0
FAIL=0
<span class="hljs-comment"># Clear recent audit log
<span class="hljs-built_in">echo <span class="hljs-string">"" > /var/run/test-audit-marker
logger -p security.info <span class="hljs-string">"TEST_START $(date +%s)"
<span class="hljs-comment"># Run the application
/usr/bin/my-app --run-integration-tests
APP_EXIT=$?
<span class="hljs-keyword">if [ <span class="hljs-variable">$APP_EXIT -ne 0 ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"FAIL: Application exited with code $APP_EXIT under SELinux enforcing"
FAIL=$((FAIL + <span class="hljs-number">1))
<span class="hljs-keyword">else
<span class="hljs-built_in">echo <span class="hljs-string">"PASS: Application ran successfully"
PASS=$((PASS + <span class="hljs-number">1))
<span class="hljs-keyword">fi
<span class="hljs-comment"># Check for unexpected AVCs (access vector cache denials)
DENIALS=$(ausearch -m avc --start recent -c my-app 2>/dev/null <span class="hljs-pipe">| grep -c <span class="hljs-string">"denied")
<span class="hljs-keyword">if [ <span class="hljs-string">"$DENIALS" -gt 0 ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"FAIL: $DENIALS unexpected SELinux denials found:"
ausearch -m avc --start recent -c my-app 2>/dev/null <span class="hljs-pipe">| audit2why
FAIL=$((FAIL + <span class="hljs-number">1))
<span class="hljs-keyword">else
<span class="hljs-built_in">echo <span class="hljs-string">"PASS: No unexpected denials"
PASS=$((PASS + <span class="hljs-number">1))
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"Results: $PASS passed, <span class="hljs-variable">$FAIL failed"
[ <span class="hljs-variable">$FAIL -eq 0 ]Test 2: Verify Policy Denies Dangerous Access
import subprocess
import pytest
def run_as_type(selinux_type, command):
"""Run a command under a specific SELinux type using runcon."""
result = subprocess.run(
["runcon", "-t", selinux_type, "--", "sh", "-c", command],
capture_output=True,
text=True,
timeout=10
)
return result
@pytest.mark.skipif(
subprocess.run(["getenforce"], capture_output=True).stdout.strip() != b"Enforcing",
reason="SELinux must be in Enforcing mode"
)
class TestSELinuxPolicy:
def test_app_can_read_config(self):
"""App type can read its own config directory."""
result = run_as_type("my_app_t", "cat /etc/myapp/config.yaml")
assert result.returncode == 0, \
f"App should read its config. Got:\n{result.stderr}"
def test_app_cannot_read_shadow(self):
"""App type cannot read /etc/shadow."""
result = run_as_type("my_app_t", "cat /etc/shadow")
assert result.returncode != 0, \
"App should NOT be able to read /etc/shadow"
def test_app_cannot_access_other_process_memory(self):
"""App type cannot read other processes' memory."""
result = run_as_type("my_app_t", "cat /proc/1/mem")
assert result.returncode != 0
def test_no_unexpected_avc_denials(self):
"""Running the app should produce no AVC denials."""
# Clear
subprocess.run(["ausearch", "-m", "avc", "--start", "recent"],
capture_output=True)
# Run the app
subprocess.run(["/usr/bin/my-app", "--smoke-test"],
capture_output=True, timeout=30)
# Check denials
result = subprocess.run(
["ausearch", "-m", "avc", "--start", "recent", "-c", "my-app"],
capture_output=True, text=True
)
denied = [line for line in result.stdout.splitlines()
if "denied" in line.lower()]
assert len(denied) == 0, \
f"Unexpected AVC denials:\n" + "\n".join(denied)SELinux for Kubernetes
apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
securityContext:
seLinuxOptions:
level: "s0:c123,c456" # MLS/MCS level
containers:
- name: app
image: my-app:latest
securityContext:
seLinuxOptions:
type: "my_app_t"
role: "system_r"
user: "system_u"
level: "s0"Checking SELinux Status in CI
#!/bin/bash
<span class="hljs-comment"># In CI on RHEL/CentOS nodes
<span class="hljs-comment"># Verify SELinux is enforcing
MODE=$(getenforce)
<span class="hljs-keyword">if [ <span class="hljs-string">"$MODE" != <span class="hljs-string">"Enforcing" ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"ERROR: SELinux is not Enforcing (mode: $MODE)"
<span class="hljs-built_in">echo <span class="hljs-string">"Security tests require Enforcing mode"
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-comment"># Verify policy module is loaded
<span class="hljs-keyword">if ! semodule -l <span class="hljs-pipe">| grep -q <span class="hljs-string">"my-app-policy"; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"ERROR: my-app-policy SELinux module not loaded"
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"SELinux: Enforcing, policy module loaded — OK"Shared Testing Patterns
Pattern 1: Before/After Policy Change Comparison
def get_avc_denials(process_name, duration_seconds=10):
import subprocess, time
start = time.time()
subprocess.run(["/usr/bin/my-app", "--smoke-test"])
result = subprocess.run(
["ausearch", "-m", "avc", "--start", "recent", "-c", process_name],
capture_output=True, text=True
)
return [l for l in result.stdout.splitlines() if "denied" in l]
def test_policy_change_doesnt_increase_denials():
"""New policy version should have same or fewer denials."""
baseline_denials = get_avc_denials("my-app")
# Deploy new policy
subprocess.run(["semodule", "-i", "my-app-policy-v2.pp"])
new_denials = get_avc_denials("my-app")
assert len(new_denials) <= len(baseline_denials), \
f"Policy change increased denials: {baseline_denials} -> {new_denials}"Pattern 2: Policy Syntax Validation in CI
name: MAC Policy CI
on:
push:
paths:
- 'security/apparmor/**'
- 'security/selinux/**'
jobs:
validate-apparmor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install AppArmor utils
run: sudo apt-get install -y apparmor-utils
- name: Parse and validate profiles
run: |
for profile in security/apparmor/*.profile; do
echo "Validating: $profile"
sudo apparmor_parser --preprocess "$profile"
echo "OK: $profile"
done
validate-selinux:
runs-on: fedora-latest
steps:
- uses: actions/checkout@v4
- name: Install SELinux tools
run: sudo dnf install -y checkpolicy policycoreutils
- name: Compile and check policy modules
run: |
for te_file in security/selinux/*.te; do
base="${te_file%.te}"
echo "Compiling: $te_file"
checkmodule -M -m -o "${base}.mod" "$te_file"
semodule_package -o "${base}.pp" -m "${base}.mod"
echo "OK: ${base}.pp"
doneCommon Mistakes
Profile too permissive in complain mode: Generating a profile from complain/permissive mode captures everything your app does, including unnecessary syscalls during setup. Run only the production paths, not setup scripts, when profiling.
Testing against the wrong profile: Verify which profile is actually loaded with aa-status (AppArmor) or ps -eZ (SELinux) before running tests. A test that passes because the profile isn't loaded is worse than no test.
Not testing the negative case: Always test that things you don't want to allow are actually blocked, not just that your application works. Both can be true simultaneously.
Kernel version mismatches: AppArmor and SELinux features vary by kernel version. A profile that works on Ubuntu 22.04 may behave differently on 20.04. Pin your kernel versions in CI.
Summary
Testing MAC policies requires two types of assertions:
- Positive: Your application runs correctly under the policy (functional tests pass)
- Negative: Dangerous access patterns are actually blocked (security tests)
For AppArmor:
- Generate with
aa-genprof, test withaa-exec, validate in complain mode first - Test in Kubernetes with SecurityContext or annotations
For SELinux:
- Generate with
audit2allow, test withruncon, validate AVC denial counts - Ensure nodes are in
Enforcingmode before running security tests
Both systems need CI integration that validates syntax, runs functional tests, and verifies restrictions on every policy change. Policies that can't be tested can't be trusted.