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
| tool | container-sec-derive 0.7.0 |
|---|---|
| observer | drop-test |
| validated image | docker.io/library/wordpress@sha256:5d2c212561c4… |
| validated date | 2026-07-16 |
| confidence | high |
| validated via | drop-test, ci-smoke |
| workload | profiles/workloads/wordpress.sh |
| workload sha256 | 283f1126ba4c… |
| ig version | v0.51.0 |
Drop-test evidence
Each candidate removed in turn, the container restarted, and the workload re-verified.
| Removed | Verdict | Observed |
|---|---|---|
CHOWN | required | container exited: tar: Exiting with failure status due to previous errors |
DAC_OVERRIDE | required | container exited: docker-entrypoint.sh: wp-config.php: Permission denied |
FSETID | removable | correct: titled homepage served from DB, worker uid=33 (non-root) |
FOWNER | required | container exited: tar: Exiting with failure status due to previous errors |
MKNOD | removable | correct: titled homepage served from DB, worker uid=33 (non-root) |
NET_RAW | removable | correct: titled homepage served from DB, worker uid=33 (non-root) |
SETGID | required | apache workers running as ROOT or not found (drop failed): uid=0 |
SETUID | required | container exited: [:emerg] AH02818: MPM run failed, exiting |
SETFCAP | removable | correct: titled homepage served from DB, worker uid=33 (non-root) |
SETPCAP | removable | correct: titled homepage served from DB, worker uid=33 (non-root) |
NET_BIND_SERVICE | required | container exited: AH00015: Unable to open logs |
SYS_CHROOT | removable | correct: titled homepage served from DB, worker uid=33 (non-root) |
KILL | removable | correct: titled homepage served from DB, worker uid=33 (non-root) |
AUDIT_WRITE | removable | correct: 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_opt | no-new-privileges:true |
|---|---|
| sysctls | net.ipv4.ip_unprivileged_port_start=1024 |
| env | WORDPRESS_DB_HOSTWORDPRESS_DB_USERWORDPRESS_DB_PASSWORDWORDPRESS_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
| tool | container-sec-derive 0.7.0 |
|---|---|
| observer | drop-test |
| validated image | docker.io/library/wordpress@sha256:5d2c212561c4… |
| validated date | 2026-07-16 |
| confidence | high |
| validated via | drop-test, ci-smoke |
| workload | profiles/workloads/wordpress.sh |
| workload sha256 | 283f1126ba4c… |
| ig version | v0.51.0 |
Drop-test evidence
Each candidate removed in turn, the container restarted, and the workload re-verified.
| Removed | Verdict | Observed |
|---|---|---|
/var/run/apache2 | required | container exited: AH00558: apache2: Could not reliably determine the server's fully qualified domain name, usi |
/var/log/apache2 | removable | correct: titled homepage served from DB, worker uid=33 (non-root) |
/tmp | removable | correct: 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.
| env | WORDPRESS_DB_HOSTWORDPRESS_DB_USERWORDPRESS_DB_PASSWORDWORDPRESS_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
- Validation criteria — per-image scenarios and pass criteria
- Profile source in the repository
- Version history — every previously published pin, with the full profile as validated against it
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)"}