Skip to content

Add OpenShift compatibility: rootless, arbitrary-UID support - #432

Open
jfstenuit wants to merge 17 commits into
MISP:masterfrom
jfstenuit:openshift-ready
Open

jfstenuit wants to merge 17 commits into
MISP:masterfrom
jfstenuit:openshift-ready

Conversation

@jfstenuit

Copy link
Copy Markdown

MISP's images assumed a fixed UID and root-then-drop-privilege startup (sudo, chown, binding privileged ports, php-fpm's user/group/listen.owner/listen.group directives), which conflicts with OpenShift's restricted SCC - pods run under a randomly assigned, non-predictable UID with primary GID 0, never root, and never with CAP_CHOWN/CAP_SETUID.

This PR reworks misp-core, misp-modules, and misp-guard to start as their final identity instead of dropping into it, following the standard "support arbitrary user IDs" container pattern (owner + group 0, g=u permissions, setgid on directories so new files stay group-writable regardless of the runtime UID). Docker Compose, the raw Kubernetes manifests, and the Helm chart are all updated to match - including moving nginx off privileged ports 80/443, removing hardcoded runAsUser/fsGroup from pod security contexts, and generating all credentials (admin, API key, supervisord, MariaDB) as strong random secrets instead of static defaults. Non-OpenShift usage (plain Docker/Compose/vanilla k8s with a fixed UID) is unaffected - the default UID (33/www-data) is kept and simply added to supplementary group 0.

@ostefano

Copy link
Copy Markdown
Collaborator

Thanks for this.

We might need to give priority to this though at this stage #430

Would a rebase possible (some of the concerns should already be addressed by that PR) once we merge?

@jfstenuit

Copy link
Copy Markdown
Author

There is ~40% overlap with the work of PR #430 . Probably better of create a new PR once #430 has been merged.

@ostefano

Copy link
Copy Markdown
Collaborator

There is ~40% overlap with the work of PR #430 . Probably better of create a new PR once #430 has been merged.

Could you do a review of that PR and check whether the direction is compatible with yours?

@jfstenuit

Copy link
Copy Markdown
Author

There is ~40% overlap with the work of PR #430 . Probably better of create a new PR once #430 has been merged.

Could you do a review of that PR and check whether the direction is compatible with yours?

Clearly, this goes in the right direction. The new nginx container is natively Openshift-compatible and the move to unprivileged ports aligns with the security requirements. After that change, I'll still have to adapt the init scripts and remove any leftover "sudo" or "chown" along the way. There is also the "-R" flag of php-fpm that needs to be dropped.

Also, the whole helm chart needs to be reviewed and aligned with the new Dockerfile.

I suggest I start a new branch once #430 is merged, with two change sets :

  • one to fix the remaining privilege issues in the common code
  • one for the helm chart

@pascaliske

pascaliske commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

@jfstenuit Nice! I had the core container on my todo list as well after #430 is merged. Thanks for taking over. 😁

In addition to the permission and UID changes I planned to switch the FPM / PHP config to a template based syntax as the nginx config in #430 instead of all the sed commands. Maybe you're interested in integrating that in your PR too?

PHP templates
# docker-compose.yml
services:
  misp-core:
    tmpfs:
      - /opt/php:mode=0755,uid=33,gid=33
# core/Dockerfile
ENV PHP_INI_SCAN_DIR="/etc/php/${PHP_PACKAGE_VERSION}/conf.d:/opt/php/conf.d"

...

# overwrite global php-fpm config to load dynamic pool configs
COPY <<-EOF /etc/php/${PHP_PACKAGE_VERSION}/fpm/php-fpm.conf
[global]
pid = /run/php/php${PHP_PACKAGE_VERSION}-fpm.pid
error_log = /var/log/php${PHP_PACKAGE_VERSION}-fpm.log
include=/etc/php/${PHP_PACKAGE_VERSION}/fpm/pool.d/*.conf
include=/opt/php/pool.d/*.conf
EOF
# core/files/entrypoint_fpm.sh
generate_php_config() {
    local REDIS_HOST_WITH_SCHEME
    local REDIS_PASSWORD_ESCAPED
    local STATUS_LISTEN

    mkdir -p /opt/php/{pool.d,conf.d}

    REDIS_HOST_WITH_SCHEME=$(echo "$REDIS_HOST" | grep -E '^\w+://' || echo "tcp://$REDIS_HOST")
    REDIS_PASSWORD_ESCAPED=$(printf '%s\n' "$REDIS_PASSWORD" | sed -e 's/[\/&]/\\&/g')

    if [[ "$ENABLE_REDIS_EMPTY_PASSWORD" == "true" ]]; then
        REDIS_SAVE_PATH="${REDIS_HOST_WITH_SCHEME}:${REDIS_PORT}"
    elif [[ -n "$REDIS_PASSWORD" ]]; then
        REDIS_SAVE_PATH="${REDIS_HOST_WITH_SCHEME}:${REDIS_PORT}?auth=${REDIS_PASSWORD_ESCAPED}"
    else
        echo "ERROR: REDIS_PASSWORD is not set but ENABLE_REDIS_EMPTY_PASSWORD is false. Please set REDIS_PASSWORD or enable ENABLE_REDIS_EMPTY_PASSWORD=true for passwordless Redis."
        exit 1
    fi

    # FPM listen address
    if [[ -n "$PHP_LISTEN_FPM" ]]; then
        FPM_LISTEN=$([[ "$DISABLE_IPV6" == "true" ]] && echo "0.0.0.0:9002" || echo "[::]:9002")
    fi

    # FPM status block
    if [[ -n "$FASTCGI_LISTEN_STATUS" ]]; then
        STATUS_LISTEN=$([[ "$DISABLE_IPV6" == "true" ]] && echo "0.0.0.0:9003" || echo "[::]:9003")
        FPM_STATUS_BLOCK="pm.status_listen=${STATUS_LISTEN}"$'\n'"pm.status_path=/fpm-status"
    else
        FPM_STATUS_BLOCK=""
    fi

    # pm.max_spare_servers: must be >= pm.start_servers
    if [[ "$PHP_FCGI_START_SERVERS" -gt "$PHP_FCGI_SPARE_SERVERS" ]]; then
        PHP_FCGI_MAX_SPARE_SERVERS="$PHP_FCGI_START_SERVERS"
    else
        PHP_FCGI_MAX_SPARE_SERVERS="$PHP_FCGI_SPARE_SERVERS"
    fi

    # --- render templates ---
    export REDIS_SAVE_PATH FPM_LISTEN FPM_STATUS_BLOCK PHP_FCGI_MAX_SPARE_SERVERS
    envsubst < /opt/php/templates/99-runtime.ini > /opt/php/conf.d/99-runtime.ini
    envsubst < /opt/php/templates/99-www.conf > /opt/php/pool.d/99-www.conf
}
# core/files/opt/php/templates/99-runtime.ini
memory_limit=${PHP_MEMORY_LIMIT}
max_execution_time=${PHP_MAX_EXECUTION_TIME}
upload_max_filesize=${PHP_UPLOAD_MAX_FILESIZE}
max_file_uploads=${PHP_MAX_FILE_UPLOADS}
post_max_size=${PHP_POST_MAX_SIZE}
max_input_time=${PHP_MAX_INPUT_TIME}
date.timezone=${TZ}

session.save_handler=redis
session.save_path='${REDIS_SAVE_PATH}'
session.sid_length=64
session.use_strict_mode=1
# core/files/opt/php/templates/99-www.conf
listen=${FPM_LISTEN}
pm.max_children=${PHP_FCGI_CHILDREN}
pm.start_servers=${PHP_FCGI_START_SERVERS}
pm.min_spare_servers=${PHP_FCGI_SPARE_SERVERS}
pm.max_spare_servers=${PHP_FCGI_MAX_SPARE_SERVERS}
pm.max_requests=${PHP_FCGI_MAX_REQUESTS}
${FPM_STATUS_BLOCK}

@t-desmond

Copy link
Copy Markdown

Really appreciate all the work that's gone into this! I tried deploying the chart myself and ran into a couple of issues. I managed to fix most of them, but after that the pod still ends up in a crash loop because:

  • customize_misp.sh restarts the workers (EACCES, exit 244)
  • entrypoint_nginx.sh restarts php-fpm, which then fails due to the read-only filesystem and a socket conflict
  • nginx exits, and the liveness probe eventually kills the pod

At this point, I think I'll just sit quietly and wait for the fix to be merged. 😅 Thanks again for all the work you're putting into this!

@ostefano

Copy link
Copy Markdown
Collaborator

@jfstenuit this should be now rebased, otherwise I will close it

@ostefano ostefano left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rebase

@ostefano ostefano added documentation Improvements or additions to documentation enhancement New feature or request breaking Breaking change k8s Request or change related to K8S support labels Sep 10, 2026
@ostefano

Copy link
Copy Markdown
Collaborator

@jfstenuit we merged the other big change (nginx container) so this might be a good time to revisit this.

Let me know if you are interested, otherwise I will close this.

@jfstenuit

Copy link
Copy Markdown
Author

Hi @ostefano . Pending time re-allocation, but rebase and adaptation is definitely foreseen within the month.

…ID support for OpenShift

MISP's containers relied on running as root and dropping privilege at
runtime (supervisord as root, ~85 `sudo -u www-data` calls, nginx binding
80/443, php-fpm's -R flag, chown-based permission enforcement on every
start). None of this works under OpenShift's restricted/restricted-v2
Security Context Constraint, which always runs containers as an arbitrary,
unpredictable UID, never root, and forbids privilege escalation.

Rather than special-case OpenShift, all three images now run as their
final identity from PID 1 everywhere (Docker Compose, Kubernetes, Helm):

- misp-core: removed the sudo package and every sudo invocation in
  configure_misp.sh/utilities.sh; dropped php-fpm's -R flag and all
  supervisor `user=` directives; nginx moved to unprivileged ports
  8080/8443; every system path the entrypoint chain writes to (nginx,
  php-fpm, supervisord, rsyslog config dirs) is now baked at build time
  with group-0 ownership, group=owner permissions, and setgid on
  directories, so files stay accessible regardless of which UID the
  container actually runs as. Config file rewriting (bootstrap.php,
  database.php, email.php, app/files seeding) switched from
  chmod-then-edit-in-place to copy/recreate-then-edit, since chmod and
  in-place writes require file ownership, not just group-write access.
  rsyslog's imuxsock module (needs a writable /dev/log) and
  update-ca-certificates (needs a writable system trust store) are now
  root-only, skipped otherwise, since neither is achievable without root
  or a writable image layer.
- misp-modules/misp-guard: their service users are now supplementary
  members of group 0, and their writable directories (/custom,
  /home/misp, /srv, /home/guard, the shared mitmproxy CA volume) are
  group-0-owned with setgid, matching the same arbitrary-UID convention.
- docker-compose.yml/Makefile: container-internal ports updated to
  8080/8443; the sudo-only AUDIT_WRITE capability dropped; added
  `make init-dirs` since misp-core can no longer chown its bind-mounted
  host directories itself on first start.
- Helm chart: misp.securityContext no longer pins runAsUser/runAsGroup,
  letting OpenShift assign its own; mariadb/valkey's
  global.compatibility.openshift.adaptSecurityContext made explicit;
  documented the bitnamilegacy registry as an accepted short-term
  tradeoff for OpenShift compatibility on the DB/cache layer.
- Raw kubernetes/manifests: replaced fixed fsGroup/runAsUser with
  restricted-compliant securityContext (no fixed UID, capabilities
  dropped, no privilege escalation); documented that the stock
  mariadb/redis images here aren't arbitrary-UID-safe on a fresh PVC and
  that the Helm chart is the recommended OpenShift path.
- default admin account back to an e-mail address
- properly start supervisord and background workers
- use a dynamically generated random password for supervisord
…swd file, as expected) - now runs via libnsswrapper to ensure the name 'www-data' is mapped to the OpenShift random UID

readinessProbe was permanently broken - it grepped /misp/readiness/ready.log, a file nothing ever wrote. Fixed by doing a simple httpGet on port 8080
customize_misp.sh hung forever polling ${BASE_URL} - now polling localhost instead.
@jfstenuit

Copy link
Copy Markdown
Author

PR is now re-based, but still require some tests :

  • effective deployment on OpenShift
  • effective deployment on docker (to ensure no regression introduced)

Comment thread README.md Outdated

1. Copy the `template.env` file to `.env` in the project root.
2. Customize `.env` according to your requirements (optional but recommended).
3. `make init-dirs` (or manually `mkdir -p configs logs files ssl gnupg && chown -R 33:0 configs logs files ssl gnupg`) - `misp-core` runs as a non-root user and can no longer `chown` these bind-mounted host directories itself on first start.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the execution of misp-core fails if those dirs are not created / chowned corrrectly? We should fail early if possible

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would assume that misp-core fails because the permissions of those dirs will be wrong on first start. But this could probably be automated using init containers (for both docker and k8s):

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be addressed by commit 9b4e531

Jean-Francois Stenuit added 3 commits September 24, 2026 11:05
Deployments behind a TLS-terminating reverse proxy/ingress (e.g. Traefik,
an OpenShift Route with edge termination) legitimately have BASE_URL set
to https while this container itself only ever sees plain HTTP and holds
no certificate. The existing check couldn't tell that apart from a real
misconfiguration and would exit 1. Replaces the old DISABLE_SSL_REDIRECT
variable removed upstream, which covered the same case.
/etc/supervisor/conf.d is mounted from an emptyDir that survives
container restarts within the same pod (unlike the pod's init containers,
which only re-run on pod recreation). If the script reaches PHP-FPM once
and later crashes for an unrelated reason, the next container restart
would find the .kubernetes source files already consumed by the earlier
mv and fail right here every time - masking the actual, still-unknown
crash reason behind an unrelated "No such file or directory".
The refresh curl had no timeout at all. On a host with no outbound
internet access this hangs until the OS's own TCP connect timeout (well
over a minute) rather than failing fast, which alone can exceed the
pod's startup probe budget and put it in a restart loop before the
container ever gets a chance to become ready. Bound both the connection
and overall request, and fall back to the local (build-time) CA bundle
if the remote isn't reachable instead of leaving cacert.pem untouched.
Jean-Francois Stenuit added 4 commits September 24, 2026 13:51
PHP-FPM's default clear_env=yes strips the environment - including
LD_PRELOAD and NSS_WRAPPER_PASSWD/GROUP - from request-handling worker
processes. entrypoint.sh's nss_wrapper shim needs those to make our
arbitrary OpenShift UID resolve to "www-data" (see its own comments).
Without this, any `cake` subprocess PHP shells out to from within a web
request (e.g. loading /servers/serverSettings) inherits a stripped
environment and fails MISP's own OS-user check ("This OS user is not
allowed to run this command"), even though the identical check passes
for supervisord-managed background workers and anything the entrypoint
script invokes directly - neither of which goes through PHP-FPM.
MISP core's AdminShell::__assertOsUserMayWrite() checks a plain string
equality: $whoami === Configure::read('MISP.osuser'), where $whoami comes
from ProcessTool::whoami() (posix_getpwuid(posix_geteuid())['name'], with
a whoami-shell-out fallback) - and prints that exact value back in its
error ("You tried to run this command as: X"). On at least one code path
under OpenShift's arbitrary per-namespace UID assignment, that resolves
to the raw numeric UID rather than the nss_wrapper-mapped "www-data",
even though the identical check passes for supervisord-managed workers
and anything the entrypoint script invokes directly. Rather than keep
chasing why nss_wrapper doesn't reach that specific call, let MISP.osuser
be pinned to whatever value it actually needs to match.

MISP.osuser previously only had a default (applied if unset, no env var
override). Moved it to the "enforced" mechanism (minimum_config.envars.json,
always applied from an env var via envsubst) instead, matching how
SimpleBackgroundJobs.supervisor_host etc. already work. envsubst has no
${VAR:-default} fallback of its own, so the "www-data" default is now
resolved at the bash level (matching the existing PYTHON_BIN/GPG_BINARY/
SETTING_CONTACT pattern) before envsubst ever sees it, keeping the
previous default behavior for anyone not setting MISP_OSUSER.
Module_send_report_to_CTIInfoExtractor.php and Module_ai_summarize_report.php
both declare $id = 'send-report-to-cti-info-extractor', which makes
Workflow::loadAllWorkflowModules() throw WorkflowDuplicatedModuleIDException
on every load - a 500 on /servers/serverSettings, among anything else that
reads server settings. Confirmed against MISP/MISP: the current 2.5 branch
no longer has this file at all, superseded by Module_ai_summarize_report.php
in 1072b37f ("Rework the CTIInfoExtractor workflow action into Summarise
report with AI") - that commit landed after v2.5.47 was tagged. Remove the
stale duplicate ourselves until a later CORE_TAG picks up the real fix;
rm -f is a no-op once it does.
Addresses pascaliske's review comment on MISP#432: the manual `make init-dirs`
step (misp-core can no longer chown its own bind-mounted host directories
on first start, now that it runs as a fixed non-root user from process 1)
was a regression in onboarding UX that init containers are the standard
fix for.

- core/files/init.sh: fixes ownership of misp-core's own bind mounts
  (configs, logs, files, gnupg) to 33:0, skipping any already correct -
  ssl is deliberately not included, see below.
- docker-compose.yml: new misp-core-init service runs this as root, once,
  before misp-core starts (depends_on: condition: service_completed_
  successfully). Reuses the misp-core image purely as "a Linux userland
  with chown available" rather than building/pulling a separate image for
  it. Both services now reference the same volume list via a shared YAML
  anchor (x-misp-core-volumes), so the mount list is never duplicated.
- Makefile, README.md: drop `make init-dirs` and the manual step it
  documented (introduced by this same unreleased PR, so no deprecation
  period needed) - ownership is now handled automatically.

ssl is deliberately excluded from this: it's mounted read-only into
misp-nginx (a different, non-root user, 101) and nginx never writes to
it, so there is no ownership to fix - only a *readability* constraint,
documented as a README note instead (nginx-unprivileged's fixed UID needs
read access to cert.pem/key.pem, which default permissions from the
documented openssl command already satisfy).

misp-modules' and misp-guard's own bind mounts were also reviewed:
misp-modules' ./custom/*_mod/ dirs are read-only from the app's
perspective (custom modules are imported, not written to) and typically
fine under default host directory permissions; misp-guard's only host
bind mount (./guard/config.json) is already :ro in compose. Neither
needed the same treatment.
@jfstenuit
jfstenuit requested a review from ostefano September 28, 2026 09:07
@ostefano

Copy link
Copy Markdown
Collaborator

@ostefano
ostefano requested a review from pascaliske September 28, 2026 10:34
@ostefano

ostefano commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator

@pascaliske @jfstenuit what is the status of this review / PR ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaking change documentation Improvements or additions to documentation enhancement New feature or request k8s Request or change related to K8S support

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants