courses

Lab — Week 3: Container Security I — Threat Model and Image Hardening
Due: 24 July 2026
Submission: GitLab repo secdevops-s26-<CECS>

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.

Prerequisites


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

  1. 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

  1. Write Dockerfile.dvwa on ubuntu-server (commit to your repo). Requirements:
    • Use a minimal base image (debian:trixie-slim or ubuntu:26.04). Pick the current stable release, not whatever an example on the internet used — Debian 12 bookworm became oldstable when Debian 13 trixie released 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 WORKDIR appropriately
  2. Build the image: docker build -t dvwa:hardened -f Dockerfile.dvwa .

  3. Verify the container runs as non-root:
    docker run --rm dvwa:hardened id -u
    # Must NOT print 0
    

    id -u rather than whoami on purpose: whoami looks the UID up in /etc/passwd and errors out with cannot find name for user ID when there is no matching entry, which is a confusing way to discover you are not root. If your image defines an ENTRYPOINT, this command becomes an argument to it rather than replacing it — use docker run --rm --entrypoint id dvwa:hardened -u in that case.

  4. Inspect layers for accidental secrets:
    docker history --no-trunc dvwa:hardened
    docker inspect dvwa:hardened | jq '.[0].Config.Env'
    

nginx Dockerfile

  1. Write Dockerfile.nginx for an nginx reverse proxy that will front DVWA. Requirements:
    • Minimal base image
    • Non-root user
    • Copy a basic nginx.conf that proxies / → http://dvwa:80/
    • Expose only port 80 (TLS termination comes in a later lab)
  2. Build: docker build -t nginx-proxy:hardened -f Dockerfile.nginx .

Container Check Script

  1. Write scripts/container-check.sh that 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 inspect reports "User": "" for a container with no USER directive, and an empty string means root. A check that only looks for the literal string root will pass the most common way of running as root. Either treat empty as a failure, or ask the running process directly with docker 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=hunter2 must 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.

  2. 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

  1. 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:


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

  1. Write docker-compose.yml on ubuntu-server that brings up three services: nginx, dvwa, and mariadb. For every service, apply:
    • read_only: true
    • cap_drop: [ALL]
    • security_opt: [no-new-privileges:true]
    • Only add back capabilities that are actually required (document each one)
    • tmpfs mounts or named volumes for any paths that must be writable
  2. Use the hardened images from Part 1 for nginx and dvwa. Use mariadb:11 for 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 curl commands below to match whichever host port you choose.

Network Segmentation

  1. Define two Compose networks: frontend and backend.
    • nginx is on frontend only
    • dvwa is on both (it bridges the two)
    • mariadb is on backend only
  2. Bring the stack up: docker compose up -d

  3. Verify that DVWA is reachable through nginx: curl -s -o /dev/null -w "%{http_code}" http://localhost:8080

    A 404 means 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, with docker compose ps and docker compose logs dvwa.

  4. 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 0
    

    Running 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 get executable 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

  1. Create seccomp-dvwa.json — a custom seccomp profile based on the Docker default that additionally blocks ptrace and reboot. Apply it to the dvwa service in docker-compose.yml.

  2. Verify the profile is applied:

    docker inspect $(docker compose ps -q dvwa) \
      | jq '.[0].HostConfig.SecurityOpt'
    

Verify the Full Stack

  1. Run scripts/container-check.sh from Part 1 against all three running containers and confirm all checks pass.

Threat Model Update

  1. 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:


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.