catalog / docker.io/pihole/pihole

docker.io/pihole/pihole

validated  pin current  tags: 2026.07.2

pihole — minimum Linux capabilities, derived by drop-test against the
default invocation, DNS-ONLY SCOPE (DHCP mode needs NET_ADMIN and is out of
scope). This profile trims the full Docker default cap set 14 -> 6 — and
the finding the coverage queue predicted: upstream docs suggest NET_RAW /
NET_ADMIN / SYS_NICE, and NONE of them are load-bearing on the DNS path.

capabilities: cap_drop:[ALL] + cap_add:[CHOWN, DAC_OVERRIDE,
NET_BIND_SERVICE, SETFCAP, SETGID, SETUID]. The architecture is the
catalog's fourth privilege shape: the ROOT entrypoint provisions
/etc/pihole (CHOWN/DAC_OVERRIDE) and SETCAPs the pihole-FTL binary
(SETFCAP — a rare, honest grant), FTL then starts as the pihole user
(SETGID/SETUID) and receives NET_BIND_SERVICE for the privileged :53 via
those FILE capabilities.

TWO HARD SCOPE CONDITIONS:
1. NO no-new-privileges. Measured incompatibility: nnp blocks file-cap
acquisition on exec, so under the pinned privileged-port sysctl an
nnp'd pihole can NEVER listen on :53. This is the only catalog profile
where nnp must be omitted — a real architectural property, not an
oversight.
2. NET_BIND_SERVICE is scoped to the pinned
net.ipv4.ip_unprivileged_port_start=1024 posture (docker defaults 0).
See criteria/docker.io/pihole/pihole.md.
filesystem: read_only:false (a derived NEGATIVE result). pihole cannot run
under a read-only rootfs as shipped: its entrypoint chowns /macvendor.db and
sed-rewrites /crontab.txt at the FILESYSTEM ROOT, and the FTL auto-update
writes /usr/bin/pihole-FTL — none relocatable to a tmpfs/volume. Even with
generous tmpfs on the config/runtime dirs the baseline fails. See criteria.

Use it

services:
  pihole:
    image: docker.io/pihole/pihole:2026.07.2
    cap_drop: [ALL]
    cap_add: [CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETFCAP, SETGID, SETUID]
    read_only: false

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, NET_BIND_SERVICE, SETFCAP, SETGID, SETUID

toolcontainer-sec-derive 0.7.0
observerdrop-test
validated imagedocker.io/pihole/pihole@sha256:f7d1be836e3b…
validated date2026-07-16
confidencemoderate
validated viadrop-test, ci-smoke
workloadprofiles/workloads/pihole.sh
workload sha256d1757e654379…
ig versionv0.51.0

Feature coverage (ledger)

A drop-test proves the minimum for what the workload exercised. Privilege-relevant features it did not drive are listed honestly — using one may need more than this minimum.

featureevidence / reason
drivenDNS filtering (FTL)pi.hole is answered by FTL itself under the derived set — no upstream in the loop
not drivenDHCP serverdocumented conditional cap NET_ADMIN; not driven
not drivenNTP clientdocumented conditional cap SYS_TIME; not driven
not drivenpriority tuning (SYS_NICE)documented conditional cap; not driven

Drop-test evidence

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

RemovedVerdictObserved
CHOWNrequiredcontainer exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s
DAC_OVERRIDErequiredcontainer exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s
FSETIDremovablecorrect: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)
FOWNERremovablecorrect: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)
MKNODremovablecorrect: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)
NET_RAWremovablecorrect: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)
SETGIDrequiredcontainer exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s
SETUIDrequiredcontainer exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s
SETFCAPrequiredcontainer exited: If you are seeing this error, please set the environment variable 'DNSMASQ_USER' to the value 'root'
SETPCAPremovablecorrect: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)
NET_BIND_SERVICErequiredFTL never listened on 53 (file-cap NET_BIND_SERVICE draws from the bounding set)
SYS_CHROOTremovablecorrect: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)
KILLremovablecorrect: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)
AUDIT_WRITEremovablecorrect: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)

Recorded invocation (run_config)

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

sysctlsnet.ipv4.ip_unprivileged_port_start=1024
envFTLCONF_webserver_api_password
Workload script (profiles/workloads/pihole.sh)
#!/usr/bin/env bash
# Workload exerciser for the pihole reference image (DNS-only scope).
#
# Real DNS resolution with no external dependence: dig the `pi.hole` name,
# which pihole-FTL answers authoritatively itself — deterministic, no
# upstream reachability in the loop. Probes exec as --user pihole using the
# in-image dig (a root probe pollutes a caps minimum). The non-root FTL uid
# assert lives in the drop-test correctness check.
#
# Required env: PIHOLECONTAINER (target container name or id).
set -euo pipefail

: "${PIHOLECONTAINER:?PIHOLECONTAINER must be set}"
C="${PIHOLECONTAINER}"

deadline=$((SECONDS + 90))
# capture-then-match — `docker logs | grep -q` under pipefail SIGPIPEs the
# producer once the log outgrows the pipe buffer, turning a match into failure.
until grep -q "listening on .* port 53" <<<"$(docker logs "$C" 2>&1)"; do
    (( SECONDS >= deadline )) && { echo "FTL never listened on 53" >&2; exit 1; }
    sleep 2
done

ans="$(docker exec --user pihole "$C" dig +short +time=3 +tries=2 @127.0.0.1 pi.hole 2>/dev/null)"
if [ -z "$ans" ]; then
    echo "dig pi.hole returned no answer" >&2
    exit 1
fi

Dimension: filesystem

read_only: false, no tmpfs

toolcontainer-sec-derive 0.7.0
observerdrop-test
validated imagedocker.io/pihole/pihole@sha256:f7d1be836e3b…
validated date2026-07-16
confidencehigh
validated viadrop-test, ci-smoke
workloadprofiles/workloads/pihole.sh
workload sha256d1757e654379…
ig versionv0.51.0

Drop-test evidence

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

RemovedVerdictObserved
read_only-rootfsrequiredread-only rootfs is INFEASIBLE: the entrypoint chowns /macvendor.db and sed-rewrites /crontab.txt at the FILESYSTEM ROOT, and the FTL auto-update writes /usr/bin/pihole-FTL - none relocatable to a tmpfs or volume (you cannot tmpfs / or /usr/bin). Verified under pihole's published cap set with generous tmpfs on /etc/pihole,/etc/dnsmasq.d,/run,/tmp,/var/log; the baseline still fails. Reproducer: csd testdata/drop-test/pihole-fs.yaml (exits 3).

Recorded invocation (run_config)

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

envFTLCONF_webserver_api_password
Workload script (profiles/workloads/pihole.sh)
#!/usr/bin/env bash
# Workload exerciser for the pihole reference image (DNS-only scope).
#
# Real DNS resolution with no external dependence: dig the `pi.hole` name,
# which pihole-FTL answers authoritatively itself — deterministic, no
# upstream reachability in the loop. Probes exec as --user pihole using the
# in-image dig (a root probe pollutes a caps minimum). The non-root FTL uid
# assert lives in the drop-test correctness check.
#
# Required env: PIHOLECONTAINER (target container name or id).
set -euo pipefail

: "${PIHOLECONTAINER:?PIHOLECONTAINER must be set}"
C="${PIHOLECONTAINER}"

deadline=$((SECONDS + 90))
# capture-then-match — `docker logs | grep -q` under pipefail SIGPIPEs the
# producer once the log outgrows the pipe buffer, turning a match into failure.
until grep -q "listening on .* port 53" <<<"$(docker logs "$C" 2>&1)"; do
    (( SECONDS >= deadline )) && { echo "FTL never listened on 53" >&2; exit 1; }
    sleep 2
done

ans="$(docker exec --user pihole "$C" dig +short +time=3 +tries=2 @127.0.0.1 pi.hole 2>/dev/null)"
if [ -z "$ans" ]; then
    echo "dig pi.hole returned no answer" >&2
    exit 1
fi

Evidence & provenance

Raw profile YAML
# pihole — minimum Linux capabilities, derived by drop-test against the
# default invocation, DNS-ONLY SCOPE (DHCP mode needs NET_ADMIN and is out of
# scope). This profile trims the full Docker default cap set 14 -> 6 — and
# the finding the coverage queue predicted: upstream docs suggest NET_RAW /
# NET_ADMIN / SYS_NICE, and NONE of them are load-bearing on the DNS path.
#
# capabilities: cap_drop:[ALL] + cap_add:[CHOWN, DAC_OVERRIDE,
# NET_BIND_SERVICE, SETFCAP, SETGID, SETUID]. The architecture is the
# catalog's fourth privilege shape: the ROOT entrypoint provisions
# /etc/pihole (CHOWN/DAC_OVERRIDE) and SETCAPs the pihole-FTL binary
# (SETFCAP — a rare, honest grant), FTL then starts as the pihole user
# (SETGID/SETUID) and receives NET_BIND_SERVICE for the privileged :53 via
# those FILE capabilities.
#
# TWO HARD SCOPE CONDITIONS:
#   1. NO no-new-privileges. Measured incompatibility: nnp blocks file-cap
#      acquisition on exec, so under the pinned privileged-port sysctl an
#      nnp'd pihole can NEVER listen on :53. This is the only catalog profile
#      where nnp must be omitted — a real architectural property, not an
#      oversight.
#   2. NET_BIND_SERVICE is scoped to the pinned
#      net.ipv4.ip_unprivileged_port_start=1024 posture (docker defaults 0).
# See criteria/docker.io/pihole/pihole.md.
# filesystem: read_only:false (a derived NEGATIVE result). pihole cannot run
# under a read-only rootfs as shipped: its entrypoint chowns /macvendor.db and
# sed-rewrites /crontab.txt at the FILESYSTEM ROOT, and the FTL auto-update
# writes /usr/bin/pihole-FTL — none relocatable to a tmpfs/volume. Even with
# generous tmpfs on the config/runtime dirs the baseline fails. See criteria.
#
schema_version: "1.6"
image: docker.io/pihole/pihole
reference_url: https://tmatens.github.io/container-security-profiles/profiles/docker.io/pihole/pihole.html
applies_to:
  tags: ["2026.07.2"]
status: validated
dimensions:
  capabilities:
    cap_drop: [ALL]
    cap_add: [CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETFCAP, SETGID, SETUID]
    derivation:
      tool: container-sec-derive
      tool_version: "0.7.0"
      sidecar_schema_version: "1.5"
      observer: drop-test
      validated_image: docker.io/pihole/pihole@sha256:f7d1be836e3bc608b56d82fc9904f5a831cdfbc0dc9c6d58f94e4c985c70038b
      validated_date: "2026-07-16"
      duration_seconds: 420
      confidence: moderate
      workload: profiles/workloads/pihole.sh
      workload_sha256: "d1757e6543795069f71d3f0fd6cc1c043e48f0ad0a804c9aca2ce69b8c735161"
      validated_via: [drop-test, ci-smoke]
      observation_backend:
        ig_version: v0.51.0
        gadgets: []
      run_config:
        user: ""
        command: []
        entrypoint: ""
        network: ""
        pid: ""
        devices: []
        security_opt: []
        sysctls: ["net.ipv4.ip_unprivileged_port_start=1024"]
        mounts: []
        env: [FTLCONF_webserver_api_password]
      features:
        - name: "DNS filtering (FTL)"
          driven: true
          why: "pi.hole is answered by FTL itself under the derived set — no upstream in the loop"
        - name: "DHCP server"
          driven: false
          why: "documented conditional cap NET_ADMIN; not driven"
        - name: "NTP client"
          driven: false
          why: "documented conditional cap SYS_TIME; not driven"
        - name: "priority tuning (SYS_NICE)"
          driven: false
          why: "documented conditional cap; not driven"
      drop_test:
        checks:
          - {removed: CHOWN, required: true, observed: "container exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s"}
          - {removed: DAC_OVERRIDE, required: true, observed: "container exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s"}
          - {removed: FSETID, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
          - {removed: FOWNER, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
          - {removed: MKNOD, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
          - {removed: NET_RAW, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
          - {removed: SETGID, required: true, observed: "container exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s"}
          - {removed: SETUID, required: true, observed: "container exited: [!] ERROR: Did not find 'FTL started' message in FTL.log in 30s"}
          - {removed: SETFCAP, required: true, observed: "container exited: If you are seeing this error, please set the environment variable 'DNSMASQ_USER' to the value 'root'"}
          - {removed: SETPCAP, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
          - {removed: NET_BIND_SERVICE, required: true, observed: "FTL never listened on 53 (file-cap NET_BIND_SERVICE draws from the bounding set)"}
          - {removed: SYS_CHROOT, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
          - {removed: KILL, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
          - {removed: AUDIT_WRITE, required: false, observed: "correct: pi.hole resolves locally (127.0.0.1), FTL uid=1000 (non-root)"}
  filesystem:
    read_only: false
    tmpfs: []
    derivation:
      tool: container-sec-derive
      tool_version: "0.7.0"
      sidecar_schema_version: "1.5"
      observer: drop-test
      validated_image: docker.io/pihole/pihole@sha256:f7d1be836e3bc608b56d82fc9904f5a831cdfbc0dc9c6d58f94e4c985c70038b
      validated_date: "2026-07-16"
      duration_seconds: 12
      confidence: high
      workload: profiles/workloads/pihole.sh
      workload_sha256: "d1757e6543795069f71d3f0fd6cc1c043e48f0ad0a804c9aca2ce69b8c735161"
      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: [FTLCONF_webserver_api_password]
      drop_test:
        checks:
          - {removed: read_only-rootfs, required: true, observed: "read-only rootfs is INFEASIBLE: the entrypoint chowns /macvendor.db and sed-rewrites /crontab.txt at the FILESYSTEM ROOT, and the FTL auto-update writes /usr/bin/pihole-FTL - none relocatable to a tmpfs or volume (you cannot tmpfs / or /usr/bin). Verified under pihole's published cap set with generous tmpfs on /etc/pihole,/etc/dnsmasq.d,/run,/tmp,/var/log; the baseline still fails. Reproducer: csd testdata/drop-test/pihole-fs.yaml (exits 3)."}