{"id":51722770,"url":"https://github.com/tmatens/container-security-profiles","last_synced_at":"2026-07-17T14:38:36.111Z","repository":{"id":371534574,"uuid":"1288268431","full_name":"tmatens/container-security-profiles","owner":"tmatens","description":"Evidence-backed minimum-security profiles for container images: the capabilities and read-only-filesystem config each image actually needs, with drop-test proof","archived":false,"fork":false,"pushed_at":"2026-07-17T12:46:25.000Z","size":520,"stargazers_count":1,"open_issues_count":2,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-17T14:38:34.099Z","etag":null,"topics":["container-security","docker","docker-compose","hardening","least-privilege","linux-capabilities","security","self-hosted"],"latest_commit_sha":null,"homepage":"https://tmatens.github.io/container-security-profiles/","language":"Shell","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tmatens.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-07-03T12:37:05.000Z","updated_at":"2026-07-17T12:46:42.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/tmatens/container-security-profiles","commit_stats":null,"previous_names":["tmatens/container-security-profiles"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/tmatens/container-security-profiles","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tmatens%2Fcontainer-security-profiles","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tmatens%2Fcontainer-security-profiles/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tmatens%2Fcontainer-security-profiles/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tmatens%2Fcontainer-security-profiles/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tmatens","download_url":"https://codeload.github.com/tmatens/container-security-profiles/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tmatens%2Fcontainer-security-profiles/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35585713,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-17T02:00:06.162Z","response_time":116,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["container-security","docker","docker-compose","hardening","least-privilege","linux-capabilities","security","self-hosted"],"created_at":"2026-07-17T14:38:35.075Z","updated_at":"2026-07-17T14:38:36.105Z","avatar_url":"https://github.com/tmatens.png","language":"Shell","funding_links":[],"categories":[],"sub_categories":[],"readme":"# container-security-profiles\n\n[![validate](https://github.com/tmatens/container-security-profiles/actions/workflows/validate.yml/badge.svg)](https://github.com/tmatens/container-security-profiles/actions/workflows/validate.yml)\n[![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n\n**Evidence-backed minimum-security profiles for container images** — the\ncapabilities and read-only-filesystem config each image *actually needs*,\nderived by removing privileges one at a time and proving the container breaks\nwithout them. Not guidance copied from a blog post: every profile is\ndigest-pinned, backed by a committed workload script, and carries its full\nderivation evidence in the file.\n\nFor example, immich ships its postgres with `cap_add` including `FOWNER` — the\nderived, drop-tested minimum is four capabilities, and `FOWNER` is a genuine\nover-grant:\n\n```yaml\nservices:\n  postgres:\n    image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0\n    cap_drop: [ALL]\n    cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]\n    security_opt: [\"no-new-privileges:true\"]\n```\n\n## The catalog\n\n| Image | Tags | Minimum (all dimensions apply as a unit) | Confidence |\n|---|---|---|---|\n| `codeberg.org/forgejo/forgejo` | `15` | `capabilities: cap_add: [CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETGID, SETUID, SYS_CHROOT] (NET_BIND_SERVICE under ip_unprivileged_port_start=1024)` | high |\n| `docker.io/grafana/alloy` | `v1.16.2` | `capabilities: cap_drop: [ALL] (zero-cap)` · `filesystem: read_only: true` | moderate (caps — collectors) |\n| `docker.io/grafana/grafana` | `13.0.2` | `capabilities: cap_drop: [ALL] (zero-cap)` · `filesystem: read_only: true` | high (caps) · moderate (fs) |\n| `docker.io/grafana/loki` | `3.7.2` | `capabilities: cap_drop: [ALL] (zero-cap)` · `filesystem: read_only: true` | high |\n| `docker.io/jellyfin/jellyfin` | `10.11` | `capabilities: cap_drop: [ALL] (zero-cap)` · `filesystem: read_only: true` · `devices: [/dev/dri] (hw transcode)` | high (caps/fs) · moderate (devices) |\n| `docker.io/binwiederhier/ntfy` | `v2.14.0` | `capabilities: cap_add: [NET_BIND_SERVICE] (under ip_unprivileged_port_start=1024)` · `filesystem: read_only: true` | high |\n| `docker.io/library/caddy` | `2` | `capabilities: cap_add: [NET_BIND_SERVICE]` · `filesystem: read_only: true` | high |\n| `docker.io/library/haproxy` | `3.2` | `capabilities: cap_drop: [ALL] (zero-cap, non-root)` · `filesystem: read_only: true` | high |\n| `docker.io/library/httpd` | `2.4` | `capabilities: cap_add: [NET_BIND_SERVICE, SETGID, SETUID] (under ip_unprivileged_port_start=1024)` · `filesystem: read_only: true, tmpfs: [/usr/local/apache2/logs]` | high |\n| `docker.io/library/eclipse-mosquitto` | `2.0` | `capabilities: cap_add: [SETGID, SETUID]` · `filesystem: read_only: true` | high |\n| `docker.io/library/mariadb` | `11.4` | `capabilities: cap_add: [SETGID, SETUID]` · `filesystem: read_only: true, tmpfs: [/run/mysqld, /tmp]` | high |\n| `docker.io/adguard/adguardhome` | `v0.107.78` | `capabilities: cap_add: [DAC_OVERRIDE, NET_BIND_SERVICE] (DNS-only, under ip_unprivileged_port_start=1024)` · `filesystem: read_only: true` | moderate (caps — DHCP=NET_ADMIN) |\n| `docker.io/library/memcached` | `1.6` | `capabilities: cap_drop: [ALL] (zero-cap)` · `filesystem: read_only: true` | high |\n| `docker.io/louislam/uptime-kuma` | `2` | `capabilities: cap_add: [DAC_OVERRIDE, FOWNER]` (ping monitors add `NET_RAW`) · `filesystem: read_only: true, tmpfs: [/tmp]` | moderate (caps — ping=NET_RAW) |\n| `docker.io/minio/minio` | `RELEASE.2025-09-07…` | `capabilities: cap_drop: [ALL] (zero-cap)` · `filesystem: read_only: true` | high |\n| `docker.io/library/mongo` | `8.0` | `capabilities: cap_add: [SETGID, SETUID]` · `filesystem: read_only: true, tmpfs: [/tmp]` | high |\n| `quay.io/keycloak/keycloak` | `26.4` | `capabilities: cap_drop: [ALL] (zero-cap, non-root)` | high |\n| `docker.io/library/mysql` | `8.4` | `capabilities: cap_add: [DAC_OVERRIDE, SETGID, SETUID]` · `filesystem: read_only: true, tmpfs: [/tmp, /var/run/mysqld]` | high |\n| `docker.io/library/nextcloud` | `32` | `capabilities: cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, NET_BIND_SERVICE, SETGID, SETUID] (under ip_unprivileged_port_start=1024)` · `filesystem: read_only: true, tmpfs: [/var/run/apache2]` | high |\n| `docker.io/library/postgres` | `16` | `capabilities: cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]` · `filesystem: read_only: true, tmpfs: [/run/postgresql]` | high |\n| `docker.io/library/traefik` | `v3.7` | `capabilities: cap_add: [NET_BIND_SERVICE] (under ip_unprivileged_port_start=1024)` · `filesystem: read_only: true` | high |\n| `docker.io/library/wordpress` | `6.9` | `capabilities: cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, NET_BIND_SERVICE, SETGID, SETUID] (under ip_unprivileged_port_start=1024)` · `filesystem: read_only: true, tmpfs: [/var/run/apache2]` | high |\n| `docker.io/library/rabbitmq` | `4.3` | `capabilities: cap_add: [SETGID, SETUID]` | high |\n| `docker.io/syncthing/syncthing` | `2.0` | `capabilities: cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]` · `filesystem: read_only: true` | high |\n| `docker.io/library/redis` | `8.2.7` | `capabilities: cap_add: [SETGID, SETUID]` · `filesystem: read_only: true` | high |\n| `docker.io/prom/prometheus` | `v3.13.1` | `capabilities: cap_drop: [ALL] (zero-cap)` · `filesystem: read_only: true` | high |\n| `docker.io/pihole/pihole` | `2026.07.2` | `capabilities: cap_add: [CHOWN, DAC_OVERRIDE, NET_BIND_SERVICE, SETFCAP, SETGID, SETUID] (DNS-only; NO no-new-privileges — see criteria)` · `filesystem: read_only: false (infeasible — see criteria)` | moderate (caps) · high (fs) |\n| `docker.io/netdata/netdata` | `v2.10.3` | `capabilities: cap_add: [DAC_OVERRIDE, SETGID, SETUID, SYS_PTRACE]` · `filesystem: read_only: true, tmpfs: [/tmp]` (persistent dirs on named volumes; `group_add:` for docker.sock — see criteria) | moderate (caps — see coverage) · high (fs) |\n| `docker.io/valkey/valkey` | `9` | `capabilities: cap_add: [SETGID, SETUID]` · `filesystem: read_only: true` | high · app-tier ✓ |\n| `ghcr.io/gethomepage/homepage` | `v1.13.2` | `filesystem: read_only: true, tmpfs: [/app/.next/cache]` | high |\n| `ghcr.io/home-assistant/home-assistant` | `2026.7.1` | `capabilities: cap_add: [DAC_OVERRIDE]` · `filesystem: read_only: true, tmpfs: [/run:exec]` | moderate (caps — base install) |\n| `ghcr.io/paperless-ngx/paperless-ngx` | `2.18` | `capabilities: cap_add: [CHOWN, SETGID, SETUID]` · `filesystem: read_only: true, tmpfs: [/run:exec, /tmp]` | high |\n| `ghcr.io/immich-app/postgres` | `14-vectorchord…` | `capabilities: cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]` · `filesystem: read_only: true, tmpfs: [/etc/postgresql, /var/run/postgresql]` | high · app-tier ✓ |\n\nAll capability profiles are `cap_drop: [ALL]` plus the listed `cap_add`.\n**Confidence** is `high` only where a single workload provably bounds the\nimage's *privilege* surface; a dimension is `moderate` when an unexercised\noptional feature could need more privilege than the derived minimum (e.g.\nPi-hole's DHCP → `NET_ADMIN`, uptime-kuma's ping monitors → `NET_RAW`, netdata's\ncontainer-network collector → `SYS_ADMIN`) — the criteria doc names what's\nuncovered. The model is [compose-lint ADR-018](https://github.com/tmatens/compose-lint/blob/main/docs/adr/018-confidence-as-multi-axis.md).\nA dimension missing from a row (most commonly `filesystem`) has **not yet been\nderived** for that image — absence means not tested, not that the image can't\nrun read-only. Each profile's header comment calls this out explicitly.\nThe **[browsable catalog site](https://tmatens.github.io/container-security-profiles/)**\nrenders every profile with its copy-paste snippet, drop-test evidence table,\nrecorded invocation, and criteria — or read the YAML directly under\n[`catalog/`](catalog/).\n\nWant an image added? [Request a profile](../../issues/new?template=profile-request.yml).\n\n## Using a profile\n\n**Directly:** copy the dimension into your compose file (each profile page on\nthe site has a ready snippet). One caveat that matters: a profile is the\nminimum **for the recorded invocation** — the `run_config` block in the\nprofile. A different `user:`, a pre-initialised vs fresh data volume, or an\nentrypoint override changes the minimum. Read the profile's `criteria/` doc\nbefore adopting; if a profile breaks your deployment,\n[report the mismatch](../../issues/new?template=profile-mismatch.yml).\n\n**Via compose-lint** (opt-in, experimental preview): point\n[compose-lint](https://github.com/tmatens/compose-lint) ≥ 0.13 at a clone of\nthis catalog and its findings gain image-specific guidance:\n\n```yaml\n# .compose-lint.yml\nprofiles:\n  enabled: true\n  path: /path/to/container-security-profiles/catalog\n```\n\n```\nCL-0006  Service does not drop all capabilities. …\n    fix: …\n    profile hint (csd-derived, confidence high, from docker.io/library/postgres@sha256:fe03a76…,\n    tag match — compose-lint can't see your runtime, confirm it fits your setup):\n    observed minimum is cap_drop: [ALL] + cap_add: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]\n```\n\nEnrichment is advisory only — it never creates, drops, or reclassifies a\nfinding, and only `validated` profiles are consumed.\n\n## Why trust these profiles\n\nEvery `validated` profile clears a machine-checked bar (CI runs it on every\nchange; `make validate` runs the same locally):\n\n- **Schema-valid** against the versioned compose-lint profile schema, fetched\n  from a pinned compose-lint commit (`contract/compose-lint.ref`) at\n  validation time — no vendored copy to drift.\n- **Digest-pinned**: `validated_image` records the exact `…@sha256:…` the\n  evidence was produced against, and `applies_to.tags` may pin only\n  **immutable version tags** — a profile derived against `latest` is\n  meaningless.\n- **Workload-backed**: the exerciser script is committed under\n  [`profiles/workloads/`](profiles/workloads/) and hash-verified\n  (`workload_sha256`). Workloads exercise the image's real function and assert\n  privilege drops (a health check alone is not enough).\n- **Evidence in-file**, per derivation method:\n  - **drop-test** — every granted element removed in turn, the container\n    restarted, the workload re-verified; the per-element results are the\n    `drop_test.checks` table in the profile. This catches *startup-only*\n    minimums (a data-dir `chown`, a root→user privilege drop) that runtime\n    observation is structurally blind to. The whole current catalog is derived\n    this way.\n  - **bpf-observation** — live eBPF observation over a running workload,\n    ≥ 300 s.\n- **App-tier verified** (where marked): the hardening was additionally proven\n  at the *service* level — the full stack brought up with the minimum applied\n  and driven through its real API, including an over-hardening probe showing\n  the check catches a too-tight config.\n- **Freshness-tracked**: a weekly `staleness` workflow compares each pinned\n  digest to the tag's currently published digest and opens a tracking issue on\n  drift, flagging the profile for re-derivation.\n\n**Trust model:** endorsed (`validated`) profiles are ones maintainer\nautomation derives and can re-derive. External contributions land as\n`exploratory` (advisory only, never used for enrichment) until reproduced —\nsee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Layout\n\nCatalog and criteria paths are registry-namespaced\n(`\u003cregistry\u003e/\u003corg\u003e/\u003cimage\u003e`), mirroring the fully-qualified image reference.\n\n- `catalog/….yaml` — validated profiles; `catalog/exploratory/…` — drafts\n  below the promotion bar.\n- `criteria/….md` — per-image scenarios and pass criteria.\n- `profiles/workloads/*.sh` — the committed exerciser scripts.\n- `derivation/manifest.yaml` — the re-derivation spec: how to re-derive each\n  profile representatively (image, dimension, method, workload).\n- `contract/compose-lint.ref` + `scripts/fetch-contract.sh` — the pinned\n  schema/validator contract (fetched into `.contract/`, gitignored).\n- `scripts/check_staleness.py` — the registry-only digest-drift check.\n- `scripts/build_site.py` — the static catalog-site generator\n  (`make site`; deployed by `.github/workflows/pages.yml`).\n\n## Validation\n\n```sh\npip install -r requirements.txt\nmake validate        # fetches the pinned contract, then validates the catalog\n```\n\nCI runs the same on every PR and push to main, plus a smoke build of the\ncatalog site.\n\n## Relationship to compose-lint\n\nThis is the external profile catalog described in compose-lint\n[ADR-017](https://github.com/tmatens/compose-lint/blob/main/docs/adr/017-security-profile-catalog.md):\nthe two are tied by exactly one thing — the versioned profile schema.\ncompose-lint ships the schema, loader, and validator but **no profiles**; this\ncatalog ships profiles but no code dependency. Data flows one way (catalog →\nconsumer), and consumption is opt-in. The name is consumer-neutral on purpose:\ncompose-lint is the first consumer, not the only possible one.\n\n## How profiles are derived\n\nProfiles are derived by **container-sec-derive (csd)** — a runtime tool that\nobserves a running container via eBPF (capabilities, filesystem writes,\ndevices, egress) and drop-tests candidate minimums against a real workload.\ncsd is not yet published; until it is, every profile carries enough evidence\n(`run_config`, workload script, per-element drop-test results) to reproduce\nthe derivation with any harness: apply the profile, run the workload, remove\none element, watch it break.\n\nFor a narrative walk-through of what this looks like in practice — images that\nloudly document a need for `--privileged` or root, and what they actually need\nwhen you measure — see\n[**docs/articles/does-your-container-need-root.md**](docs/articles/does-your-container-need-root.md).\nIt includes a cautionary case (netdata) where measurement first over-derived,\nthen over-corrected, before a live deployment settled it.\n\n## Contributing\n\nProfile requests, mismatch reports, and profile contributions are all\nwelcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Security policy:\n[SECURITY.md](SECURITY.md).\n\n## License\n\n[MIT](LICENSE). Profiles are data — reuse them freely with attribution.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftmatens%2Fcontainer-security-profiles","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftmatens%2Fcontainer-security-profiles","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftmatens%2Fcontainer-security-profiles/lists"}