Kubernetes Runtime Enforcement with KubeArmor


When I took my Linux Certified Sysadmin course in 2021, I was expecting a crash course in package managers, terminal commands, Bash scripting, storage, networking, etc. And I did get a ton of that — and more. There was one interesting part I didn’t put too much thought into: SELinux. For this course, it was a small sliver and didn’t stand out at all or seem practical. I probably should’ve asked, “Wait, the kernel already has a security system built in?”
That framework is called the Linux Security Module (LSM). It’s been part of the kernel for decades and allows access control decisions to be enforced at the lowest levels. Most systems already have an LSM enabled by default — with no configuration required. Two of the most common are SELinux and AppArmor.
I first learned SELinux through the LFCS course because I was operating on a CentOS VM on my Mac. But these days, I almost exclusively work on Ubuntu — which means I use AppArmor.
A Quick Look at SELinux and AppArmor
SELinux (Security-Enhanced Linux), originally developed by the NSA and integrated into the mainline Linux kernel in 2003 (version 2.6), uses a label-based Mandatory Access Control (MAC) model. It's widely adopted in Red Hat–based distributions like RHEL, CentOS, Fedora, and Amazon Linux. SELinux applies rules based on context — every file, process, and user has a security label — and uses policy definitions to control access based on those labels. Profiles (or “policies”) can also run in permissive or enforcing modes. These policies are defined using a domain-specific language (DSL) that’s powerful but equally challenging.
AppArmor, originally developed by Immunix and adopted early by SUSE and Ubuntu, was merged into the mainline Linux kernel in 2010 (version 2.6.36). It provides a path-based approach to Mandatory Access Control (MAC) and is widely used in Debian-based distributions today. Each profile you define controls which files, capabilities, and network access a process is allowed to use. Profiles can be permissive (audit only) or enforced. AppArmor policies are also defined using a domain-specific language (DSL), but it’s path-based and generally considered more readable and approachable than SELinux’s label-based system.
How AppArmor Actually Works
AppArmor lets you define what specific processes are allowed to access — files, capabilities, sockets, etc. You write a “profile” for a binary or process that defines its behavior boundaries. The kernel enforces that profile every time the process runs. If something goes outside those rules, it’s blocked (or logged, if you're just in complain mode).
What’s running by default? You might already have a handful of AppArmor profiles running. Here’s how you can check:
Command
sudo aa-status
Example Output
apparmor module is loaded.
148 profiles are loaded.
53 profiles are in enforce mode.
/usr/bin/man
/usr/lib/snapd/snap-confine
/usr/lib/snapd/snap-confine//mount-namespace-capture-helper
cri-containerd.apparmor.d
docker-default
Some of these are preloaded by your system for common services like rsyslog
or tcpdump
. These can be helpful examples — and they also remind us that enforcement might already be happening under the hood.
🚧 Heads up: AppArmor is powerful — but a misconfigured profile can block unexpected behavior or even break apps. That’s why you might consider starting with a profile in complain mode while testing. You’ll get visibility into what would have been blocked without stopping execution. Once things look clean, you can flip it to enforce.
A Practical AppArmor Example
Time to try it. Let’s build a tiny script, give it an AppArmor profile, and watch it do its thing — blocking behavior before it even happens. No containers, no orchestration — just Linux.
Step 1: The Script We Want to Control
Save this script as hello.sh
:
#!/bin/bash
echo "Hello from our AppArmor test!"
Make it executable:
chmod +x hello.sh
Now run it normally (without AppArmor):
./hello.sh
Expected Output:
Hello from our AppArmor test!
Cool, it works.
Step 2: Generate the Profile Using aa-genprof
We’re not going to create the profile manually. Instead, we’ll use aa-genprof
, a built-in tool that watches how a binary behaves and suggests a profile based on what it sees.
If you don’t already have the tooling:
sudo apt update && sudo apt install apparmor-profiles apparmor-utils
Now run the script under AppArmor’s profile generation mode:
sudo aa-genprof ./hello.sh
Expected Output:
Updating AppArmor profiles in /etc/apparmor.d.
Before you begin, you may wish to check if a
profile already exists for the application you
wish to confine. See the following wiki page for
more information:
https://gitlab.com/apparmor/apparmor/wikis/Profiles
Profiling: /home/matt/hello.sh
Please start the application to be profiled in
another window and exercise its functionality now.
Once completed, select the "Scan" option below in
order to scan the system logs for AppArmor events.
For each AppArmor event, you will be given the
opportunity to choose whether the access should be
allowed or denied.
[(S)can system log for AppArmor events] / (F)inish
The tool will ask you to run the script in another terminal and report back. It’ll monitor file access, commands used, and prompt you to approve or deny each behavior. It works — and it’s a great way to build a minimal profile from observed behavior.
After running the script in a separate terminal, enter S
to scan the logs. You’ll be walked through options for how to configure the profile.
Expected Output:
Reading log entries from /var/log/audit/audit.log.
Complain-mode changes:
Profile: /home/matt/hello.sh
Path: /dev/tty
New Mode: rw
Severity: 9
[1 - include <abstractions/consoles>]
2 - /dev/tty rw,
(A)llow / [(D)eny] / (I)gnore / (G)lob / Glob with (E)xtension / (N)ew / Audi(t) / Abo(r)t / (F)inish
AppArmor will provide prompts for all detected actions. When it asks whether to allow hello.sh
to access /dev/tty
, it’s offering a policy rule — a discrete permission that defines whether your process can read or write to that path.
In this case, it’s suggesting either the full path rule (/dev/tty rw,
) or using the abstraction <abstractions/consoles>
, which is kind of like a macro. The abstraction is generally best for common I/O, as it also includes /dev/console
and /dev/pts
.
To allow it, simply enter A
for Allow.
Here’s what that abstraction looks like:
cat /etc/apparmor.d/abstractions/consoles
# vim:syntax=apparmor
# ------------------------------------------------------------------
# Copyright (C) 2002-2005 Novell/SUSE
# This program is free software; you can redistribute it and/or
# modify it under the terms of version 2 of the GNU General Public
# License published by the Free Software Foundation.
# ------------------------------------------------------------------
abi <abi/4.0>,
# there are three common ways to refer to consoles
/dev/console rw,
/dev/tty rw,
# this next entry is a tad unfortunate; /dev/tty will always be
# associated with the controlling terminal by the kernel, but if a
# program uses the /dev/pts/ interface, it actually has access to
# -all- xterm, sshd, etc., terminals on the system.
/dev/pts/[0-9]* rw,
/dev/pts/ r,
# Include additions to the abstraction
include if exists <abstractions/consoles.d>
The abstraction used in our profile
Once complete, you’ll get a new profile saved to /etc/apparmor.d/<path>.hello.sh
. You can confirm it’s loaded with:
sudo aa-status
Expected Output:
apparmor module is loaded.
149 profiles are loaded.
54 profiles are in enforce mode.
/home/matt/hello.sh
Sweet — good to go on our test now.
Step 3: Enforce the Profile
Now let’s see the payoff.
Updated Script:
#!/bin/bash
echo "Hello from our AppArmor test!"
echo "recon stuff" > /tmp/recon.txt
Now run the script again. We should see the write attempt blocked:
./hello.sh
Expected Output:
Hello from our AppArmor test!
./hello.sh: line 3: /tmp/recon.txt: Permission denied
AppArmor didn’t block the write to /tmp
because /tmp
is dangerous or globally off-limits — it blocked it because our profile never explicitly allowed the script to write there. AppArmor uses a strict allowlist approach:
"Only what's declared in the profile is permitted. Everything else is denied by default."
Not gonna lie — that was a decent amount of work just to create a profile for a single script. LSMs like AppArmor are incredibly powerful, but they weren’t exactly built for convenience. Still, with just a couple of commands — and nothing to install except a few helper tools — we’ve got a script that can’t write. No agents, no sidecars, no drama. Just good old built-in Linux security doing its job. Think of it like the fries in a Cali Burrito — the layer you didn’t know you needed, but now can’t live without. That’s enforcement, straight from the kernel.
Now for Our Pods
So far, we’ve been completely devoid of Kubernetes. So how can we leverage the built-in Linux security capabilities with Kubernetes? I first came across this in the CKS exam — buried in the security domain alongside all the usual RBAC and NetworkPolicy topics. Turns out, Kubernetes supports AppArmor natively. There’s no sidecar, no custom controller — it’s just there.
We can do this through the Pod Security Context. The good news? Since Kubernetes 1.31, AppArmor integration is finally straightforward. No annotations — just a proper security context and a supported host OS. Let’s walk through it step by step.
Using AppArmor with Kubernetes, let’s apply the same idea — blocking access to writing to /tmp
— but inside a Kubernetes pod.
Step 1: Create an AppArmor profile
We’ll create a super minimal profile here in the /etc/apparmor.d/
directory with just denying file writes to /tmp/
and nested directories /tmp/**
. There isn’t anything really special here, compared to our profile created earlier via aa-genprof
. The attach_disconnected
is from the Kubernetes docs and is helpful with containers.
#include <tunables/global>
profile k8s-apparmor-example-deny-tmp-write flags=(attach_disconnected) {
#include <abstractions/base>
file,
# Deny all /tmp file writes.
deny /tmp/** w,
deny /tmp/ w,
}
We then need to load this, and we’re completely done on the AppArmor side.
sudo apparmor_parser -r /etc/apparmor.d/k8s-apparmor-example-deny-tmp-write
Step 2: Create a Pod spec that references our AppArmor profile
Nothing new except adding in our appArmorProfile
. The selection of Localhost
allows us to use a profile locally created on the node.
apiVersion: v1
kind: Pod
metadata:
name: tmp-writer
spec:
containers:
- name: tmp-writer
image: busybox
command: ["/bin/sh", "-c"]
args: ["echo 'test' > /tmp/test.txt; sleep 60"]
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-apparmor-example-deny-tmp-write
restartPolicy: Never
Step 3: Enforce the Profile
Now we can launch our pod that references it.
kubectl apply -f tmp-writer.yaml
Then, once the pod is running:
kubectl logs tmp-writer
Expected Output
/bin/sh: can't create /tmp/test.txt: Permission denied
Presto. We’ve now leveraged AppArmor with Kubernetes to block certain behaviors before they ever occur.
But It’s Not All Smooth Sailing
While this works, it’s clearly not the most portable or scalable setup. You have to:
- Install profiles on every node
- Ensure AppArmor is enabled in the OS
- Reference the profile directly in every Pod spec
- And this only works on compatible hosts
Up Next: KubeArmor
In the next post, we’ll explore how KubeArmor makes this all easier — handling profile generation, policy enforcement, and runtime visibility across your entire Kubernetes cluster. No annotations. No hardcoded profiles. No node-by-node hassle. It’s the protein-packed middle layer of the Cali Burrito — runtime protection with Kubernetes-native flavor.
Stay tuned.
Subscribe to my newsletter
Read articles from Matt Brown directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Matt Brown
Matt Brown
Working as a solutions architect while going deep on Kubernetes security — prevention-first thinking, open source tooling, and a daily rabbit hole of hands-on learning. I make the mistakes, then figure out how to fix them (eventually).