catalog / docker.io/library/wordpress

docker.io/library/wordpress

validated  pin current  tags: 6.9

wordpress — minimum Linux capabilities, derived by drop-test against the
default in-stack invocation (WORDPRESS_DB_* env against a mysql database).
This profile trims the full Docker default cap set 14 -> 6 — the richest cap
story in the catalog, every grant with its own distinct evidence:

CHOWN + FOWNER  — the entrypoint copies the WordPress sources into the
fresh /var/www/html VOLUME via tar --owner www-data:
ownership set = chown syscalls (CHOWN), permission/time
restore on non-owned files (FOWNER). Either dropped:
"tar: Exiting with failure status".
DAC_OVERRIDE    — the ROOT-phase entrypoint writes wp-config.php into the
www-data-owned docroot ("wp-config.php: Permission
denied" when dropped).
SETGID + SETUID — the apache master/worker drop to www-data. SETUID
dropped = MPM fails (AH02818). SETGID dropped = apache
SERVES ANYWAY with root workers — caught only by the
worker uid assert (the httpd sharp edge, reproduced
here too).
NET_BIND_SERVICE — the :80 bind, valid ONLY under the pinned
net.ipv4.ip_unprivileged_port_start=1024 posture
(docker defaults 0; the derivation pins the hardened
posture, recorded structurally in run_config.sysctls
— schema 1.4 — and in the criteria doc).

First public-catalog profile derived with the in-stack dependent-tier model
(deps: mysql). See criteria/docker.io/library/wordpress.md.
filesystem: read_only:true, tmpfs:[/var/run/apache2]. wordpress (Apache+PHP)
copies WP core + stores uploads/config in /var/www/html, a declared VOLUME
(persistent, never tmpfs). Under a read-only rootfs the only rootfs write is
Apache's pid/lock dir /var/run/apache2; /var/log/apache2 and /tmp were
drop-tested and are NOT required. Derived by drop-test (in-stack mysql dep).

Use it

services:
  wordpress:
    image: docker.io/library/wordpress:6.9
    cap_drop: [ALL]
    cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, NET_BIND_SERVICE, SETGID, SETUID]
    security_opt: ["no-new-privileges:true"]
    read_only: true
    tmpfs:
      - /var/run/apache2

This profile has multiple dimensions and is applied as a unit. Dimensions can interact — a capability can be required only because of a sibling read-only/tmpfs recommendation — so where they do, the minimum was derived under the sibling dimension's context. See the criteria doc and each dimension's recorded invocation below before applying them separately.

Dimension: capabilities

cap_drop: ALL + cap_add: CHOWN, DAC_OVERRIDE, FOWNER, NET_BIND_SERVICE, SETGID, SETUID

toolcontainer-sec-derive 0.7.0
observerdrop-test
validated imagedocker.io/library/wordpress@sha256:5d2c212561c4…
validated date2026-07-16
confidencehigh
validated viadrop-test, ci-smoke
workloadprofiles/workloads/wordpress.sh
workload sha256283f1126ba4c…
ig versionv0.51.0

Drop-test evidence

Each candidate removed in turn, the container restarted, and the workload re-verified.

RemovedVerdictObserved
CHOWNrequiredcontainer exited: tar: Exiting with failure status due to previous errors
DAC_OVERRIDErequiredcontainer exited: docker-entrypoint.sh: wp-config.php: Permission denied
FSETIDremovablecorrect: titled homepage served from DB, worker uid=33 (non-root)
FOWNERrequiredcontainer exited: tar: Exiting with failure status due to previous errors
MKNODremovablecorrect: titled homepage served from DB, worker uid=33 (non-root)
NET_RAWremovablecorrect: titled homepage served from DB, worker uid=33 (non-root)
SETGIDrequiredapache workers running as ROOT or not found (drop failed): uid=0
SETUIDrequiredcontainer exited: [:emerg] AH02818: MPM run failed, exiting
SETFCAPremovablecorrect: titled homepage served from DB, worker uid=33 (non-root)
SETPCAPremovablecorrect: titled homepage served from DB, worker uid=33 (non-root)
NET_BIND_SERVICErequiredcontainer exited: AH00015: Unable to open logs
SYS_CHROOTremovablecorrect: titled homepage served from DB, worker uid=33 (non-root)
KILLremovablecorrect: titled homepage served from DB, worker uid=33 (non-root)
AUDIT_WRITEremovablecorrect: titled homepage served from DB, worker uid=33 (non-root)

Recorded invocation (run_config)

The minimum is valid for this invocation; a different user:, volume state, or entrypoint can change it.

security_optno-new-privileges:true
sysctlsnet.ipv4.ip_unprivileged_port_start=1024
envWORDPRESS_DB_HOST
WORDPRESS_DB_USER
WORDPRESS_DB_PASSWORD
WORDPRESS_DB_NAME
Workload script (profiles/workloads/wordpress.sh)
#!/usr/bin/env bash
# Workload exerciser for the wordpress reference image (in-stack, against a
# mysql database reachable per WORDPRESS_DB_HOST).
#
# Real function, both site states:
#   - fresh database: drive the REAL HTTP install flow (install.php?step=2 —
#     creates the schema through php -> mysql), then assert the homepage
#     serves the configured blog title from the database;
#   - installed database: assert the title-bearing homepage directly (fresh
#     container generates wp-config from env, php reads the site from mysql).
# The install POST retries and the title check polls — the first request
# after boot can land while php/mysql are still settling. Probed from a curl
# sidecar sharing the target's netns. The non-root WORKER uid assert lives in
# the drop-test correctness check (the httpd sharp edge: a broken drop keeps
# serving with root workers).
#
# Required env: WORDPRESSCONTAINER (target container name or id).
set -euo pipefail

: "${WORDPRESSCONTAINER:?WORDPRESSCONTAINER must be set}"
C="${WORDPRESSCONTAINER}"
PROBE="${CSD_PROBE_CURL_IMAGE:-curlimages/curl@sha256:c1fe1679c34d9784c1b0d1e5f62ac0a79fca01fb6377cdd33e90473c6f9f9a69}"
sc() { docker run --rm --network "container:${C}" "$PROBE" "$@"; }
TITLE="csdblog-e7a1"
# capture-then-match, NEVER `curl | grep -q` (pipefail + grep -q early-exit =
# SIGPIPE 141 = false negative on a matching page).
titled() { local b; b="$(sc -sL --max-time 10 http://localhost:80/ 2>/dev/null)"; case "$b" in *"$TITLE"*) return 0;; esac; return 1; }

deadline=$((SECONDS + 90))
while :; do
    code="$(sc -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:80/ 2>/dev/null)"
    case "$code" in 200|30?) break;; esac
    (( SECONDS >= deadline )) && { echo "wordpress never answered" >&2; exit 1; }
    sleep 2
done

if ! titled; then
    ok=""
    for attempt in 1 2 3; do
        sc -s --max-time 30 -X POST \
            --data-urlencode "weblog_title=$TITLE" \
            --data-urlencode "user_name=csdadmin" \
            --data-urlencode "admin_password=CsdProbe-Pw-12345-Strong" \
            --data-urlencode "admin_password2=CsdProbe-Pw-12345-Strong" \
            --data-urlencode "admin_email=csd@example.com" \
            --data-urlencode "blog_public=0" \
            'http://localhost:80/wp-admin/install.php?step=2' >/dev/null 2>&1
        deadline2=$((SECONDS+20))
        until titled; do (( SECONDS >= deadline2 )) && break; sleep 2; done
        titled && { ok=1; break; }
        sleep 3
    done
    if [ -z "$ok" ]; then
        echo "install flow did not produce the titled homepage" >&2
        exit 1
    fi
fi

Dimension: filesystem

read_only: true + tmpfs: /var/run/apache2

toolcontainer-sec-derive 0.7.0
observerdrop-test
validated imagedocker.io/library/wordpress@sha256:5d2c212561c4…
validated date2026-07-16
confidencehigh
validated viadrop-test, ci-smoke
workloadprofiles/workloads/wordpress.sh
workload sha256283f1126ba4c…
ig versionv0.51.0

Drop-test evidence

Each candidate removed in turn, the container restarted, and the workload re-verified.

RemovedVerdictObserved
/var/run/apache2requiredcontainer exited: AH00558: apache2: Could not reliably determine the server's fully qualified domain name, usi
/var/log/apache2removablecorrect: titled homepage served from DB, worker uid=33 (non-root)
/tmpremovablecorrect: titled homepage served from DB, worker uid=33 (non-root)

Recorded invocation (run_config)

The minimum is valid for this invocation; a different user:, volume state, or entrypoint can change it.

envWORDPRESS_DB_HOST
WORDPRESS_DB_USER
WORDPRESS_DB_PASSWORD
WORDPRESS_DB_NAME
Workload script (profiles/workloads/wordpress.sh)
#!/usr/bin/env bash
# Workload exerciser for the wordpress reference image (in-stack, against a
# mysql database reachable per WORDPRESS_DB_HOST).
#
# Real function, both site states:
#   - fresh database: drive the REAL HTTP install flow (install.php?step=2 —
#     creates the schema through php -> mysql), then assert the homepage
#     serves the configured blog title from the database;
#   - installed database: assert the title-bearing homepage directly (fresh
#     container generates wp-config from env, php reads the site from mysql).
# The install POST retries and the title check polls — the first request
# after boot can land while php/mysql are still settling. Probed from a curl
# sidecar sharing the target's netns. The non-root WORKER uid assert lives in
# the drop-test correctness check (the httpd sharp edge: a broken drop keeps
# serving with root workers).
#
# Required env: WORDPRESSCONTAINER (target container name or id).
set -euo pipefail

: "${WORDPRESSCONTAINER:?WORDPRESSCONTAINER must be set}"
C="${WORDPRESSCONTAINER}"
PROBE="${CSD_PROBE_CURL_IMAGE:-curlimages/curl@sha256:c1fe1679c34d9784c1b0d1e5f62ac0a79fca01fb6377cdd33e90473c6f9f9a69}"
sc() { docker run --rm --network "container:${C}" "$PROBE" "$@"; }
TITLE="csdblog-e7a1"
# capture-then-match, NEVER `curl | grep -q` (pipefail + grep -q early-exit =
# SIGPIPE 141 = false negative on a matching page).
titled() { local b; b="$(sc -sL --max-time 10 http://localhost:80/ 2>/dev/null)"; case "$b" in *"$TITLE"*) return 0;; esac; return 1; }

deadline=$((SECONDS + 90))
while :; do
    code="$(sc -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:80/ 2>/dev/null)"
    case "$code" in 200|30?) break;; esac
    (( SECONDS >= deadline )) && { echo "wordpress never answered" >&2; exit 1; }
    sleep 2
done

if ! titled; then
    ok=""
    for attempt in 1 2 3; do
        sc -s --max-time 30 -X POST \
            --data-urlencode "weblog_title=$TITLE" \
            --data-urlencode "user_name=csdadmin" \
            --data-urlencode "admin_password=CsdProbe-Pw-12345-Strong" \
            --data-urlencode "admin_password2=CsdProbe-Pw-12345-Strong" \
            --data-urlencode "admin_email=csd@example.com" \
            --data-urlencode "blog_public=0" \
            'http://localhost:80/wp-admin/install.php?step=2' >/dev/null 2>&1
        deadline2=$((SECONDS+20))
        until titled; do (( SECONDS >= deadline2 )) && break; sleep 2; done
        titled && { ok=1; break; }
        sleep 3
    done
    if [ -z "$ok" ]; then
        echo "install flow did not produce the titled homepage" >&2
        exit 1
    fi
fi

Evidence & provenance

Raw profile YAML
# wordpress — minimum Linux capabilities, derived by drop-test against the
# default in-stack invocation (WORDPRESS_DB_* env against a mysql database).
# This profile trims the full Docker default cap set 14 -> 6 — the richest cap
# story in the catalog, every grant with its own distinct evidence:
#
#   CHOWN + FOWNER  — the entrypoint copies the WordPress sources into the
#                     fresh /var/www/html VOLUME via tar --owner www-data:
#                     ownership set = chown syscalls (CHOWN), permission/time
#                     restore on non-owned files (FOWNER). Either dropped:
#                     "tar: Exiting with failure status".
#   DAC_OVERRIDE    — the ROOT-phase entrypoint writes wp-config.php into the
#                     www-data-owned docroot ("wp-config.php: Permission
#                     denied" when dropped).
#   SETGID + SETUID — the apache master/worker drop to www-data. SETUID
#                     dropped = MPM fails (AH02818). SETGID dropped = apache
#                     SERVES ANYWAY with root workers — caught only by the
#                     worker uid assert (the httpd sharp edge, reproduced
#                     here too).
#   NET_BIND_SERVICE — the :80 bind, valid ONLY under the pinned
#                     net.ipv4.ip_unprivileged_port_start=1024 posture
#                     (docker defaults 0; the derivation pins the hardened
#                     posture, recorded structurally in run_config.sysctls
#                     — schema 1.4 — and in the criteria doc).
#
# First public-catalog profile derived with the in-stack dependent-tier model
# (deps: mysql). See criteria/docker.io/library/wordpress.md.
# filesystem: read_only:true, tmpfs:[/var/run/apache2]. wordpress (Apache+PHP)
# copies WP core + stores uploads/config in /var/www/html, a declared VOLUME
# (persistent, never tmpfs). Under a read-only rootfs the only rootfs write is
# Apache's pid/lock dir /var/run/apache2; /var/log/apache2 and /tmp were
# drop-tested and are NOT required. Derived by drop-test (in-stack mysql dep).
#
schema_version: "1.5"
image: docker.io/library/wordpress
reference_url: https://tmatens.github.io/container-security-profiles/profiles/docker.io/library/wordpress.html
applies_to:
  tags: ["6.9"]
status: validated
dimensions:
  capabilities:
    cap_drop: [ALL]
    cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, NET_BIND_SERVICE, SETGID, SETUID]
    derivation:
      tool: container-sec-derive
      tool_version: "0.7.0"
      sidecar_schema_version: "1.5"
      observer: drop-test
      validated_image: docker.io/library/wordpress@sha256:5d2c212561c4b5442ebc4d98933a9cbadcf3dee8888ed3fd9ed44667c27cc905
      validated_date: "2026-07-16"
      duration_seconds: 600
      confidence: high
      workload: profiles/workloads/wordpress.sh
      workload_sha256: "283f1126ba4c268f47e307c261673429e388226570d62d202b58fd562477dc75"
      validated_via: [drop-test, ci-smoke]
      observation_backend:
        ig_version: v0.51.0
        gadgets: []
      run_config:
        user: ""
        command: []
        entrypoint: ""
        network: ""
        pid: ""
        devices: []
        security_opt: ["no-new-privileges:true"]
        sysctls: ["net.ipv4.ip_unprivileged_port_start=1024"]
        mounts: []
        env: [WORDPRESS_DB_HOST, WORDPRESS_DB_USER, WORDPRESS_DB_PASSWORD, WORDPRESS_DB_NAME]
      drop_test:
        checks:
          - {removed: CHOWN, required: true, observed: "container exited: tar: Exiting with failure status due to previous errors"}
          - {removed: DAC_OVERRIDE, required: true, observed: "container exited: docker-entrypoint.sh: wp-config.php: Permission denied"}
          - {removed: FSETID, required: false, observed: "correct: titled homepage served from DB, worker uid=33 (non-root)"}
          - {removed: FOWNER, required: true, observed: "container exited: tar: Exiting with failure status due to previous errors"}
          - {removed: MKNOD, required: false, observed: "correct: titled homepage served from DB, worker uid=33 (non-root)"}
          - {removed: NET_RAW, required: false, observed: "correct: titled homepage served from DB, worker uid=33 (non-root)"}
          - {removed: SETGID, required: true, observed: "apache workers running as ROOT or not found (drop failed): uid=0"}
          - {removed: SETUID, required: true, observed: "container exited: [:emerg] AH02818: MPM run failed, exiting"}
          - {removed: SETFCAP, required: false, observed: "correct: titled homepage served from DB, worker uid=33 (non-root)"}
          - {removed: SETPCAP, required: false, observed: "correct: titled homepage served from DB, worker uid=33 (non-root)"}
          - {removed: NET_BIND_SERVICE, required: true, observed: "container exited: AH00015: Unable to open logs"}
          - {removed: SYS_CHROOT, required: false, observed: "correct: titled homepage served from DB, worker uid=33 (non-root)"}
          - {removed: KILL, required: false, observed: "correct: titled homepage served from DB, worker uid=33 (non-root)"}
          - {removed: AUDIT_WRITE, required: false, observed: "correct: titled homepage served from DB, worker uid=33 (non-root)"}
  filesystem:
    read_only: true
    tmpfs: [/var/run/apache2]
    derivation:
      tool: container-sec-derive
      tool_version: "0.7.0"
      sidecar_schema_version: "1.5"
      observer: drop-test
      validated_image: docker.io/library/wordpress@sha256:5d2c212561c4b5442ebc4d98933a9cbadcf3dee8888ed3fd9ed44667c27cc905
      validated_date: "2026-07-16"
      duration_seconds: 14
      confidence: high
      workload: profiles/workloads/wordpress.sh
      workload_sha256: "283f1126ba4c268f47e307c261673429e388226570d62d202b58fd562477dc75"
      validated_via: [drop-test, ci-smoke]
      observation_backend:
        ig_version: v0.51.0
        gadgets: []
      run_config:
        user: ""
        command: []
        entrypoint: ""
        network: ""
        pid: ""
        devices: []
        security_opt: []
        mounts: []
        env: [WORDPRESS_DB_HOST, WORDPRESS_DB_USER, WORDPRESS_DB_PASSWORD, WORDPRESS_DB_NAME]
      drop_test:
        checks:
          - {removed: /var/run/apache2, required: true, observed: "container exited: AH00558: apache2: Could not reliably determine the server's fully qualified domain name, usi"}
          - {removed: /var/log/apache2, required: false, observed: "correct: titled homepage served from DB, worker uid=33 (non-root)"}
          - {removed: /tmp, required: false, observed: "correct: titled homepage served from DB, worker uid=33 (non-root)"}