Lab — Week 3: Container Security I — Threat Model and Image Hardening
Due: 24 July 2026
Submission: GitLab repo secdevops-s26-<CECS>
- Overview
- Prerequisites
- Part 1: Container Threat Model and Image Hardening
- Part 2: Container Runtime Hardening
- Submission
Overview
Understand what containers actually isolate (and what they don’t), harden the DVWA and nginx images, and apply runtime constraints that limit what a compromised container can do.
- Part 1 — Container Threat Model and Image Hardening applies your threat modeling skills to the containerized workload, then builds hardened Docker images for DVWA and nginx.
- Part 2 — Container Runtime Hardening applies seccomp profiles, capability restrictions, read-only filesystems, and network segmentation, then composes the stack into a single hardened
docker-compose.yml.
Prerequisites
- Week 1 Part 1 complete — ubuntu-server running with Docker installed
- Week 1 Part 2 complete — threat model exists in
lab02/threat-model.md
Part 1: Container Threat Model and Image Hardening
Containers are not a security boundary — they share the host kernel. This part applies the threat modeling skills from Week 1 Part 2 to the containerized workload, then builds hardened Docker images for DVWA and nginx that implement as many of those mitigations as possible at the image layer.
Container Threat Model Extension
- In
lab02/threat-model.md, add at least three new STRIDE threats that are specific to the containerized workload. Consider: container escape, secrets in image layers, network reachability between containers, and privilege escalation via root-in-container.
DVWA Dockerfile
- Write
Dockerfile.dvwaon ubuntu-server (commit to your repo). Requirements:- Use a minimal base image (
debian:trixie-slimorubuntu:26.04). Pick the current stable release, not whatever an example on the internet used — Debian 12bookwormbecame oldstable when Debian 13trixiereleased in August 2025, and starting from an oldstable base hands you a longer CVE list in Week 4 for no benefit. Check the Debian releases page rather than trusting this sentence, which will itself age. - Install only the packages DVWA requires
- Create a non-root system user and switch to it with
USER - Do not include any credentials, API keys, or passwords in any layer
- Set
WORKDIRappropriately
- Use a minimal base image (
-
Build the image:
docker build -t dvwa:hardened -f Dockerfile.dvwa . - Verify the container runs as non-root:
docker run --rm dvwa:hardened id -u # Must NOT print 0id -urather thanwhoamion purpose:whoamilooks the UID up in/etc/passwdand errors out withcannot find name for user IDwhen there is no matching entry, which is a confusing way to discover you are not root. If your image defines anENTRYPOINT, this command becomes an argument to it rather than replacing it — usedocker run --rm --entrypoint id dvwa:hardened -uin that case. - Inspect layers for accidental secrets:
docker history --no-trunc dvwa:hardened docker inspect dvwa:hardened | jq '.[0].Config.Env'
nginx Dockerfile
- Write
Dockerfile.nginxfor an nginx reverse proxy that will front DVWA. Requirements:- Minimal base image
- Non-root user
- Copy a basic
nginx.confthat proxies/ → http://dvwa:80/ - Expose only port 80 (TLS termination comes in a later lab)
- Build:
docker build -t nginx-proxy:hardened -f Dockerfile.nginx .
Container Check Script
- Write
scripts/container-check.shthat accepts a container name as its first argument and checks:- The process inside is not running as
root(UID 0) - The container is not running with
--privileged - No environment variables match
password|secret|token|key(case-insensitive)
Exit 0 if all checks pass; exit non-zero otherwise.
Two ways this script commonly passes when it should fail:
- The user check.
docker inspectreports"User": ""for a container with noUSERdirective, and an empty string means root. A check that only looks for the literal stringrootwill pass the most common way of running as root. Either treat empty as a failure, or ask the running process directly withdocker exec <name> id -u. - The environment check. The requirement is that no variable matches those words, not that a variable begins with them.
DB_PASSWORD=hunter2must be caught. Anchoring your pattern to the start of the variable name will miss it, and this is exactly the shape a real Compose file produces.
Test the failure path, not just the success path. Build a deliberately bad image — root user, a
password=environment variable — run your script against it, and confirm it exits non-zero. A check that has only ever printed PASS tells you nothing. - The process inside is not running as
- Start both containers and run the script against each:
docker run -d --name dvwa_test dvwa:hardened sleep 3600 docker run -d --name nginx_test nginx-proxy:hardened bash scripts/container-check.sh dvwa_test bash scripts/container-check.sh nginx_test docker rm -f dvwa_test nginx_test
Threat Model Update
- In
lab02/threat-model.md, mark any threats now addressed by image hardening. Update status and note the specific control.
Deliverables
Create lab05/ in your repo containing:
Dockerfile.dvwa— hardened DVWA imageDockerfile.nginx— hardened nginx reverse proxy imagescripts/container-check.sh— the check script- Non-root verification —
docker run --rm dvwa:hardened whoamioutput (not root) - Layer inspection —
docker historyoutput showing no secrets in any layer - Check script output — both containers passing all checks
- Updated
lab02/threat-model.md— container threats added and any newly mitigated entries updated lab05/lab05.md— for each Dockerfile decision (base image choice, USER, packages), one sentence explaining the security rationale
Part 2: Container Runtime Hardening
Dockerfile hardening controls what is in an image. Runtime hardening controls what a running container is allowed to do. This part applies seccomp profiles, capability restrictions, read-only filesystems, and network segmentation to the lab stack, then composes it into a single hardened docker-compose.yml.
Part 1 must be complete — dvwa:hardened and nginx-proxy:hardened images built.
Hardened docker-compose.yml
- Write
docker-compose.ymlon ubuntu-server that brings up three services:nginx,dvwa, andmariadb. For every service, apply:read_only: truecap_drop: [ALL]security_opt: [no-new-privileges:true]- Only add back capabilities that are actually required (document each one)
tmpfsmounts or named volumes for any paths that must be writable
-
Use the hardened images from Part 1 for
nginxanddvwa. Usemariadb:11for the database (DVWA speaks the MySQL/MariaDB protocol, not PostgreSQL).Publish nginx’s port to the host so you can reach the stack from ubuntu-server — no other service gets a
ports:entry, since everything else should only be reachable over the Compose networks:nginx: ports: - "8080:80"Adjust the
curlcommands below to match whichever host port you choose.
Network Segmentation
- Define two Compose networks:
frontendandbackend.nginxis onfrontendonlydvwais on both (it bridges the two)mariadbis onbackendonly
-
Bring the stack up:
docker compose up -d -
Verify that DVWA is reachable through nginx:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080A
404means nginx is up and proxying but DVWA is serving nothing — check that your dvwa container is actually running Apache rather than sitting at a shell, withdocker compose psanddocker compose logs dvwa. - Verify that nginx cannot directly reach mariadb. Test the network, using a throwaway container attached to the frontend network, rather than a client binary inside the nginx image:
docker network ls | grep frontend # find the project-prefixed name docker run --rm --network <project>_frontend busybox \ sh -c 'nc -z -w 5 mariadb 3306; echo "exit=$?"' # must be non-zero docker run --rm --network <project>_backend busybox \ sh -c 'nc -z -w 5 mariadb 3306; echo "exit=$?"' # must be 0Running the same command against both networks is what makes the result mean something. Do not test this with
docker compose exec nginx mariadb ...: a hardened nginx image has no MariaDB client, so you getexecutable file not found, which looks like a pass and proves nothing about the network. Distinguish the outcomes you might see —executable file not found(wrong test), a DNS failure (the name does not resolve across networks), a connection refused (reached a host, nothing listening), and a timeout (packets dropped) are four different results and only the last two say anything about segmentation.
seccomp Profile
-
Create
seccomp-dvwa.json— a custom seccomp profile based on the Docker default that additionally blocksptraceandreboot. Apply it to thedvwaservice indocker-compose.yml. -
Verify the profile is applied:
docker inspect $(docker compose ps -q dvwa) \ | jq '.[0].HostConfig.SecurityOpt'
Verify the Full Stack
- Run
scripts/container-check.shfrom Part 1 against all three running containers and confirm all checks pass.
Threat Model Update
- In
lab02/threat-model.md, mark any threats now mitigated by runtime hardening. For each, name the specific control (cap_drop,read_only, network segmentation, seccomp).
Deliverables
Create lab06/ in your repo containing:
docker-compose.yml— fully hardened, with inline comments explaining each security optionseccomp-dvwa.json— custom seccomp profile- DVWA reachability —
curloutput showing HTTP 200 (or redirect) through nginx - Network isolation test — the paired
frontendandbackendresults from step 6, with a sentence saying which outcome each exit code represents - seccomp verification —
docker inspectoutput showing the profile is applied - Container check — all three containers passing
scripts/container-check.sh - Updated
lab02/threat-model.md lab06/lab06.md— for each capability you added back aftercap_drop: [ALL], explain what it allows and why that service needs it; and name one capability you did not add back that the service might plausibly have wanted, with the reason it turned out to be unnecessary
Submission
Commit all files for both parts — lab05/ and lab06/ — and push to your GitLab repo. The lab is considered submitted when the commit appears in GitLab before the due date.