diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ccfbf66..13f8bc3 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -51,7 +51,7 @@ Ignity supervises container processes through three discrete stages: **Stage 2 (`/etc/s6/boot/stage2`):** Service & CMD Execution (depends on `IGNITY_MODE`) - `stage2-copy`: Copies `/etc/ignity/{init,finalize,services}` to `/run/ignity`, only what the mode needs - `stage2-init`: Executes the `/run/ignity/init/*` copies of `/etc/ignity/init/*` in order (skipped in `services` mode) -- `stage2-cmd`: Runs the CMD, dropping to `USERMAP_UID:USERMAP_GID` via `s6-applyuidgid` only if PID 1 is root and the mapping isn't 0:0 (supplementary groups set to the gid) +- `stage2-cmd`: Runs the CMD as PID 1 (root) by default. With `IGNITY_CMD_DROP_PRIVILEGES=1`, drops to `USERMAP_UID:USERMAP_GID` via `s6-applyuidgid` only if PID 1 is root and the mapping isn't 0:0 (supplementary groups set to the gid) - `stage2-services`: Copies `/run/ignity/services/*` into the live scandir `/run/ignity/services-state` and starts them under s6 (skipped in `init` mode) - Optionally runs CMD after services healthy (configurable delay via `IGNITY_CMD_WAIT_FOR_SERVICES`); skipped in `init` mode - Writes the exit status to `/run/ignity/stage2-exited` for stage 3; a failure (or `init` mode completing) stops the supervision tree @@ -141,6 +141,7 @@ Follow conventional commits: `type: description` (types: build, ci, docs, feat, - `.github/workflows/ci.yml` — On PR/push (and reused by release): `poe test` (source), `package` per arch (native runners), `sanitize` runs the suite from the packages on debian/ubuntu/alpine/fedora **Environment Variables (Runtime):** +- `IGNITY_CMD_DROP_PRIVILEGES` — Run the CMD as `USERMAP_UID:USERMAP_GID` instead of root (0/1, default 0) - `IGNITY_CMD_WAIT_FOR_SERVICES` — Wait for services before CMD (0/1) - `IGNITY_CMD_WAIT_FOR_SERVICES_MAXTIME` — Wait timeout in milliseconds (default 5000) - `IGNITY_KILL_GRACETIME` — Shutdown grace period in ms (default 3000) @@ -149,7 +150,7 @@ Follow conventional commits: `type: description` (types: build, ci, docs, feat, - `IGNITY_SKIP_PERMS` — Skip permission fixing (0/1) - `IGNITY_ACCOUNTS_DIR` — Write account files there instead of creating USER in /etc (read-only root filesystem, see `gen-accounts`) - `IGNITY_MODE` — `full` (default) | `init` (init scripts only, then exit) | `services` (skip init scripts); for Kubernetes init containers. No env is handed between containers, share files through a volume -- `USER`, `USERMAP_UID`, `USERMAP_GID` — Non-root user mapping; also the uid/gid the CMD drops to (`stage2-cmd`) when PID 1 is root and the mapping isn't 0:0 +- `USER`, `USERMAP_UID`, `USERMAP_GID` — Non-root user mapping; also the uid/gid the CMD drops to (`stage2-cmd`) with `IGNITY_CMD_DROP_PRIVILEGES=1`, when PID 1 is root and the mapping isn't 0:0 ## Language & Implementation Details diff --git a/README.md b/README.md index 8803948..23c2993 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ IGNITY_INSTALL=package DOCKER_BASE_IMAGE=alpine:3 uv run poe test | Parameter | Description | Value (Current) | |----------------------------------------|-----------------------------------------------------------------------------------------------------------------------|-----------------| +| `IGNITY_CMD_DROP_PRIVILEGES` | Run the `CMD` as `USERMAP_UID:USERMAP_GID` instead of root (0/1, see "How to run the CMD as a non root user") | 0 | | `IGNITY_CMD_WAIT_FOR_SERVICES_MAXTIME` | The maximum time (in milliseconds) the services could take to bring up before proceding to CMD executing | 5000 | | `IGNITY_CMD_WAIT_FOR_SERVICES` | In order to proceed executing CMD overlay will wait until services are up | 0 | | `IGNITY_KILL_FINALIZE_MAXTIME` | The maximum time (in milliseconds) a script in `/etc/ignity/finalize` could take before sending a `KILL` signal to it | 5000 | @@ -125,8 +126,8 @@ IGNITY_INSTALL=package DOCKER_BASE_IMAGE=alpine:3 uv run poe test | `IGNITY_SERVICES_GRACETIME` | How long (in milliseconds) `ignity` should wait services down before sending a down signal | 16000 | | `IGNITY_SYNC_DISKS` | Call `s6-sync` before and after sending the termination signals (0/1) | 1 | | `USER` | User name to map with uid and gid | root | -| `USERMAP_GID` | User gid to map on files, and group of the `CMD` (see "How to run the CMD as a non root user") | 0 | -| `USERMAP_UID` | User uid to map on files, and user of the `CMD` (see "How to run the CMD as a non root user") | 0 | +| `USERMAP_GID` | User gid to map on files, and group of the `CMD` with `IGNITY_CMD_DROP_PRIVILEGES=1` | 0 | +| `USERMAP_UID` | User uid to map on files, and user of the `CMD` with `IGNITY_CMD_DROP_PRIVILEGES=1` | 0 | ### How to integrate ignity in your project from a package @@ -321,8 +322,8 @@ exec with-env -k 'HOME,LANG,TZ,APP_*' -d /run/secrets/myapp myapp from getting it back. Services running with the same uid as PID 1 can read it. - Dropping the uid does not clear the environment: a service still starts with the full container environment unless you narrow it with `-k`. -- The `CMD` runs as `USERMAP_UID:USERMAP_GID` (see below), but it still starts with the full environment unless you - narrow it. Init and finalize scripts run with the uid of PID 1 (root), and the full environment, unless they drop +- The `CMD` runs as root, or as `USERMAP_UID:USERMAP_GID` with `IGNITY_CMD_DROP_PRIVILEGES=1` (see below), but it + still starts with the full environment unless you narrow it. Init and finalize scripts run with the uid of PID 1 (root), and the full environment, unless they drop privileges themselves. ### How to use ignity with Kubernetes init containers @@ -404,7 +405,7 @@ USER ${USERMAP_UID}:${USERMAP_GID} | `CHOWN` | `fix-perms` changing owners | | `FOWNER` | `fix-perms` changing modes of files that belong to another uid (the one it just handed over) | | `DAC_OVERRIDE` | `fix-perms` reading what it just handed over (a `0700` directory of another uid) | -| `SETUID` | The `CMD` (`USERMAP_UID`) and services dropping privileges | +| `SETUID` | The `CMD` (`IGNITY_CMD_DROP_PRIVILEGES`) and services dropping privileges | | `SETGID` | Same, including supplementary groups | | `KILL` | Sending signals to processes running as another uid: stopping services, shutdown (without it the container never stops) | @@ -412,8 +413,9 @@ USER ${USERMAP_UID}:${USERMAP_GID} Add `FSETID` only if your `perms` files set setuid or setgid bits. - This list comes from reading the scripts and is checked by `tests/test_least_privilege.py`. It doesn't cover what your own init scripts and services do. -- A process that drops privileges loses its capabilities (the `CMD` ends up with none), so they only concern PID 1, the - init and finalize scripts. +- A process that drops privileges loses its capabilities (the `CMD` ends up with none with + `IGNITY_CMD_DROP_PRIVILEGES=1`), so they only concern PID 1, the init and finalize scripts, and the `CMD` when it + runs as root. - `allowPrivilegeEscalation: false` is fine: nothing relies on setuid binaries. **Read-only root filesystem** @@ -442,6 +444,7 @@ spec: env: - { name: USERMAP_UID, value: "1000" } - { name: USERMAP_GID, value: "1000" } + - { name: IGNITY_CMD_DROP_PRIVILEGES, value: "1" } securityContext: runAsUser: 0 allowPrivilegeEscalation: false @@ -503,6 +506,7 @@ spec: - { name: USER, value: app } - { name: USERMAP_UID, value: "1000" } - { name: USERMAP_GID, value: "1000" } + - { name: IGNITY_CMD_DROP_PRIVILEGES, value: "1" } securityContext: readOnlyRootFilesystem: true allowPrivilegeEscalation: false @@ -544,9 +548,11 @@ spec: ### How to run the CMD as a non root user -- By default `USERMAP_UID` and `USERMAP_GID` are `0`, so the `CMD` runs as root, like PID 1. -- If PID 1 is root and either of them is not `0`, the `CMD` runs as `USERMAP_UID:USERMAP_GID`. Supplementary groups are - replaced by `USERMAP_GID`, the `CMD` doesn't keep the groups of root. +- By default the `CMD` runs as root, like PID 1, even when `USERMAP_UID` and `USERMAP_GID` are set (they are then only + used for `perms`): the `CMD` is mostly there to debug or test. +- Set `IGNITY_CMD_DROP_PRIVILEGES=1` to opt in. If PID 1 is root and either of `USERMAP_UID` and `USERMAP_GID` is not + `0`, the `CMD` then runs as `USERMAP_UID:USERMAP_GID`. Supplementary groups are replaced by `USERMAP_GID`, the `CMD` + doesn't keep the groups of root. - Only the `CMD` is affected. Init scripts, finalize scripts and services keep running as PID 1, and services drop privileges themselves in their `run` script if they need it. - The environment is not changed: `HOME`, `USER` and the other variables stay as they are, so set them in the container @@ -561,6 +567,7 @@ spec: env: - { name: USERMAP_UID, value: "1000" } - { name: USERMAP_GID, value: "1000" } + - { name: IGNITY_CMD_DROP_PRIVILEGES, value: "1" } ``` ### How to run a container in read-only mode @@ -641,6 +648,8 @@ exec s6-setuidgid daemon myservice It is possible somehow to tweak `ignity` behaviour by providing an already predefined set of environment variables to the execution context: +- `IGNITY_CMD_DROP_PRIVILEGES` (default = 0): Run the `CMD` as `USERMAP_UID:USERMAP_GID` instead of root. See "How to + run the CMD as a non root user". - `IGNITY_CMD_WAIT_FOR_SERVICES_MAXTIME` (default = 5000): The maximum time (in milliseconds) the services could take to bring up before proceding to CMD executing. - `IGNITY_CMD_WAIT_FOR_SERVICES` (default = 0): In order to proceed executing CMD overlay will wait until services are diff --git a/src/etc/s6/boot/stage2-cmd b/src/etc/s6/boot/stage2-cmd index e36f70e..ceeffc3 100755 --- a/src/etc/s6/boot/stage2-cmd +++ b/src/etc/s6/boot/stage2-cmd @@ -1,8 +1,12 @@ #!/usr/lib/ignity/bin/execlineb -S0 -# Run the CMD, dropping privileges to USERMAP_UID:USERMAP_GID when it makes sense: -# - they are both 0 (default): nothing to drop, the CMD runs as root like before +# Run the CMD, as PID 1 (root) unless IGNITY_CMD_DROP_PRIVILEGES=1. +# When opted in, it drops privileges to USERMAP_UID:USERMAP_GID when it makes sense: +# - they are both 0 (default): nothing to drop, the CMD runs as root # - PID 1 is not root (e.g. USER set in the Dockerfile): it can't change uid, the CMD runs as is +importas -D 0 IGNITY_CMD_DROP_PRIVILEGES IGNITY_CMD_DROP_PRIVILEGES +ifelse { eltest ${IGNITY_CMD_DROP_PRIVILEGES} -ne 1 } { $@ } + importas -D 0 USERMAP_UID USERMAP_UID importas -D 0 USERMAP_GID USERMAP_GID diff --git a/src/usr/bin/gen-accounts b/src/usr/bin/gen-accounts index 308485b..ba09507 100755 --- a/src/usr/bin/gen-accounts +++ b/src/usr/bin/gen-accounts @@ -28,7 +28,11 @@ if { exit 1 } if { - ifelse { eltest ${USERMAP_UID} -ge 0 -a ${USERMAP_GID} -ge 0 } { exit 0 } + # eltest -ge accepts values like 1000:0, check the digits instead + ifelse { + if { pipeline { s6-echo -n -- ${USERMAP_UID} } grep -Eqzx "[0-9]+" } + pipeline { s6-echo -n -- ${USERMAP_GID} } grep -Eqzx "[0-9]+" + } { exit 0 } fdmove -c 1 2 foreground { s6-echo -- "[supervisor/accounts] USERMAP_UID and USERMAP_GID must be numbers" } exit 1 diff --git a/tests/test_boot.py b/tests/test_boot.py index e24546c..856b2d9 100644 --- a/tests/test_boot.py +++ b/tests/test_boot.py @@ -45,11 +45,21 @@ def test_cmd_runs_as_root_by_default(image): assert "UID=0 GID=0" in result.stdout +def test_cmd_stays_root_with_usermap_by_default(image): + result = run_in_container( + image, + "echo UID=$(id -u) GID=$(id -g)", + extra_args=env_args(USERMAP_UID=1000, USERMAP_GID=1000), + ) + assert result.returncode == 0 + assert "UID=0 GID=0" in result.stdout + + def test_cmd_drops_privileges_to_usermap(image): result = run_in_container( image, "echo UID=$(id -u) GID=$(id -g) GROUPS=$(id -G)", - extra_args=env_args(USERMAP_UID=1000, USERMAP_GID=1000), + extra_args=env_args(USERMAP_UID=1000, USERMAP_GID=1000, IGNITY_CMD_DROP_PRIVILEGES=1), ) assert result.returncode == 0 assert "UID=1000 GID=1000 GROUPS=1000" in result.stdout @@ -61,6 +71,7 @@ def test_cmd_dropped_cannot_read_pid1_environ(image): "cat /proc/1/environ >/dev/null 2>&1; echo RC=$?", extra_args=[ "-e", "USERMAP_UID=1000", "-e", "USERMAP_GID=1000", "-e", "MYSECRET=x", + "-e", "IGNITY_CMD_DROP_PRIVILEGES=1", ], ) assert "RC=0" not in result.stdout @@ -71,7 +82,7 @@ def test_cmd_exit_code_kept_when_dropping_privileges(image): result = run_in_container( image, "exit 7", - extra_args=env_args(USERMAP_UID=1000, USERMAP_GID=1000), + extra_args=env_args(USERMAP_UID=1000, USERMAP_GID=1000, IGNITY_CMD_DROP_PRIVILEGES=1), ) assert result.returncode == 7 @@ -115,6 +126,7 @@ def test_accounts_of_init_container_mounted_in_main_container(image, tmp_path): *READ_ONLY, "-v", f"{tmp_path}/passwd:/etc/passwd:ro", "-v", f"{tmp_path}/group:/etc/group:ro", + "-e", "IGNITY_CMD_DROP_PRIVILEGES=1", *_ACCOUNT, ], ) @@ -159,6 +171,7 @@ def test_accounts_reject_non_numeric_ids(image): ], ) assert result.returncode != 0 + assert "must be numbers" in result.stdout + result.stderr def test_accounts_never_duplicate_an_existing_id(image): diff --git a/tests/test_least_privilege.py b/tests/test_least_privilege.py index 192ffaf..53fff11 100644 --- a/tests/test_least_privilege.py +++ b/tests/test_least_privilege.py @@ -10,7 +10,7 @@ # What ignity needs when PID 1 is root: # - CHOWN, FOWNER, DAC_OVERRIDE: fix-perms (chown, chmod, then traverse what it just handed over) -# - SETUID, SETGID: dropping privileges (CMD with USERMAP_*, services) +# - SETUID, SETGID: dropping privileges (CMD with IGNITY_CMD_DROP_PRIVILEGES, services) # - KILL: signalling processes running as another uid (stop services, shutdown) MINIMAL = ( "--cap-drop=ALL", @@ -24,7 +24,9 @@ *READ_ONLY, # Written by the init script of the fixture "--tmpfs=/shared", - *env_args(USERMAP_UID=1000, USERMAP_GID=1000, IGNITY_SERVICES_GRACETIME=3000), + *env_args( + USERMAP_UID=1000, USERMAP_GID=1000, IGNITY_CMD_DROP_PRIVILEGES=1, IGNITY_SERVICES_GRACETIME=3000, + ), ) @@ -80,7 +82,9 @@ def _args(without: str | None = None) -> tuple[str, ...]: *caps, "--security-opt=no-new-privileges", # Not read-only: /data is part of the image and has to be fixed by fix-perms - *env_args(USERMAP_UID=1234, USERMAP_GID=1234, IGNITY_SERVICES_GRACETIME=3000), + *env_args( + USERMAP_UID=1234, USERMAP_GID=1234, IGNITY_CMD_DROP_PRIVILEGES=1, IGNITY_SERVICES_GRACETIME=3000, + ), )