User Process │ └── syscall() ← ring 3 → ring 0 transition │ ├── seccomp filter (classic BPF) │ │ │ ├── KILL / ERRNO / TRAP / NOTIFY → exit here, never reaches kernel │ └── ALLOW → continue │ ├── kernel executes syscall logic │ │ │ └── LSM hooks fire (AppArmor / SELinux) │ │ │ ├── path / capability / network label check │ └── DENY → EACCES, operation aborted │ ├── capability checks (if privileged op requested) │ │ │ └── e.g. CAP_SYS_ADMIN for mount(), CAP_NET_RAW for raw sockets │ └── actual resource access (filesystem, network, IPC)
User Process │ └── syscall() ← ring 3 → ring 0 transition │ ├── seccomp filter (classic BPF) │ │ │ ├── KILL / ERRNO / TRAP / NOTIFY → exit here, never reaches kernel │ └── ALLOW → continue │ ├── kernel executes syscall logic │ │ │ └── LSM hooks fire (AppArmor / SELinux) │ │ │ ├── path / capability / network label check │ └── DENY → EACCES, operation aborted │ ├── capability checks (if privileged op requested) │ │ │ └── e.g. CAP_SYS_ADMIN for mount(), CAP_NET_RAW for raw sockets │ └── actual resource access (filesystem, network, IPC)
User Process │ └── syscall() ← ring 3 → ring 0 transition │ ├── seccomp filter (classic BPF) │ │ │ ├── KILL / ERRNO / TRAP / NOTIFY → exit here, never reaches kernel │ └── ALLOW → continue │ ├── kernel executes syscall logic │ │ │ └── LSM hooks fire (AppArmor / SELinux) │ │ │ ├── path / capability / network label check │ └── DENY → EACCES, operation aborted │ ├── capability checks (if privileged op requested) │ │ │ └── e.g. CAP_SYS_ADMIN for mount(), CAP_NET_RAW for raw sockets │ └── actual resource access (filesystem, network, IPC)
securityContext: appArmorProfile: type: Localhost localhostProfile: my-org/nginx-v2
securityContext: appArmorProfile: type: Localhost localhostProfile: my-org/nginx-v2
securityContext: appArmorProfile: type: Localhost localhostProfile: my-org/nginx-v2
apiVersion: security-profiles-operator.x-k8s.io/v1alpha1
kind: AppArmorProfile
metadata: name: nginx-restricted # pods reference this name in appArmorProfile.localhostProfile namespace: production # profile is scoped to this namespace
spec: policy: | #include <tunables/global> # defines @{PROC}, @{HOME} and other path variables profile nginx-restricted flags=(attach_disconnected) { # attach_disconnected: allow profile to apply even if the binary path # isn't reachable at load time (common in containers with overlayfs) #include <abstractions/base> # allows libc, locale files, /dev/null etc. #include <abstractions/nameservice> # allows DNS resolution (/etc/resolv.conf, nsswitch) # Allow outbound TCP only — no UDP, no raw sockets network inet tcp, network inet6 tcp, # Binary: map+read+execute (mr). Denies writes to the nginx binary itself. /usr/sbin/nginx mr, /etc/nginx/** r, # read-only access to all nginx config files /var/log/nginx/** w, # write access for access/error logs /var/cache/nginx/** rw, # read+write for proxy cache and temp files /tmp/** rw, # read+write for nginx temp upload/body buffers # Explicit denials — these take precedence over any allow rules above deny /proc/sys/kernel/core_pattern w, # prevent overwriting core dump handler (container escape vector) deny @{PROC}/*/mem rw, # prevent reading/writing any process's memory deny /sys/** w, # prevent writing to sysfs (kernel tunable manipulation) }
apiVersion: security-profiles-operator.x-k8s.io/v1alpha1
kind: AppArmorProfile
metadata: name: nginx-restricted # pods reference this name in appArmorProfile.localhostProfile namespace: production # profile is scoped to this namespace
spec: policy: | #include <tunables/global> # defines @{PROC}, @{HOME} and other path variables profile nginx-restricted flags=(attach_disconnected) { # attach_disconnected: allow profile to apply even if the binary path # isn't reachable at load time (common in containers with overlayfs) #include <abstractions/base> # allows libc, locale files, /dev/null etc. #include <abstractions/nameservice> # allows DNS resolution (/etc/resolv.conf, nsswitch) # Allow outbound TCP only — no UDP, no raw sockets network inet tcp, network inet6 tcp, # Binary: map+read+execute (mr). Denies writes to the nginx binary itself. /usr/sbin/nginx mr, /etc/nginx/** r, # read-only access to all nginx config files /var/log/nginx/** w, # write access for access/error logs /var/cache/nginx/** rw, # read+write for proxy cache and temp files /tmp/** rw, # read+write for nginx temp upload/body buffers # Explicit denials — these take precedence over any allow rules above deny /proc/sys/kernel/core_pattern w, # prevent overwriting core dump handler (container escape vector) deny @{PROC}/*/mem rw, # prevent reading/writing any process's memory deny /sys/** w, # prevent writing to sysfs (kernel tunable manipulation) }
apiVersion: security-profiles-operator.x-k8s.io/v1alpha1
kind: AppArmorProfile
metadata: name: nginx-restricted # pods reference this name in appArmorProfile.localhostProfile namespace: production # profile is scoped to this namespace
spec: policy: | #include <tunables/global> # defines @{PROC}, @{HOME} and other path variables profile nginx-restricted flags=(attach_disconnected) { # attach_disconnected: allow profile to apply even if the binary path # isn't reachable at load time (common in containers with overlayfs) #include <abstractions/base> # allows libc, locale files, /dev/null etc. #include <abstractions/nameservice> # allows DNS resolution (/etc/resolv.conf, nsswitch) # Allow outbound TCP only — no UDP, no raw sockets network inet tcp, network inet6 tcp, # Binary: map+read+execute (mr). Denies writes to the nginx binary itself. /usr/sbin/nginx mr, /etc/nginx/** r, # read-only access to all nginx config files /var/log/nginx/** w, # write access for access/error logs /var/cache/nginx/** rw, # read+write for proxy cache and temp files /tmp/** rw, # read+write for nginx temp upload/body buffers # Explicit denials — these take precedence over any allow rules above deny /proc/sys/kernel/core_pattern w, # prevent overwriting core dump handler (container escape vector) deny @{PROC}/*/mem rw, # prevent reading/writing any process's memory deny /sys/** w, # prevent writing to sysfs (kernel tunable manipulation) }
# File rules
/etc/nginx/** r # read all files under /etc/nginx
/var/log/nginx/*.log w # write to log files
/tmp/nginx-*/ rw # read/write temp directories
/run/nginx.pid rw # read/write PID file # Capability rules
capability net_bind_service, # allow binding to ports < 1024
capability dac_override, # override file permission checks (avoid if possible) # Network rules
network inet tcp,
network inet6 tcp,
deny network raw, # deny raw sockets explicitly # Deny dangerous kernel paths explicitly
deny /proc/sys/kernel/** w,
deny @{PROC}/*/maps r, # prevent reading process memory maps (explicit deny required)
# File rules
/etc/nginx/** r # read all files under /etc/nginx
/var/log/nginx/*.log w # write to log files
/tmp/nginx-*/ rw # read/write temp directories
/run/nginx.pid rw # read/write PID file # Capability rules
capability net_bind_service, # allow binding to ports < 1024
capability dac_override, # override file permission checks (avoid if possible) # Network rules
network inet tcp,
network inet6 tcp,
deny network raw, # deny raw sockets explicitly # Deny dangerous kernel paths explicitly
deny /proc/sys/kernel/** w,
deny @{PROC}/*/maps r, # prevent reading process memory maps (explicit deny required)
# File rules
/etc/nginx/** r # read all files under /etc/nginx
/var/log/nginx/*.log w # write to log files
/tmp/nginx-*/ rw # read/write temp directories
/run/nginx.pid rw # read/write PID file # Capability rules
capability net_bind_service, # allow binding to ports < 1024
capability dac_override, # override file permission checks (avoid if possible) # Network rules
network inet tcp,
network inet6 tcp,
deny network raw, # deny raw sockets explicitly # Deny dangerous kernel paths explicitly
deny /proc/sys/kernel/** w,
deny @{PROC}/*/maps r, # prevent reading process memory maps (explicit deny required)
# If /allowed-dir is permitted and an attacker can bind-mount /etc/shadow there:
mount --bind /etc/shadow /allowed-dir/shadow # now readable via allowed path
# If /allowed-dir is permitted and an attacker can bind-mount /etc/shadow there:
mount --bind /etc/shadow /allowed-dir/shadow # now readable via allowed path
# If /allowed-dir is permitted and an attacker can bind-mount /etc/shadow there:
mount --bind /etc/shadow /allowed-dir/shadow # now readable via allowed path
# Load a profile in complain mode (logs denials, doesn't enforce)
apparmor_parser -C /etc/apparmor.d/my-profile # Watch would-be denials in real time
journalctl -k -f | grep apparmor
# Load a profile in complain mode (logs denials, doesn't enforce)
apparmor_parser -C /etc/apparmor.d/my-profile # Watch would-be denials in real time
journalctl -k -f | grep apparmor
# Load a profile in complain mode (logs denials, doesn't enforce)
apparmor_parser -C /etc/apparmor.d/my-profile # Watch would-be denials in real time
journalctl -k -f | grep apparmor
AppArmor Coverage vs. Threat Severity HIGH │ ✗ Kernel CVE bypass ║ ✓ Cgroup release_agent escape │ ✗ In-memory / ROP chain ║ ✓ Write to /sys or /proc/kernel S │ ✗ Network exfiltration ║ ✓ Service account token read E │ ✗ Misloaded profile (silent) ║ ✓ Read /proc/*/maps (recon) V │ ✗ Path traversal/bind mount ║ E ─────┼──────────────────────────────╫────────────────────────────────
R │ ║ I │ (no threats here — ║ ✓ Raw socket creation T │ low severity threats ║ Y │ not covered by AA ║ │ are acceptable risk) ║ LOW │ ║ └──────────────────────────────╨──────────────────────────────── LOW COVERAGE HIGH COVERAGE ◄── AppArmor Coverage ──► ✗ = AppArmor does NOT cover this threat (needs other controls) ✓ = AppArmor blocks this (if profile is correctly written)
AppArmor Coverage vs. Threat Severity HIGH │ ✗ Kernel CVE bypass ║ ✓ Cgroup release_agent escape │ ✗ In-memory / ROP chain ║ ✓ Write to /sys or /proc/kernel S │ ✗ Network exfiltration ║ ✓ Service account token read E │ ✗ Misloaded profile (silent) ║ ✓ Read /proc/*/maps (recon) V │ ✗ Path traversal/bind mount ║ E ─────┼──────────────────────────────╫────────────────────────────────
R │ ║ I │ (no threats here — ║ ✓ Raw socket creation T │ low severity threats ║ Y │ not covered by AA ║ │ are acceptable risk) ║ LOW │ ║ └──────────────────────────────╨──────────────────────────────── LOW COVERAGE HIGH COVERAGE ◄── AppArmor Coverage ──► ✗ = AppArmor does NOT cover this threat (needs other controls) ✓ = AppArmor blocks this (if profile is correctly written)
AppArmor Coverage vs. Threat Severity HIGH │ ✗ Kernel CVE bypass ║ ✓ Cgroup release_agent escape │ ✗ In-memory / ROP chain ║ ✓ Write to /sys or /proc/kernel S │ ✗ Network exfiltration ║ ✓ Service account token read E │ ✗ Misloaded profile (silent) ║ ✓ Read /proc/*/maps (recon) V │ ✗ Path traversal/bind mount ║ E ─────┼──────────────────────────────╫────────────────────────────────
R │ ║ I │ (no threats here — ║ ✓ Raw socket creation T │ low severity threats ║ Y │ not covered by AA ║ │ are acceptable risk) ║ LOW │ ║ └──────────────────────────────╨──────────────────────────────── LOW COVERAGE HIGH COVERAGE ◄── AppArmor Coverage ──► ✗ = AppArmor does NOT cover this threat (needs other controls) ✓ = AppArmor blocks this (if profile is correctly written)
{ "syscalls": [ { "names": ["clone"], "action": "SCMP_ACT_ALLOW", "args": [ { "index": 0, "value": 2114060288, "op": "SCMP_CMP_MASKED_EQ" } ] } ]
}
{ "syscalls": [ { "names": ["clone"], "action": "SCMP_ACT_ALLOW", "args": [ { "index": 0, "value": 2114060288, "op": "SCMP_CMP_MASKED_EQ" } ] } ]
}
{ "syscalls": [ { "names": ["clone"], "action": "SCMP_ACT_ALLOW", "args": [ { "index": 0, "value": 2114060288, "op": "SCMP_CMP_MASKED_EQ" } ] } ]
}
apiVersion: v1
kind: Pod
metadata: name: hardened-app namespace: production
spec: automountServiceAccountToken: false # disable default SA token mount — most pods don't need API access securityContext: # pod-level: applies to all containers runAsNonRoot: true # kubelet rejects the pod if the image runs as UID 0 runAsUser: 1001 # explicit UID — avoid root (0) and well-known service UIDs runAsGroup: 1001 # primary GID for the process fsGroup: 1001 # volume files are chowned to this GID on mount seccompProfile: type: RuntimeDefault # use the container runtime's built-in seccomp profile (~44 blocked syscalls) # move to Localhost + custom profile for high-security workloads containers: - name: app image: my-org/app:1.4.2 # pin to digest in production; tags are mutable securityContext: # container-level: overrides pod-level where both exist appArmorProfile: type: Localhost # use a node-loaded custom profile, not RuntimeDefault localhostProfile: my-org/app-v1 # path relative to /etc/apparmor.d/ — must be loaded by SPO before pod starts allowPrivilegeEscalation: false # prevents setuid binaries and sudo from granting more privilege than the parent readOnlyRootFilesystem: true # container filesystem is immutable — writes go only to explicit volume mounts capabilities: drop: - ALL # drop every capability Linux grants by default add: - NET_BIND_SERVICE # re-add only if binding to ports < 1024; remove if app uses port >= 1024 volumeMounts: - name: tmp mountPath: /tmp # writable scratch space — required by many runtimes even under readOnlyRootFilesystem - name: cache mountPath: /var/cache/app # app-specific writable path; scope this as narrowly as possible volumes: - name: tmp emptyDir: {} # ephemeral, node-local; wiped on pod restart — not for persistent data - name: cache emptyDir: {} # same — both volumes exist only to satisfy readOnlyRootFilesystem
apiVersion: v1
kind: Pod
metadata: name: hardened-app namespace: production
spec: automountServiceAccountToken: false # disable default SA token mount — most pods don't need API access securityContext: # pod-level: applies to all containers runAsNonRoot: true # kubelet rejects the pod if the image runs as UID 0 runAsUser: 1001 # explicit UID — avoid root (0) and well-known service UIDs runAsGroup: 1001 # primary GID for the process fsGroup: 1001 # volume files are chowned to this GID on mount seccompProfile: type: RuntimeDefault # use the container runtime's built-in seccomp profile (~44 blocked syscalls) # move to Localhost + custom profile for high-security workloads containers: - name: app image: my-org/app:1.4.2 # pin to digest in production; tags are mutable securityContext: # container-level: overrides pod-level where both exist appArmorProfile: type: Localhost # use a node-loaded custom profile, not RuntimeDefault localhostProfile: my-org/app-v1 # path relative to /etc/apparmor.d/ — must be loaded by SPO before pod starts allowPrivilegeEscalation: false # prevents setuid binaries and sudo from granting more privilege than the parent readOnlyRootFilesystem: true # container filesystem is immutable — writes go only to explicit volume mounts capabilities: drop: - ALL # drop every capability Linux grants by default add: - NET_BIND_SERVICE # re-add only if binding to ports < 1024; remove if app uses port >= 1024 volumeMounts: - name: tmp mountPath: /tmp # writable scratch space — required by many runtimes even under readOnlyRootFilesystem - name: cache mountPath: /var/cache/app # app-specific writable path; scope this as narrowly as possible volumes: - name: tmp emptyDir: {} # ephemeral, node-local; wiped on pod restart — not for persistent data - name: cache emptyDir: {} # same — both volumes exist only to satisfy readOnlyRootFilesystem
apiVersion: v1
kind: Pod
metadata: name: hardened-app namespace: production
spec: automountServiceAccountToken: false # disable default SA token mount — most pods don't need API access securityContext: # pod-level: applies to all containers runAsNonRoot: true # kubelet rejects the pod if the image runs as UID 0 runAsUser: 1001 # explicit UID — avoid root (0) and well-known service UIDs runAsGroup: 1001 # primary GID for the process fsGroup: 1001 # volume files are chowned to this GID on mount seccompProfile: type: RuntimeDefault # use the container runtime's built-in seccomp profile (~44 blocked syscalls) # move to Localhost + custom profile for high-security workloads containers: - name: app image: my-org/app:1.4.2 # pin to digest in production; tags are mutable securityContext: # container-level: overrides pod-level where both exist appArmorProfile: type: Localhost # use a node-loaded custom profile, not RuntimeDefault localhostProfile: my-org/app-v1 # path relative to /etc/apparmor.d/ — must be loaded by SPO before pod starts allowPrivilegeEscalation: false # prevents setuid binaries and sudo from granting more privilege than the parent readOnlyRootFilesystem: true # container filesystem is immutable — writes go only to explicit volume mounts capabilities: drop: - ALL # drop every capability Linux grants by default add: - NET_BIND_SERVICE # re-add only if binding to ports < 1024; remove if app uses port >= 1024 volumeMounts: - name: tmp mountPath: /tmp # writable scratch space — required by many runtimes even under readOnlyRootFilesystem - name: cache mountPath: /var/cache/app # app-specific writable path; scope this as narrowly as possible volumes: - name: tmp emptyDir: {} # ephemeral, node-local; wiped on pod restart — not for persistent data - name: cache emptyDir: {} # same — both volumes exist only to satisfy readOnlyRootFilesystem
# Live denial stream
journalctl -k -f | grep 'apparmor="DENIED"' # Example denial entry
kernel: audit: type=1400 audit(1708012345.123:42): apparmor="DENIED" operation="open" profile="my-org/app-v1" name="/proc/1/maps" pid=12345 comm="sh" requested_mask="r" denied_mask="r" fsuid=1001 ouid=0
# Live denial stream
journalctl -k -f | grep 'apparmor="DENIED"' # Example denial entry
kernel: audit: type=1400 audit(1708012345.123:42): apparmor="DENIED" operation="open" profile="my-org/app-v1" name="/proc/1/maps" pid=12345 comm="sh" requested_mask="r" denied_mask="r" fsuid=1001 ouid=0
# Live denial stream
journalctl -k -f | grep 'apparmor="DENIED"' # Example denial entry
kernel: audit: type=1400 audit(1708012345.123:42): apparmor="DENIED" operation="open" profile="my-org/app-v1" name="/proc/1/maps" pid=12345 comm="sh" requested_mask="r" denied_mask="r" fsuid=1001 ouid=0
If seccomp fails (missing profile, wrong defaults) → AppArmor still restricts filesystem and object access → Capabilities still bound privilege → NetworkPolicy still governs egress If AppArmor fails (Unconfined exception, profile drift) → Seccomp still blocks high-risk syscall classes → readOnlyRootFilesystem still prevents write exploitation → Capabilities still block privileged operations If both fail → Capabilities + PSS Restricted still constrain privilege → Detection (Falco/Tetragon) becomes your last active layer → NetworkPolicy still limits lateral movement
If seccomp fails (missing profile, wrong defaults) → AppArmor still restricts filesystem and object access → Capabilities still bound privilege → NetworkPolicy still governs egress If AppArmor fails (Unconfined exception, profile drift) → Seccomp still blocks high-risk syscall classes → readOnlyRootFilesystem still prevents write exploitation → Capabilities still block privileged operations If both fail → Capabilities + PSS Restricted still constrain privilege → Detection (Falco/Tetragon) becomes your last active layer → NetworkPolicy still limits lateral movement
If seccomp fails (missing profile, wrong defaults) → AppArmor still restricts filesystem and object access → Capabilities still bound privilege → NetworkPolicy still governs egress If AppArmor fails (Unconfined exception, profile drift) → Seccomp still blocks high-risk syscall classes → readOnlyRootFilesystem still prevents write exploitation → Capabilities still block privileged operations If both fail → Capabilities + PSS Restricted still constrain privilege → Detection (Falco/Tetragon) becomes your last active layer → NetworkPolicy still limits lateral movement - Why Platform Teams Should Care
- How the Kernel Enforces Security: Not a Pipeline
- From Syscall to Enforcement: The Full Execution Path
- The Runtime Default Trap
- Managing Profiles at Scale: Declarative or Nothing
- Writing a Real Profile: The Rule Model The Path-Based Trap
- The Path-Based Trap
- What AppArmor Won't Stop
- AppArmor vs. Seccomp vs. SELinux: An Opinionated Take Choosing AppArmor vs. SELinux at Platform Level
Control Failure Mode Comparison
When Is seccomp Alone Enough?
LSM Stacking: The Frontier
- Choosing AppArmor vs. SELinux at Platform Level
- Control Failure Mode Comparison
- When Is seccomp Alone Enough?
- LSM Stacking: The Frontier
- Seccomp: Deeper Than You Think The cBPF Filter Model
Return Actions (More Than Allow/Deny)
Argument Filtering: The Underused Power Feature
Two Non-Obvious Properties
What Seccomp Won't Stop
- The cBPF Filter Model
- Return Actions (More Than Allow/Deny)
- Argument Filtering: The Underused Power Feature
- Two Non-Obvious Properties
- What Seccomp Won't Stop
- A Threat Scenario: Container Escape Attempt
- What Breaks First in Production
- Performance Considerations
- A Production-Grade Pod Spec
- Observability: Catching Denials Before They Become Incidents
- Compliance Mapping
- A Realistic Failure Postmortem
- AppArmor's Threat Model Boundary
- The Operational Cost of AppArmor
- Common Anti-Patterns
- Platform Team Playbook
- Designing for Control Failure
- Key Takeaways
- The Real Purpose of AppArmor
- If You Remember Only One Thing Per Control
- Closing Thoughts - The Path-Based Trap - Choosing AppArmor vs. SELinux at Platform Level
- Control Failure Mode Comparison
- When Is seccomp Alone Enough?
- LSM Stacking: The Frontier - The cBPF Filter Model
- Return Actions (More Than Allow/Deny)
- Argument Filtering: The Underused Power Feature
- Two Non-Obvious Properties
- What Seccomp Won't Stop - Pod Security Standards at admission
- seccomp RuntimeDefault filtering syscalls
- NetworkPolicies governing traffic paths
- RBAC limiting API surface - Capabilities gate privileged operations at the point they're requested (e.g., CAP_NET_BIND_SERVICE before binding to a port below 1024).
- Seccomp intercepts syscalls before they execute, using a BPF filter to allow, deny, or trap them.
- AppArmor is a Linux Security Module (LSM) that hooks into kernel object access — mediating access to files, sockets, capabilities, and IPC based on a per-process policy. - Seccomp → reduce what the kernel will execute
- AppArmor → reduce what processes can access
- Capabilities → reduce what processes are privileged to do - Seccomp answers: can this syscall be invoked at all?
- AppArmor answers: given this syscall is allowed, what can it operate on?
- Capabilities answers: does this process hold the privilege required for this operation? - containerd delegates to the default.profile shipped by the runtime shim.
- CRI-O uses a profile derived from the OCI runtime spec with its own modifications.
- Docker uses its own docker-default profile (relevant in non-Kubernetes contexts). - You need path-level access control (restrict reads to specific filesystem subtrees)
- You're running multi-tenant workloads and need isolation between namespace tenants
- You need explicit capability access control beyond what the Pod securityContext expresses
- You're building toward a compliance posture that requires MAC - Your nodes are heterogeneous (mixed OS) and profile management would span both engines
- Your workloads are internal tooling with low breach impact
- The operational cost of profile lifecycle management exceeds your team's capacity - Filters are compiled into a set of instructions evaluated in kernel context on every syscall entry
- Execution is intentionally constrained: no loops, bounded instruction count, no memory allocation
- This constraint is a feature — it guarantees the filter cannot hang or crash the kernel - Allow open() for read-only, deny write: check the flags argument for O_RDWR or O_WRONLY
- Block clone() with namespace-creating flags: the RuntimeDefault profile already does this — it doesn't block clone entirely (threads need it), it blocks the CLONE_NEWUSER / CLONE_NEWNS flag combinations that enable container escapes
- Restrict prctl() operations: allow PR_SET_NAME (used by many runtimes), block PR_SET_DUMPABLE and PR_CAP_AMBIENT - Deploy to a canary namespace with the profile in complain mode first (flags=(complain)), even if you generated it from production traffic.
- Monitor denials for 24–48 hours across all probe types, init containers, and sidecar interactions.
- Promote to enforce mode only after the denial stream is clean.
- If enforcement causes an incident, roll back to complain mode — never to Unconfined. Complain mode preserves the security signal while restoring service.
- Treat a post-incident Unconfined pod as technical debt with a ticket, not a resolved incident. - A team runs a debug container with privileged: true to diagnose an incident and never removes it.
- A legacy workload requires hostPath mounts that weren't caught in policy review.
- A node autoscaler provisions a new node type whose image doesn't have the SPO-managed profiles loaded.
- An operator chart sets appArmorProfile: type: Unconfined for convenience during development and the override is never removed before promotion. - Profile lifecycle management — profiles must be versioned, reviewed, and retired as applications evolve. A profile that was accurate at authoring time may be wrong after a dependency upgrade or JDK version change.
- Runtime compatibility — when containerd or CRI-O ships a new version, default behavior can shift. Profiles that relied on implicit runtime behavior may need updates.
- Sidecar and probe coverage — every new sidecar (Envoy, log shipper, OTel collector) added to a namespace needs its own profile or must be explicitly covered. Forgetting this is how Unconfined exceptions accumulate.
- Exception management under pressure — during incidents, the fastest resolution is always to disable the control. Without a clear policy on what constitutes a legitimate exception (and a process for revisiting it), profiles erode over time.