Writing and Enforcing an AppArmor Profile for a Service
Building a working AppArmor mandatory access control profile from scratch using complain mode and the log-based profile generator, rather than guessing at rules upfront.
AppArmor is a mandatory access control (MAC) system that confines individual programs to a defined set of capabilities and file access permissions, path-based rather than label-based like SELinux, which in practice makes it noticeably more approachable to write a first working profile for — you’re reasoning about actual filesystem paths a program touches, not an abstract security-context labeling scheme.
Why confine a service at all beyond normal Unix permissions
Standard Unix file permissions and even running a service as an unprivileged user only go so far — if a service is compromised (through a vulnerability in the service itself or a dependency), the attacker inherits whatever that unprivileged user account can access, which is often still far more than the service actually needs to function. AppArmor profiles restrict a specific program to only the files, capabilities, and network access it’s been explicitly profiled to need, so a compromised service is contained to a much smaller blast radius regardless of what its running user account could otherwise reach.
Checking whether AppArmor is active
aa-status
This lists every loaded profile and whether each is in enforce mode (violations are blocked) or complain mode (violations are logged but allowed), plus any unconfined processes — worth checking first since profile-writing behavior differs meaningfully depending on which mode you’re starting from.
Starting a new profile in complain mode, not enforce mode
Writing a correct profile from scratch by guessing at every file a program needs is impractical for anything beyond a trivial service. The practical approach is generating a skeleton profile and running the service in complain mode first, so AppArmor logs every access the program attempts without actually blocking any of it:
aa-genprof /usr/bin/myservice
aa-genprof creates an initial profile and puts it into complain mode, then waits while you exercise the service’s normal functionality — starting it, running through its typical operations, triggering whatever code paths matter for how it’s actually used in practice.
Exercising the service, then reviewing what it actually touched
With the service running under the new complain-mode profile, use it normally — the entire point is capturing real, representative access patterns rather than a synthetic subset. Once you’ve exercised the functionality you care about, return to the aa-genprof prompt and let it scan the audit log:
Reading log entries from /var/log/audit/audit.log.
Updating AppArmor profiles in /etc/apparmor.d.
Profile: /usr/bin/myservice
Path: /etc/myservice/config.yaml
New Mode: r
[1 - Allow]
2 - Audit
3 - Deny
(A)llow / [(I)gnore] / (D)eny / Audit / Glob / Glob w/Ext / (N)ew / (C)ontinue profiling / (X)
For each logged access, aa-genprof proposes adding a corresponding rule and asks for a decision — allow, deny, or glob (turning a specific logged path into a wildcarded pattern covering a whole directory, useful for paths like log files where the exact filename varies but the directory doesn’t). Reviewing each proposed rule rather than blanket-approving everything is worth the extra attention here, since this step is exactly where you’d notice a service unexpectedly accessing something it has no legitimate reason to touch.
What a finished profile actually looks like
# /etc/apparmor.d/usr.bin.myservice
#include <tunables/global>
/usr/bin/myservice {
#include <abstractions/base>
/etc/myservice/*.yaml r,
/var/log/myservice/*.log w,
/var/run/myservice.pid rw,
network inet stream,
capability net_bind_service,
deny /home/** rwx,
}
Each line is a specific, narrow grant: read access to config files matching a glob, write access to its own log directory, read-write to its own PID file, permission to use inet stream sockets, and the specific net_bind_service capability (needed only if the service binds a privileged port below 1024). The explicit deny /home/** rwx line is a defense-in-depth statement — even though nothing in the profile positively grants access to user home directories, an explicit deny documents the intent and guards against a future, overly broad rule accidentally reopening that access.
Switching from complain to enforce mode
Once you’re confident the profile reflects everything the service legitimately needs (validated by having actually exercised its real functionality under complain mode without gaps), switch it to enforcing:
aa-enforce /usr/bin/myservice
From this point, any access attempt not covered by the profile is actually blocked, not just logged — which is why validating thoroughly under complain mode first matters: an incomplete profile pushed straight to enforce mode breaks the service the moment it hits a legitimate access pattern that complain mode simply never happened to exercise.
Handling a legitimate access blocked after going to enforce mode
If enforce mode blocks something legitimate that complain-mode testing missed, the audit log still records the denial:
journalctl -xe | grep -i apparmor
aa-logprof
aa-logprof re-scans new log entries against existing profiles the same way aa-genprof does initially, letting you add the missing rule without restarting the whole profile-building process from scratch.
The practical takeaway
Building an AppArmor profile by exercising real functionality under complain mode, reviewing each proposed rule deliberately rather than blanket-accepting, and only then switching to enforce mode is a meaningfully more reliable path than attempting to hand-write a complete profile upfront from documentation or guesswork — the log-driven, iterative approach directly captures what a program actually does, rather than what you assume it does.