AppArmor and SELinux: Testing Mandatory Access Control Policies

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 = execute
  • k = file locking, l = link, m = mmap
  • d = 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 profile

The 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 -20

Test 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-container

Pre-1.30 annotation approach:

apiVersion: v1
kind: Pod
metadata:
  name: my-app
  annotations:
    container.apparmor.security.beta.kubernetes.io/app: localhost/my-container

Testing 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 -Z

SELinux 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 Permissive

Generating 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.pp

SELinux 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 = deny

Testing 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"
          done

Common 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:

  1. Positive: Your application runs correctly under the policy (functional tests pass)
  2. Negative: Dangerous access patterns are actually blocked (security tests)

For AppArmor:

  • Generate with aa-genprof, test with aa-exec, validate in complain mode first
  • Test in Kubernetes with SecurityContext or annotations

For SELinux:

  • Generate with audit2allow, test with runcon, validate AVC denial counts
  • Ensure nodes are in Enforcing mode 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.

Read more