Sub-Module 1.6: SELinux Hardening with udica
|
This workshop environment has been optimized for learning efficiency. Required tools, directories, sample applications, and configurations have been pre-installed to focus on core hardened image concepts rather than repetitive setup tasks. |
Introduction
In Sub-Module 1.5, you customized hardened images at the image level with custom CAs and FIPS compliance. Now you’ll add a second, complementary layer of security at the kernel level using SELinux.
This module demonstrates how to generate custom SELinux policies tailored to distroless containers, creating double-hardening: minimal attack surface from the image plus restrictive mandatory access control from the kernel.
Intro to SELinux
SELinux (Security-Enhanced Linux) provides mandatory access control (MAC) that can enforce fine-grained security policies on containers. By default, all containers run under the container_t SELinux type, which is a one-size-fits-all policy that’s often too permissive for some use cases and too restrictive for others.
udica (Universal Container Policy) generates custom SELinux security profiles tailored to each container’s specific needs. Combined with Red Hat Hardened Images, this provides extra hardening: a minimal attack surface at the image level plus restrictive SELinux policies at the kernel level.
The Problem udica Solves
Default container_t Trade-Off
For all containers, there’s just one general SELinux policy. The default container type container_t is:
-
Too strict for some use cases (e.g., bind-mounting host directories, accessing devices)
-
Too permissive for others (e.g., allows access to resources the container doesn’t actually need)
There’s no policy that perfectly matches any specific container’s actual needs.
What udica Does
udica generates custom SELinux security profiles by:
-
Inspecting the container’s JSON configuration (from
podman inspect) -
Extracting requirements: mountpoints, exposed ports, capabilities
-
Combining rules from template blocks:
-
base_container.cil— always included, mirrors baselinecontainer_trules -
net_container.cil— added when container exposes ports; tightened to only the specific port’s SELinux label -
home_container.cil— added when/homeis bind-mounted
-
-
Generating a custom Common Intermediate Language (CIL) policy file
The Specific Distroless Challenge
With a standard UBI container, the workflow is:
-
Run container interactively
-
Exercise functionality (click buttons, run commands)
-
Inspect with udica
-
Generate policy based on observed behavior
With distroless containers, you cannot run interactively — there is no shell.
The Solution
The policy generation relies on podman inspect output and audit log analysis, not on runtime observation:
-
Run the container with the default
container_tpolicy, using realistic configuration and mounts -
Let it start (it may work or produce AVC denials in the audit log)
-
Capture
podman inspectoutput -
Feed that to udica to generate the tailored CIL policy
-
Optionally augment the policy with AVC denials from the audit log using
--append-rules
Example: Quarkus App with Bind Mounts
We’ll generate a custom SELinux policy for the hummingbird-demo:v1 application built in Sub-Module 1.2, this time with bind-mounted host directories for configuration and logs. This is where udica provides the most value — the default container_t policy doesn’t know about your specific mount points.
Step 3: Examine Pre-Configured Host Directories
Host directories for bind mounts have been pre-configured with proper SELinux contexts. Let’s examine the setup:
ls -lZ /opt/myapp/
drwxr-xr-x. <user> <user> unconfined_u:object_r:container_file_t:s0 config drwxr-xr-x. <user> <user> unconfined_u:object_r:container_file_t:s0 logs
|
|
Step 4: First Run with Default Policy
Run the quarkus application container with the default container_t policy, bind mounts, and an exposed port:
podman run -d \
--name demo-policy-run \
--env container=podman \
-v /opt/myapp/config:/app/config:ro,Z \
-v /opt/myapp/logs:/app/logs:rw,Z \
-p 8080:8080 \
hummingbird-demo:v1
|
podman ps -a --filter name=demo-policy-run
We should be able to hit the same endpoints in the app as in our first module.
curl -s http://localhost:8080/ | jq
{
"message": "Hello from Hummingbird!",
"runtime": "Java 21.0.10",
"platform": "linux",
"timestamp": "2026-03-23T19:44:28.921499086Z"
}
Step 5: Generate udica Policy
Let’s double check the context of our running container is container_t.
podman inspect demo-policy-run --format '{{.ProcessLabel}}'
system_u:system_r:container_t:s0:c338,c964
Capture the podman inspect output and feed it to udica:
podman inspect demo-policy-run | sudo udica hummingbird_demo
|
|
Policy hummingbird_demo created!
Please load these modules using:
# semodule -i hummingbird_demo.cil /usr/share/udica/templates/{base_container.cil,net_container.cil}
Restart the container with: "--security-opt label=type:hummingbird_demo.process" parameter
Step 6: Walk Through the Generated CIL
This is the most important step — understanding exactly what udica allowed:
cat hummingbird_demo.cil
(block hummingbird_demo
(blockinherit container) ; (1)
(blockinherit restricted_net_container) ; (2)
(allow process http_cache_port_t ( tcp_socket ( name_bind ))) ; (3)
(allow process container_file_t ( dir ( add_name create getattr ... ))) ; (4)
(allow process container_file_t ( file ( append create getattr ... ))) ;
(allow process container_file_t ( fifo_file ( getattr read write ... ))) ; (5)
(allow process container_file_t ( sock_file ( append getattr ... ))) ;
(allow process container_file_t ( dir ( getattr ioctl lock ... ))) ; (6)
(allow process container_file_t ( file ( getattr ioctl lock ... ))) ;
...
)
| 1 | (blockinherit container) — Inherits all rules from base_container.cil. This gives the container the same baseline permissions as container_t (process management, basic filesystem access, etc.) |
| 2 | (blockinherit restricted_net_container) — Inherits network access rules from net_container.cil (which defines both the net_container and restricted_net_container blocks). udica chooses restricted_net_container which provides TCP/UDP/SCTP socket access plus outbound HTTP connectivity, appropriate because the container exposes port 8080. |
| 3 | (allow process http_cache_port_t …) — Allows binding to ports with the http_cache_port_t SELinux label. Port 8080 shares this label with 8118, 8123, etc. |
| 4 | container_file_t dir/file write rules — Allows the container to create, write, append, and manage files and directories labeled container_file_t. This covers the read-write /opt/myapp/logs mount. |
| 5 | container_file_t fifo/sock write rules — Allows read/write access to named pipes (FIFOs) and socket files within the labeled directories. |
| 6 | container_file_t dir/file read rules — Allows read-only access to directories and files labeled container_file_t. This covers the read-only /opt/myapp/config mount. |
The policy captures rules for both bind mounts: read-write rules for the logs directory and read-only rules for the config directory, plus the port binding rule. You can see which ports share the http_cache_port_t label:
sudo semanage port -l | grep http_cache
http_cache_port_t tcp 8080, 8118, 8123, 10001-10010 http_cache_port_t udp 3130
Load and Apply the Policy
Step 7: Load the SELinux Policy
sudo semodule -i hummingbird_demo.cil \
/usr/share/udica/templates/{base_container.cil,net_container.cil}
Verify it loaded:
sudo semodule -l | grep hummingbird_demo
hummingbird_demo
Step 8: Restart with Custom Policy
Stop the first run and restart with the custom policy applied:
podman stop demo-policy-run && podman rm demo-policy-run
podman run -d \
--name demo-selinux \
--env container=podman \
--security-opt label=type:hummingbird_demo.process \
-v /opt/myapp/config:/app/config:ro,Z \
-v /opt/myapp/logs:/app/logs:rw,Z \
-p 8080:8080 \
hummingbird-demo:v1
Step 9: Verify Custom Process Label
Confirm the container is running under the custom SELinux type:
podman inspect demo-selinux --format '{{.ProcessLabel}}'
system_u:system_r:hummingbird_demo.process:s0:c123,c456
|
The |
Why This Matters
The default container_t policy sits between two bad outcomes:
-
Too permissive to provide real isolation
-
Too strict to let legitimate container operations through without tuning
udica provides fine-grained control, placing the policy between spc_t (Super Privileged Container — allows everything) and container_t (Standard Containers — one-size-fits-all).
-
Image Level: Hardened Image’s no-shell, no-tools design means a genuinely minimal syscall and filesystem access profile — smaller than any UBI image
-
SELinux Level: Custom policy allowing only the specific filesystem paths, ports, and capabilities the container actually uses
These two layers are independent and complementary — compromising one doesn’t defeat the other. A udica-generated policy for a Hardened Image container will be tighter than one for a comparable UBI container, because there are fewer legitimate access patterns to allow.
Summary
Congratulations! You’ve completed Sub-Module 1.6 and learned the fundamentals of SELinux hardening with udica.
✅ Understood the distroless-specific challenge (no shell for interactive testing)
✅ Prepared host directories with proper SELinux labels (container_file_t)
✅ Generated custom SELinux policy with bind-mount and port rules
✅ Read and understood every line of the generated CIL policy
✅ Loaded and applied the custom policy with semodule
✅ Verified the container runs under a custom SELinux type
udica Workflow for Hardened Images:
-
Prepare host directories and label with
semanage fcontext -
Run container with default
container_tand realistic config/mounts -
Capture
podman inspectoutput (not interactive testing) -
Generate policy with
udica -
Load policy with
semodule -
Restart with
--security-opt label=type:<policy>.process
CIL Template Blocks:
-
base_container.cil— always inherited, baseline rules -
net_container.cil— inherited when ports are exposed -
home_container.cil— inherited when /home is bind-mounted
Double-Hardening Benefits:
-
Image layer: Minimal attack surface (no shells, no tools, no package managers)
-
SELinux layer: Kernel-enforced access control for paths, ports, capabilities
-
Defense in depth: Compromising one layer doesn’t defeat the other
Next Steps
You’ve completed the core developer environment modules for Red Hat Hardened Images! You’ve learned to build minimal images, scan and sign them, customize security configurations, and apply kernel-level access controls.
Continue exploring advanced topics in the remaining modules, or move to Module 2 to learn platform-level hardened image deployment with Shipwright and OpenShift.
Troubleshooting
Issue: SELinux denials blocking container
ausearch -m AVC -ts recent | grep denied
sudo setenforce 0
podman inspect container | udica --append-rules /var/log/audit/audit.log policy_v2
Issue: Policy won’t load
sudo semodule -r hummingbird_demo
sudo semodule -i hummingbird_demo.cil /usr/share/udica/templates/*.cil
Issue: Container still using container_t
podman inspect container | grep -i selinux
sudo semodule -l | grep your_policy