catalog / docker.io/binwiederhier/ntfy

docker.io/binwiederhier/ntfy

validated  pin current  tags: v2.14.0

ntfy — minimum capabilities AND read-only filesystem, derived by drop-test
against the default `serve` invocation (no config = in-memory store).
Capabilities trim 14 -> 1; the filesystem locks read_only with no tmpfs.

capabilities: cap_drop:[ALL] + cap_add:[NET_BIND_SERVICE] — a single Go
binary running as root whose ONLY load-bearing capability is its default
PRIVILEGED :80 listener, and only under the pinned
net.ipv4.ip_unprivileged_port_start=1024 posture (docker defaults 0). A
deployment setting listen-http to a high port runs ZERO-cap; one keeping
docker's default sysctl can drop the cap too.

filesystem: read_only:true, tmpfs:[] — the default in-memory store writes
nothing; enabling cache-file / attachment-cache adds a data VOLUME, not
tmpfs. See criteria/docker.io/binwiederhier/ntfy.md.

Use it

services:
  ntfy:
    image: docker.io/binwiederhier/ntfy:v2.14.0
    cap_drop: [ALL]
    cap_add: [NET_BIND_SERVICE]
    security_opt: ["no-new-privileges:true"]
    read_only: true

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: NET_BIND_SERVICE

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

Drop-test evidence

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

RemovedVerdictObserved
CHOWNremovablecorrect: publish/poll round-trip ok
DAC_OVERRIDEremovablecorrect: publish/poll round-trip ok
FSETIDremovablecorrect: publish/poll round-trip ok
FOWNERremovablecorrect: publish/poll round-trip ok
MKNODremovablecorrect: publish/poll round-trip ok
NET_RAWremovablecorrect: publish/poll round-trip ok
SETGIDremovablecorrect: publish/poll round-trip ok
SETUIDremovablecorrect: publish/poll round-trip ok
SETFCAPremovablecorrect: publish/poll round-trip ok
SETPCAPremovablecorrect: publish/poll round-trip ok
NET_BIND_SERVICErequiredcontainer exited: listen tcp :80: bind: permission denied
SYS_CHROOTremovablecorrect: publish/poll round-trip ok
KILLremovablecorrect: publish/poll round-trip ok
AUDIT_WRITEremovablecorrect: publish/poll round-trip ok

Recorded invocation (run_config)

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

commandserve
security_optno-new-privileges:true
sysctlsnet.ipv4.ip_unprivileged_port_start=1024
Workload script (profiles/workloads/ntfy.sh)
#!/usr/bin/env bash
# Workload exerciser for the ntfy reference image (`serve`, default config —
# in-memory store). Real function = publish a message to a topic and poll it
# back, matching the payload. Probes ride a curl sidecar, write nothing in
# the target, capture-then-match.
# Required env: NTFYCONTAINER (target container name or id).
set -euo pipefail
: "${NTFYCONTAINER:?NTFYCONTAINER must be set}"
C="${NTFYCONTAINER}"
PROBE="${CSD_PROBE_CURL_IMAGE:-curlimages/curl@sha256:c1fe1679c34d9784c1b0d1e5f62ac0a79fca01fb6377cdd33e90473c6f9f9a69}"
sc() { docker run --rm --network "container:${C}" "$PROBE" "$@"; }
deadline=$((SECONDS + 30))
until [ "$(sc -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:80/v1/health 2>/dev/null)" = 200 ]; do
    (( SECONDS >= deadline )) && { echo "ntfy never healthy" >&2; exit 1; }
    sleep 1
done
code="$(sc -s -o /dev/null -w '%{http_code}' --max-time 5 -d "csd-msg-42" http://localhost:80/csdtopic 2>/dev/null)"
[ "$code" = 200 ] || { echo "publish failed: HTTP ${code:-none}" >&2; exit 1; }
got="$(sc -s --max-time 5 "http://localhost:80/csdtopic/json?poll=1&since=all" 2>/dev/null)"
case "$got" in *'"message":"csd-msg-42"'*) ;; *) echo "poll missing message" >&2; exit 1;; esac

Dimension: filesystem

read_only: true, no tmpfs

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

Drop-test evidence

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

RemovedVerdictObserved
/tmpremovablecorrect: publish/poll round-trip ok

Recorded invocation (run_config)

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

commandserve
security_optno-new-privileges:true
Workload script (profiles/workloads/ntfy.sh)
#!/usr/bin/env bash
# Workload exerciser for the ntfy reference image (`serve`, default config —
# in-memory store). Real function = publish a message to a topic and poll it
# back, matching the payload. Probes ride a curl sidecar, write nothing in
# the target, capture-then-match.
# Required env: NTFYCONTAINER (target container name or id).
set -euo pipefail
: "${NTFYCONTAINER:?NTFYCONTAINER must be set}"
C="${NTFYCONTAINER}"
PROBE="${CSD_PROBE_CURL_IMAGE:-curlimages/curl@sha256:c1fe1679c34d9784c1b0d1e5f62ac0a79fca01fb6377cdd33e90473c6f9f9a69}"
sc() { docker run --rm --network "container:${C}" "$PROBE" "$@"; }
deadline=$((SECONDS + 30))
until [ "$(sc -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:80/v1/health 2>/dev/null)" = 200 ]; do
    (( SECONDS >= deadline )) && { echo "ntfy never healthy" >&2; exit 1; }
    sleep 1
done
code="$(sc -s -o /dev/null -w '%{http_code}' --max-time 5 -d "csd-msg-42" http://localhost:80/csdtopic 2>/dev/null)"
[ "$code" = 200 ] || { echo "publish failed: HTTP ${code:-none}" >&2; exit 1; }
got="$(sc -s --max-time 5 "http://localhost:80/csdtopic/json?poll=1&since=all" 2>/dev/null)"
case "$got" in *'"message":"csd-msg-42"'*) ;; *) echo "poll missing message" >&2; exit 1;; esac

Evidence & provenance

Raw profile YAML
# ntfy — minimum capabilities AND read-only filesystem, derived by drop-test
# against the default `serve` invocation (no config = in-memory store).
# Capabilities trim 14 -> 1; the filesystem locks read_only with no tmpfs.
#
# capabilities: cap_drop:[ALL] + cap_add:[NET_BIND_SERVICE] — a single Go
# binary running as root whose ONLY load-bearing capability is its default
# PRIVILEGED :80 listener, and only under the pinned
# net.ipv4.ip_unprivileged_port_start=1024 posture (docker defaults 0). A
# deployment setting listen-http to a high port runs ZERO-cap; one keeping
# docker's default sysctl can drop the cap too.
#
# filesystem: read_only:true, tmpfs:[] — the default in-memory store writes
# nothing; enabling cache-file / attachment-cache adds a data VOLUME, not
# tmpfs. See criteria/docker.io/binwiederhier/ntfy.md.
schema_version: "1.5"
image: docker.io/binwiederhier/ntfy
reference_url: https://tmatens.github.io/container-security-profiles/profiles/docker.io/binwiederhier/ntfy.html
applies_to:
  tags: ["v2.14.0"]
status: validated
dimensions:
  capabilities:
    cap_drop: [ALL]
    cap_add: [NET_BIND_SERVICE]
    derivation:
      tool: container-sec-derive
      tool_version: "0.7.0"
      sidecar_schema_version: "1.5"
      observer: drop-test
      validated_image: docker.io/binwiederhier/ntfy@sha256:5a051798d14138c3ecb12c038652558ab6a077e1aceeb867c151cbf5fa8451ef
      validated_date: "2026-07-16"
      duration_seconds: 180
      confidence: high
      workload: profiles/workloads/ntfy.sh
      workload_sha256: "35a83586552bbccfd86103a5504b3f43c44f1b960b05b941fb681766282c7fb8"
      validated_via: [drop-test, ci-smoke]
      observation_backend:
        ig_version: v0.51.0
        gadgets: []
      run_config:
        user: ""
        command: ["serve"]
        entrypoint: ""
        network: ""
        pid: ""
        devices: []
        security_opt: ["no-new-privileges:true"]
        sysctls: ["net.ipv4.ip_unprivileged_port_start=1024"]
        mounts: []
        env: []
      drop_test:
        checks:
          - {removed: CHOWN, required: false, observed: "correct: publish/poll round-trip ok"}
          - {removed: DAC_OVERRIDE, required: false, observed: "correct: publish/poll round-trip ok"}
          - {removed: FSETID, required: false, observed: "correct: publish/poll round-trip ok"}
          - {removed: FOWNER, required: false, observed: "correct: publish/poll round-trip ok"}
          - {removed: MKNOD, required: false, observed: "correct: publish/poll round-trip ok"}
          - {removed: NET_RAW, required: false, observed: "correct: publish/poll round-trip ok"}
          - {removed: SETGID, required: false, observed: "correct: publish/poll round-trip ok"}
          - {removed: SETUID, required: false, observed: "correct: publish/poll round-trip ok"}
          - {removed: SETFCAP, required: false, observed: "correct: publish/poll round-trip ok"}
          - {removed: SETPCAP, required: false, observed: "correct: publish/poll round-trip ok"}
          - {removed: NET_BIND_SERVICE, required: true, observed: "container exited: listen tcp :80: bind: permission denied"}
          - {removed: SYS_CHROOT, required: false, observed: "correct: publish/poll round-trip ok"}
          - {removed: KILL, required: false, observed: "correct: publish/poll round-trip ok"}
          - {removed: AUDIT_WRITE, required: false, observed: "correct: publish/poll round-trip ok"}
  filesystem:
    read_only: true
    tmpfs: []
    derivation:
      tool: container-sec-derive
      tool_version: "0.7.0"
      sidecar_schema_version: "1.5"
      observer: drop-test
      validated_image: docker.io/binwiederhier/ntfy@sha256:5a051798d14138c3ecb12c038652558ab6a077e1aceeb867c151cbf5fa8451ef
      validated_date: "2026-07-16"
      duration_seconds: 60
      confidence: high
      workload: profiles/workloads/ntfy.sh
      workload_sha256: "35a83586552bbccfd86103a5504b3f43c44f1b960b05b941fb681766282c7fb8"
      validated_via: [drop-test, ci-smoke]
      observation_backend:
        ig_version: v0.51.0
        gadgets: []
      run_config:
        user: ""
        command: ["serve"]
        entrypoint: ""
        network: ""
        pid: ""
        devices: []
        security_opt: ["no-new-privileges:true"]
        mounts: []
        env: []
      drop_test:
        checks:
          - {removed: /tmp, required: false, observed: "correct: publish/poll round-trip ok"}