How AppArmor Profiles Actually Work
AppArmor attaches a profile to a binary path, not a PID or user. The kernel LSM hook intercepts every syscall that touches a file, network socket, capability, or signal, then checks the loaded policy for that executable. If the operation is not explicitly allowed, it is either denied silently (enforce mode) or logged without blocking (complain mode).
Profiles live in /etc/apparmor.d/ and are loaded into the kernel with apparmor_parser. The active set is visible in /sys/kernel/security/apparmor/profiles. A profile named /usr/sbin/nginx covers the binary at exactly that path - a symlink to a different location is treated as a different profile attachment point unless you add an alias directive.
The kernel evaluates rules top to bottom within each rule type, but file rules and network rules are separate namespaces. The most permissive matching rule wins for file access. For capabilities, any matching allow rule grants the capability regardless of order. This asymmetry trips up engineers who expect firewall-style first-match semantics.
# Check currently loaded profiles and their modes
cat /sys/kernel/security/apparmor/profiles | sort
# Or use the userspace tool
aa-status --json | python3 -m json.tool | head -60
Install the Tooling and Verify Kernel Support
On Ubuntu 24.04 and Debian 12 the kernel ships with AppArmor enabled by default. On systems where you compiled your own kernel, verify CONFIG_SECURITY_APPARMOR=y and CONFIG_DEFAULT_SECURITY_APPARMOR=y are set, then add security=apparmor to your kernel command line.
The userspace tools you need are in apparmor-utils. The aa-genprof and aa-logprof tools read from the audit log or syslog, so you also need auditd running - without it the learning mode has no data to work from.
# Debian/Ubuntu
apt install apparmor apparmor-utils auditd
# Verify kernel support
grep apparmor /boot/config-$(uname -r)
# Should show: CONFIG_SECURITY_APPARMOR=y
# Check LSM stack order (AppArmor must be listed)
cat /sys/kernel/security/lsm
# Example output: lockdown,capability,landlock,yama,apparmor
# Confirm AppArmor is active
apparmor_status | head -5
Generate a Skeleton with aa-genprof
aa-genprof is the fastest way to get a usable starting point. It puts the target binary into complain mode, watches the audit log while you exercise the application, then prompts you to allow or deny each observed access. The output is a profile you can refine.
Run aa-genprof in one terminal, then in a second terminal exercise the application through its full startup, normal operation, reload, and shutdown cycle. Hit 'S' in aa-genprof to scan the log at any point. The more paths you exercise, the fewer gaps you will need to fill manually later.
aa-genprof is not a replacement for reading the profile it produces. Generated profiles routinely include overly broad globs like /proc/** r because the profiler sees a single /proc/sys/kernel/hostname read and generalizes. Always audit the output.
# Terminal 1 - start profiling (runs the binary in complain mode)
sudo aa-genprof /usr/local/bin/myapp
# Terminal 2 - exercise the application
/usr/local/bin/myapp --config /etc/myapp/myapp.conf
# Run through all code paths: startup, API calls, log rotation, shutdown
# Back in Terminal 1
# Press 'S' to scan and review each access
# Press 'F' when done to finish and write the profile
# The profile lands in /etc/apparmor.d/usr.local.bin.myapp
cat /etc/apparmor.d/usr.local.bin.myapp
Profile Syntax: The Parts That Matter
A minimal profile has three sections: the header (profile name and flags), file rules, and capability rules. Everything else - network, signal, ptrace, dbus - is optional and defaults to deny.
File rules use the format: path permissions. Permissions are a subset of r (read), w (write), a (append), x (execute), m (mmap with PROT_EXEC), k (lock), l (link). The 'ix' execute permission inherits the current profile; 'cx' transitions to a child profile; 'px' transitions to a named profile; 'ux' runs the child unconfined (avoid this).
Capabilities map directly to Linux POSIX capabilities. If your daemon calls bind() on port 80 it needs capability net_bind_service. If it calls chown() it needs capability chown. The capability list in the kernel headers is authoritative - apparmor uses the same names as /usr/include/linux/capability.h.
Network rules in AppArmor 3.x use two forms. The legacy form 'network inet tcp' allows all TCP over IPv4. The newer 'network inet stream' is equivalent. For finer control you need the af_unix socket mediation or nftables - AppArmor alone cannot filter by destination IP or port number in the current mainline implementation.
# /etc/apparmor.d/usr.local.bin.myapp
#include
/usr/local/bin/myapp {
#include
# Binary and libraries
/usr/local/bin/myapp mr,
/usr/local/lib/myapp/** mr,
# Config (read-only)
/etc/myapp/ r,
/etc/myapp/*.conf r,
# Runtime data (read/write)
/var/lib/myapp/ rw,
/var/lib/myapp/** rw,
# Logs (append only - tighter than rw)
/var/log/myapp/ rw,
/var/log/myapp/*.log a,
# PID file
/run/myapp.pid rw,
# Shared libraries (via abstraction is better, but explicit for clarity)
/lib/x86_64-linux-gnu/libssl.so.* mr,
/lib/x86_64-linux-gnu/libcrypto.so.* mr,
# Network
network inet stream,
network inet6 stream,
# Capabilities
capability net_bind_service,
capability setuid,
capability setgid,
# Deny sensitive paths explicitly (belt-and-suspenders)
deny /etc/shadow r,
deny /proc/sysrq-trigger rw,
deny /sys/kernel/debug/** rwx,
}
Abstractions: Stop Reinventing Common Rules
AppArmor ships with a library of abstractions in /etc/apparmor.d/abstractions/. These are fragments you include in your profile to cover common patterns without listing every file. The most useful ones for server software are:
- abstractions/base: glibc, NSS, locale, basic /proc reads. Include this in every profile. - abstractions/nameservice: DNS resolution via /etc/hosts, /etc/resolv.conf, NSS modules. Any network-facing daemon needs this. - abstractions/openssl: libssl, libcrypto, and certificate stores under /etc/ssl/. Include for anything that speaks TLS. - abstractions/python: Python interpreter paths, site-packages access. Use if confining a Python service. - abstractions/ssl_certs: Read access to /etc/ssl/certs/ and /usr/share/ca-certificates/. Often needed separately from openssl.
Before writing a raw file rule, check whether an abstraction already covers it. Abstractions are also maintained by the distribution - including them means you inherit upstream fixes automatically.
# List available abstractions
ls /etc/apparmor.d/abstractions/
# Read one to understand its scope before including
cat /etc/apparmor.d/abstractions/nameservice
# In your profile header section:
#include
#include
#include
Complain Mode, Enforce Mode, and the Transition Workflow
New profiles should always start in complain mode. In complain mode AppArmor logs every denial that would have occurred but does not block anything. You iterate: run the application, read the audit log, update the profile, reload, repeat.
Use aa-logprof to process audit log entries interactively. It reads /var/log/audit/audit.log (with auditd) or /var/log/syslog (without), groups denials by profile and path, and presents choices: allow the specific path, allow with a glob, add an abstraction, or deny explicitly. This is faster than editing the profile by hand for long tails of missing rules.
When aa-logprof produces no new suggestions after a full application exercise cycle, switch to enforce. Monitor for the first 48 hours in production using aa-status and grep on audit.log. A single missed code path in testing - a weekly cron job, a SIGHUP reload, an error handler - can cause a production denial under enforcement.
reload vs. restart: apparmor_parser --replace loads a new version of a profile into a running kernel without restarting AppArmor itself. The confined process picks up the new rules immediately without restart.
# Put a specific profile into complain mode
aa-complain /etc/apparmor.d/usr.local.bin.myapp
# Or by binary path
aa-complain /usr/local/bin/myapp
# Process log entries interactively
sudo aa-logprof
# Reload a profile after editing (no apparmor restart needed)
sudo apparmor_parser --replace /etc/apparmor.d/usr.local.bin.myapp
# Enforce the profile
aa-enforce /usr/local/bin/myapp
# Watch for denials in real time
sudo journalctl -f | grep -i apparmor
# Or from audit.log directly
sudo tail -f /var/log/audit/audit.log | grep AVC
Handling Child Processes and exec Transitions
Most non-trivial daemons exec helper binaries - shell scripts, external tools, compiled helpers. Each exec call requires an explicit rule in the parent profile. Forgetting this is the most common cause of 'works in complain, breaks in enforce' bugs.
The execute permission modifiers determine what profile the child runs under. Use 'ix' (inherit) only when the child is tightly coupled and you intend it to share the parent's policy. Use 'px' (profile transition) when the child has its own profile and should run under it. Use 'Cx' (local child profile) when you want an inline profile defined within the parent profile file.
For shell scripts called by a daemon, a common pattern is to use 'ix' for /bin/bash or /bin/sh and then restrict what the script can do within the inherited profile. This is safer than 'ux' (unconfined execution) and simpler than writing a separate profile for each script.
For tools like curl, openssl, or ssh called as subprocesses, check whether a system profile already exists in /etc/apparmor.d/ before writing a new one. If a system profile exists, 'px' to it is the right call.
# Within your profile - exec rules
/usr/local/bin/myapp {
# ...
# Shell scripts the daemon calls - inherit profile
/bin/bash ix,
/bin/sh ix,
/usr/local/bin/myapp-helper ix,
# Transition to existing system profile for curl
/usr/bin/curl px,
# Inline child profile for a specific helper
/usr/local/bin/myapp-reload Cx -> myapp-reload,
profile myapp-reload {
#include
/etc/myapp/*.conf r,
/usr/local/bin/myapp-reload mr,
signal (receive) set=(hup) peer=/usr/local/bin/myapp,
}
}
Containerized Workloads and Docker Integration
Docker has shipped a default AppArmor profile (docker-default) since Docker 1.13. When you run a container, Docker loads this profile automatically on AppArmor-enabled systems. You can verify this inside a running container with cat /proc/self/attr/current.
For custom container workloads with specific requirements, write a profile, load it into the kernel on the host, and reference it at runtime with --security-opt. The profile runs on the host kernel and applies to all processes inside the container namespace.
One practical pattern: a profile for a data processing container might need to write to /tmp but nothing else. The docker-default profile allows /tmp writes, but you can create a tighter profile that denies network access entirely for an offline batch job. This is more reliable than relying solely on network namespace isolation.
For Kubernetes, the equivalent is a Pod SecurityContext with appArmorProfile (GA in Kubernetes 1.30) or the older annotation format for clusters below 1.30. The profile must be loaded on every node where the pod can schedule - a common operational gap that causes CrashLoopBackOff entries that look like application bugs.
# Load your custom profile on the host
sudo apparmor_parser -r /etc/apparmor.d/docker-myapp-batch
# Run container with the custom profile
docker run --security-opt apparmor=docker-myapp-batch myimage
# Kubernetes pod spec (1.30+ GA syntax)
# spec:
# securityContext:
# appArmorProfile:
# type: Localhost
# localhostProfile: docker-myapp-batch
# Verify inside the container
docker exec cat /proc/self/attr/current
# Output: docker-myapp-batch (enforce)
Automating Profile Deployment
In a fleet environment, loading AppArmor profiles by hand is not viable. The standard approach is to package profiles with the application, use configuration management to distribute them, and trigger apparmor_parser on each host after deployment.
With Ansible, the apparmor module handles profile loading and mode switching. For Puppet, the apparmor class in the puppetlabs-apparmor module covers the same. For teams running GitOps pipelines, profiles live in the application repository alongside the service unit file and are applied as part of the deployment pipeline.
If you are evaluating AI-driven DevOps automation tools to handle drift detection and remediation for security policy files, taskbotshub.ai offers automation workflows that can monitor profile state across nodes and trigger reconciliation when a profile is removed or reverted. This is useful in environments where nodes are frequently rebuilt from golden images that may not include custom profiles.
The key operational requirement is idempotent loading. apparmor_parser --replace is idempotent - running it twice with the same profile has no effect beyond the second load. Make this the default in your deployment scripts rather than checking current state first.
# Ansible task example
- name: Deploy AppArmor profile for myapp
copy:
src: files/apparmor/usr.local.bin.myapp
dest: /etc/apparmor.d/usr.local.bin.myapp
owner: root
group: root
mode: '0644'
notify: reload apparmor profile
# Ansible handler
- name: reload apparmor profile
command: apparmor_parser --replace /etc/apparmor.d/usr.local.bin.myapp
# Shell script for CI/CD pipeline
#!/bin/bash
set -euo pipefail
PROFILE_DIR="/etc/apparmor.d"
SOURCE_DIR="./apparmor-profiles"
for profile in "${SOURCE_DIR}"/*; do
dest="${PROFILE_DIR}/$(basename "${profile}")"
cp "${profile}" "${dest}"
apparmor_parser --replace "${dest}"
echo "Loaded: ${dest}"
done
Debugging Denials Without Losing Your Mind
An AppArmor denial in audit.log looks verbose but is structured. The key fields are: operation (what syscall category), profile (which profile fired), name (the path or resource), and denied_mask (what permissions were denied).
The most common debugging mistake is reloading a profile without restarting the confined process. AppArmor applies new rules to the process immediately for new operations, but if the binary has already mmap'd a library that is now denied, that memory stays mapped. For most denials, reload the profile and send SIGHUP to the daemon to re-read config without full restart. For library-related denials, a full process restart is required.
When a process silently fails and you suspect AppArmor, use strace to identify the failing syscall, then cross-reference with audit.log. If auditd is not running, AppArmor denials land in /var/log/syslog under the kernel facility. The string to grep for is 'apparmor="DENIED"'.
For intermittent denials that only appear during edge cases, enable verbose logging temporarily with: echo 1 > /sys/module/apparmor/parameters/debug. This produces significantly more log volume - disable it after capturing the relevant trace.
# Parse a denial from audit.log
grep 'apparmor="DENIED"' /var/log/audit/audit.log | tail -20
# Example denial line (reformatted for readability):
# type=AVC msg=audit(1724400123.456:789): apparmor="DENIED"
# operation="open" profile="/usr/local/bin/myapp"
# name="/etc/ssl/private/server.key"
# pid=4521 comm="myapp" requested_mask="r" denied_mask="r"
# fsuid=1001 ouid=0
# The fix: add the missing rule
# /etc/ssl/private/server.key r,
# Then reload
apparmor_parser --replace /etc/apparmor.d/usr.local.bin.myapp
# Check if a specific process is confined
cat /proc/$(pgrep myapp)/attr/current
# Enable debug logging (temporary, verbose)
echo 1 | sudo tee /sys/module/apparmor/parameters/debug
Naming Profiles and Managing Profile Files at Scale
The convention for profile filenames in /etc/apparmor.d/ is to replace path separators with dots: the profile for /usr/local/bin/myapp becomes usr.local.bin.myapp. This is a convention, not a requirement - the profile name that matters to the kernel is the path in the profile header, not the filename on disk. However, following the convention keeps apparmor_status output readable and prevents conflicts.
For software you distribute or publish internally, profile naming gets more important when the binary name collides with another package. If you are registering a domain or choosing a canonical project name for an internal tool, consistent naming from the start - the same name used in the binary path, the systemd unit, the profile file, and the package name - saves significant refactoring later. Services like nicename.me can help check name availability across registries and namespaces when you are establishing a new project's identity before you have profiles and packages to rename.
For large fleets running 50 or more custom profiles, store profiles in a git repository organized by application, not by hostname. The directory structure should mirror /etc/apparmor.d/ so rsync or configuration management can deploy without transformation. Tag each profile with the minimum AppArmor version it requires using a comment at the top of the file - AppArmor 3.x added profile namespacing features that do not parse on 2.x kernels.
# Recommended repository layout
apparmor-profiles/
usr.local.bin.myapp # AppArmor >= 3.0
usr.local.bin.myapp-worker # AppArmor >= 3.0
usr.sbin.nginx-custom # AppArmor >= 2.13
abstractions/
myapp-common # Shared fragment included by myapp profiles
# Shared abstraction fragment (abstractions/myapp-common)
# Include in each myapp profile:
#include
# myapp-common content:
/etc/myapp/ r,
/etc/myapp/*.conf r,
/var/log/myapp/ rw,
/var/log/myapp/*.log a,
Testing Profiles Before Production
Two tools are worth using before promoting a profile to enforce on production: apparmor_parser --preprocess and aa-exec.
apparmor_parser --preprocess expands all includes and prints the resolved profile to stdout without loading it. This lets you audit exactly what the kernel will see, catch typos in paths, and verify that abstractions are resolving correctly. Run this in CI against every profile change.
aa-exec lets you run a command under a specific AppArmor profile without installing the profile permanently. This is useful for testing a new profile against a real binary in a staging environment without touching /etc/apparmor.d/. Combine it with strace to see exactly which operations are being attempted and which are being denied.
For integration testing, the approach we use on our test servers is: load the profile in complain mode, run the full application test suite, grep audit.log for any denials that would have fired, add those rules, then switch to enforce and run the test suite again. Zero denials in enforce mode is the threshold before promotion.
# Preprocess and inspect the resolved profile
apparmor_parser --preprocess /etc/apparmor.d/usr.local.bin.myapp
# Run a binary under a profile without permanent installation
# (profile must be loaded first, even if not attached)
apparmor_parser --add /etc/apparmor.d/usr.local.bin.myapp
aa-exec -p /usr/local/bin/myapp -- /usr/local/bin/myapp --test-mode
# Syntax check only (no load)
apparmor_parser --debug /etc/apparmor.d/usr.local.bin.myapp 2>&1 | head -20
# Count denials after a test run
grep -c 'apparmor="DENIED".*myapp' /var/log/audit/audit.log