From abe7a1a9b9696dd18392850af9300686e37ca572 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 15:36:46 +0000 Subject: [PATCH 1/4] Harden runner pod security defaults The runner previously shipped as root with a writable root filesystem, an empty capabilities block and no seccomp profile, which widened the post-exploitation surface for a component holding broad cluster RBAC. Defaults are now: - pod securityContext: runAsNonRoot, runAsUser/runAsGroup/fsGroup 1000, seccompProfile RuntimeDefault - container securityContext: capabilities.drop [ALL], readOnlyRootFilesystem true - hardenedFs enabled, so the writable emptyDir mounts the read-only root filesystem needs are present by default The image now creates a uid/gid 1000 user and runs as it. git's core.symlinks mitigation moves from --global to --system so it still applies to the non-root user, and the hardenedFs pip cache mount follows the new HOME at /home/robusta/.cache. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016Xudoye1FXWumuQwq5eaHn --- Dockerfile | 11 +++++++- helm/robusta/templates/runner.yaml | 2 +- helm/robusta/values.yaml | 17 ++++++++++--- tests/test_helm_chart.py | 40 ++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 tests/test_helm_chart.py diff --git a/Dockerfile b/Dockerfile index 6d0c98f3d..ef9bbedcd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,7 +86,8 @@ RUN apt-get update \ # Patching CVE-2024-32002 -RUN git config --global core.symlinks false +# --system rather than --global so the setting also applies to the non-root runtime user +RUN git config --system core.symlinks false # Temporary setuptools CVE fix untill python:3.12-slim image will be used. RUN rm -rf /usr/local/lib/python3.11/ensurepip/_bundled/setuptools-65.5.0-py3-none-any.whl @@ -116,6 +117,14 @@ RUN chmod 0644 /etc/apt/keyrings/kubernetes-apt-keyring.asc \ && apt-get install -y --no-install-recommends kubectl \ && rm -rf /var/lib/apt/lists/* +# Run as a non-root user. uid/gid 1000 matches runner.securityContext.pod in the Helm chart. +RUN groupadd --gid 1000 robusta \ + && useradd --uid 1000 --gid 1000 --create-home --home-dir /home/robusta --shell /sbin/nologin robusta \ + && chown -R 1000:1000 /app /venv /etc/robusta /home/robusta + +ENV HOME=/home/robusta +USER 1000 + # Run the application # -u disables stdout buffering https://stackoverflow.com/questions/107705/disable-output-buffering CMD [ "python3", "-u", "-m", "robusta.runner.main"] diff --git a/helm/robusta/templates/runner.yaml b/helm/robusta/templates/runner.yaml index f7d9bd177..85cf0cf44 100644 --- a/helm/robusta/templates/runner.yaml +++ b/helm/robusta/templates/runner.yaml @@ -190,7 +190,7 @@ spec: - name: app-git-volume mountPath: /app/robusta-git - name: cache-volume - mountPath: /root/.cache + mountPath: /home/robusta/.cache - name: venv-lib-volume mountPath: /venv/lib/python3.11/site-packages {{- end }} diff --git a/helm/robusta/values.yaml b/helm/robusta/values.yaml index 243c183c7..bf0d00254 100644 --- a/helm/robusta/values.yaml +++ b/helm/robusta/values.yaml @@ -766,12 +766,21 @@ runner: securityContext: container: allowPrivilegeEscalation: false - capabilities: {} + capabilities: + drop: + - ALL privileged: false - readOnlyRootFilesystem: false - pod: {} + readOnlyRootFilesystem: true + pod: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + # emptyDir/PVC mounts are created root-owned; fsGroup makes them writable for uid 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault # Enable hardened filesystem security (read-only root filesystem with writable volume mounts) - hardenedFs: false + hardenedFs: true setKRRSecurityContext: false #Enabled custom DNS configuration for runner dnsConfig: diff --git a/tests/test_helm_chart.py b/tests/test_helm_chart.py new file mode 100644 index 000000000..5b65e7873 --- /dev/null +++ b/tests/test_helm_chart.py @@ -0,0 +1,40 @@ +import os + +import yaml + +CHART_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "helm", "robusta") +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _values() -> dict: + with open(os.path.join(CHART_DIR, "values.yaml")) as f: + return yaml.safe_load(f) + + +def test_runner_pod_hardened_defaults(): + runner = _values()["runner"] + pod = runner["securityContext"]["pod"] + container = runner["securityContext"]["container"] + + assert pod["runAsNonRoot"] is True + assert pod["runAsUser"] == 1000 + assert pod["runAsGroup"] == 1000 + assert pod["fsGroup"] == 1000 + assert pod["seccompProfile"] == {"type": "RuntimeDefault"} + + assert container["capabilities"] == {"drop": ["ALL"]} + assert container["allowPrivilegeEscalation"] is False + assert container["privileged"] is False + assert container["readOnlyRootFilesystem"] is True + + # the read-only root filesystem needs the writable emptyDir mounts that hardenedFs adds + assert runner["hardenedFs"] is True + + +def test_runner_image_runs_as_non_root_user(): + with open(os.path.join(REPO_ROOT, "Dockerfile")) as f: + directives = [line.strip() for line in f if line.strip().startswith("USER ")] + + # the last USER directive is what the container actually runs as + assert directives, "Dockerfile must set a USER so the runner does not run as root" + assert directives[-1] == "USER 1000" From e65bea0af2a9b4fa85f0cf599c51f92de4077988 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 06:34:57 +0000 Subject: [PATCH 2/4] Fix runtime breakage from the non-root runner defaults Auditing what the runner writes at runtime turned up three problems with the hardened defaults from the previous commit. readOnlyRootFilesystem was a values.yaml default independent of hardenedFs, so anyone with hardenedFs:false pinned in their own values would have gotten a read-only root with none of the writable mounts - the first playbook pip install fails and config_loader kills the process group, crash-looping the pod. It is no longer a standalone default: the template derives it from hardenedFs, which is now the single switch. The template also builds the container securityContext explicitly instead of gating on the user's value, so an empty securityContext.container map no longer drops the injected field, and it uses hasKey rather than merge because sprig's merge treats an explicit false as an absent value and would override it. Git-over-SSH playbook repos were broken in two ways. SSH_ROOT_DIR defaulted to /root/.ssh, which uid 1000 cannot write (already broken for hardenedFs users before this branch), and moving HOME to /home/robusta decoupled the known_hosts write from where ssh reads it, since GIT_SSH_COMMAND passed no UserKnownHostsFile. SSH_ROOT_DIR now defaults under $HOME, the directory is created with makedirs, ssh is pointed at the file we write, and the chart mounts a writable emptyDir for it. KUBECACHEDIR and PYTHONPYCACHEPREFIX now point at /tmp so kubectl's discovery cache and CPython's bytecode writes do not target the read-only filesystem. The grafana-renderer sidecar inherits the runner pod securityContext; it is disabled by default, so this is documented in values.yaml rather than changed. Regression tests cover the hardened defaults, that readOnlyRootFilesystem stays out of values.yaml, that every runtime write path has a mount, and that the version-pinned site-packages mount matches the Dockerfile's python and the cache/ssh mounts match HOME. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016Xudoye1FXWumuQwq5eaHn --- Dockerfile | 2 + helm/robusta/templates/runner.yaml | 26 ++++++--- helm/robusta/values.yaml | 13 ++++- src/robusta/integrations/git/git_repo.py | 15 ++++-- tests/test_helm_chart.py | 68 ++++++++++++++++++++++-- 5 files changed, 107 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index ef9bbedcd..df1898210 100644 --- a/Dockerfile +++ b/Dockerfile @@ -120,6 +120,8 @@ RUN chmod 0644 /etc/apt/keyrings/kubernetes-apt-keyring.asc \ # Run as a non-root user. uid/gid 1000 matches runner.securityContext.pod in the Helm chart. RUN groupadd --gid 1000 robusta \ && useradd --uid 1000 --gid 1000 --create-home --home-dir /home/robusta --shell /sbin/nologin robusta \ + && mkdir -p /home/robusta/.ssh /home/robusta/.cache \ + && chmod 0700 /home/robusta/.ssh \ && chown -R 1000:1000 /app /venv /etc/robusta /home/robusta ENV HOME=/home/robusta diff --git a/helm/robusta/templates/runner.yaml b/helm/robusta/templates/runner.yaml index 85cf0cf44..df4dc8a8d 100644 --- a/helm/robusta/templates/runner.yaml +++ b/helm/robusta/templates/runner.yaml @@ -95,16 +95,24 @@ spec: image: {{ .Values.image.registry }}/{{ .Values.runner.imageName }} {{- end }} imagePullPolicy: {{ .Values.runner.imagePullPolicy }} - {{- with .Values.runner.securityContext.container }} - securityContext: - {{- if $.Values.runner.hardenedFs }} - {{- $hardened := merge (dict "readOnlyRootFilesystem" true) . }} - {{- toYaml $hardened | nindent 12 }} - {{- else }} - {{- toYaml . | nindent 12 }} + {{- $containerSecurityContext := deepCopy (default dict .Values.runner.securityContext.container) }} + {{- /* hardenedFs provides the writable mounts that a read-only root filesystem needs, so + the two are always turned on together. hasKey rather than merge: sprig's merge + treats an explicit "false" as an absent value and would silently override it. */}} + {{- if and .Values.runner.hardenedFs (not (hasKey $containerSecurityContext "readOnlyRootFilesystem")) }} + {{- $containerSecurityContext = set $containerSecurityContext "readOnlyRootFilesystem" true }} {{- end }} + {{- if $containerSecurityContext }} + securityContext: + {{- toYaml $containerSecurityContext | nindent 12 }} {{- end }} env: + # kubectl and CPython both write caches relative to $HOME / the source tree, which are + # on the read-only root filesystem. Redirect them to the writable /tmp mount. + - name: KUBECACHEDIR + value: /tmp/.kube-cache + - name: PYTHONPYCACHEPREFIX + value: /tmp/pycache - name: PLAYBOOKS_CONFIG_FILE_PATH value: /etc/robusta/config/active_playbooks.yaml - name: RELEASE_NAME @@ -191,6 +199,8 @@ spec: mountPath: /app/robusta-git - name: cache-volume mountPath: /home/robusta/.cache + - name: ssh-volume + mountPath: /home/robusta/.ssh - name: venv-lib-volume mountPath: /venv/lib/python3.11/site-packages {{- end }} @@ -254,6 +264,8 @@ spec: emptyDir: {} - name: cache-volume emptyDir: {} + - name: ssh-volume + emptyDir: {} - name: venv-lib-volume emptyDir: {} {{- end }} diff --git a/helm/robusta/values.yaml b/helm/robusta/values.yaml index bf0d00254..ba14ae923 100644 --- a/helm/robusta/values.yaml +++ b/helm/robusta/values.yaml @@ -711,6 +711,9 @@ grafanaRenderer: memory: 512Mi limits: cpu: ~ + # This container runs inside the runner pod, so it inherits runner.securityContext.pod - + # including runAsNonRoot/runAsUser 1000. If this third-party image needs a different uid, or + # declares USER root, override runAsNonRoot/runAsUser here when enabling it. securityContext: container: privileged: false @@ -770,7 +773,9 @@ runner: drop: - ALL privileged: false - readOnlyRootFilesystem: true + # readOnlyRootFilesystem is intentionally NOT set here - it is derived from hardenedFs + # below, so that the read-only root FS always comes together with the writable mounts it + # needs. Set it explicitly only if you know what you are doing; an explicit value wins. pod: runAsNonRoot: true runAsUser: 1000 @@ -779,7 +784,11 @@ runner: fsGroup: 1000 seccompProfile: type: RuntimeDefault - # Enable hardened filesystem security (read-only root filesystem with writable volume mounts) + # Enable hardened filesystem security: adds readOnlyRootFilesystem to the runner container + # plus the writable emptyDir mounts the runner needs at runtime (/tmp, the git clone dir, the + # pip/HOME cache, ~/.ssh and site-packages for runtime playbook installs). + # Known limitation: /venv/bin stays read-only, so a playbook package that declares + # console_scripts entry points cannot be pip-installed at runtime while this is enabled. hardenedFs: true setKRRSecurityContext: false #Enabled custom DNS configuration for runner diff --git a/src/robusta/integrations/git/git_repo.py b/src/robusta/integrations/git/git_repo.py index 8bf2b73b4..f947cb60a 100644 --- a/src/robusta/integrations/git/git_repo.py +++ b/src/robusta/integrations/git/git_repo.py @@ -14,7 +14,10 @@ GIT_DIR_NAME = "robusta-git" REPO_LOCAL_BASE_DIR = os.path.abspath(os.path.join(os.environ.get("REPO_LOCAL_BASE_DIR", "/app"), GIT_DIR_NAME)) -SSH_ROOT_DIR = os.environ.get("SSH_ROOT_DIR", "/root/.ssh") +# The runner does not necessarily run as root, so default to the current user's home +# rather than a hardcoded /root. Must stay in sync with where ssh looks for known_hosts +# (see GIT_SSH_COMMAND below). +SSH_ROOT_DIR = os.environ.get("SSH_ROOT_DIR", os.path.join(os.environ.get("HOME", "/root"), ".ssh")) GIT_REPOS_VERIFIED_HOSTS = os.environ.get("GIT_REPOS_VERIFIED_HOSTS", "") GIT_SSH_PREFIX = "git@" @@ -33,8 +36,7 @@ def setup_host_keys(cls, custom_host_keys: List[str]): if cls.host_keys_initialized: return - if not os.path.exists(SSH_ROOT_DIR): - os.mkdir(SSH_ROOT_DIR) + os.makedirs(SSH_ROOT_DIR, exist_ok=True) with open(f"{SSH_ROOT_DIR}/known_hosts", "w") as f: for key in WELL_KNOWN_HOST_KEYS + custom_host_keys: key = key.strip() @@ -89,7 +91,12 @@ def __init__(self, git_repo_url: str, git_key: str, git_branch: str = None): else: ssh_key_option = "" - self.env["GIT_SSH_COMMAND"] = f"ssh {ssh_key_option} -o IdentitiesOnly=yes" + # Point ssh at the known_hosts file setup_host_keys() actually writes. Without this, + # ssh reads $HOME/.ssh/known_hosts, which is only the same file when SSH_ROOT_DIR + # happens to sit under $HOME. + self.env["GIT_SSH_COMMAND"] = ( + f"ssh {ssh_key_option} -o IdentitiesOnly=yes -o UserKnownHostsFile={SSH_ROOT_DIR}/known_hosts" + ) self.repo_lock = threading.RLock() self.repo_name = os.path.splitext(os.path.basename(git_repo_url))[0] self.repo_local_path = os.path.join(REPO_LOCAL_BASE_DIR, self.repo_name) diff --git a/tests/test_helm_chart.py b/tests/test_helm_chart.py index 5b65e7873..bc5802fe3 100644 --- a/tests/test_helm_chart.py +++ b/tests/test_helm_chart.py @@ -1,9 +1,12 @@ import os +import re import yaml -CHART_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "helm", "robusta") REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CHART_DIR = os.path.join(REPO_ROOT, "helm", "robusta") +RUNNER_TEMPLATE = os.path.join(CHART_DIR, "templates", "runner.yaml") +DOCKERFILE = os.path.join(REPO_ROOT, "Dockerfile") def _values() -> dict: @@ -11,6 +14,16 @@ def _values() -> dict: return yaml.safe_load(f) +def _runner_template() -> str: + with open(RUNNER_TEMPLATE) as f: + return f.read() + + +def _dockerfile() -> str: + with open(DOCKERFILE) as f: + return f.read() + + def test_runner_pod_hardened_defaults(): runner = _values()["runner"] pod = runner["securityContext"]["pod"] @@ -25,16 +38,63 @@ def test_runner_pod_hardened_defaults(): assert container["capabilities"] == {"drop": ["ALL"]} assert container["allowPrivilegeEscalation"] is False assert container["privileged"] is False - assert container["readOnlyRootFilesystem"] is True # the read-only root filesystem needs the writable emptyDir mounts that hardenedFs adds assert runner["hardenedFs"] is True +def test_readonly_root_fs_is_derived_from_hardened_fs(): + # readOnlyRootFilesystem must NOT be a standalone default: hardenedFs is the single switch, + # so that turning hardenedFs off can never leave a read-only FS without writable mounts. + container = _values()["runner"]["securityContext"]["container"] + assert "readOnlyRootFilesystem" not in container + + template = _runner_template() + assert '"readOnlyRootFilesystem" true' in template + assert ".Values.runner.hardenedFs" in template + + def test_runner_image_runs_as_non_root_user(): - with open(os.path.join(REPO_ROOT, "Dockerfile")) as f: - directives = [line.strip() for line in f if line.strip().startswith("USER ")] + directives = [line.strip() for line in _dockerfile().splitlines() if line.strip().startswith("USER ")] # the last USER directive is what the container actually runs as assert directives, "Dockerfile must set a USER so the runner does not run as root" assert directives[-1] == "USER 1000" + + +def test_hardened_mounts_cover_runtime_write_paths(): + template = _runner_template() + mount_paths = set(re.findall(r"^\s*mountPath:\s*(\S+)\s*$", template, re.MULTILINE)) + + # every directory the runner writes to at runtime must be a writable mount once the root + # filesystem is read-only: /tmp (tempfiles, certs), the git clone dir, the pip/HOME cache, + # ~/.ssh (known_hosts for git@ repos) and site-packages (runtime playbook pip installs) + for path in ("/tmp", "/app/robusta-git", "/home/robusta/.cache", "/home/robusta/.ssh"): + assert path in mount_paths, f"{path} is written at runtime but is not a mount" + assert any(p.endswith("/site-packages") for p in mount_paths) + + +def test_venv_mount_matches_dockerfile_python_version(): + # The site-packages mountPath is version-pinned while the setup-venv initContainer derives + # the version at runtime. If the base image's python is bumped without updating the mount, + # runtime pip installs would silently target the read-only image layer. + final_stage_images = re.findall(r"^FROM\s+python:(\d+\.\d+)", _dockerfile(), re.MULTILINE) + assert final_stage_images, "could not determine the python version from the Dockerfile" + python_version = final_stage_images[-1] + + mount_paths = re.findall(r"^\s*mountPath:\s*(\S*site-packages)\s*$", _runner_template(), re.MULTILINE) + assert mount_paths, "no site-packages mountPath found in the runner template" + for path in mount_paths: + assert f"python{python_version}" in path, ( + f"site-packages mount {path} does not match Dockerfile python {python_version}" + ) + + +def test_home_matches_cache_and_ssh_mounts(): + # pip's cache and ssh's known_hosts are resolved via $HOME by child processes, so the + # Dockerfile's HOME and the chart's mountPaths have to stay in sync. + home = re.search(r"^ENV HOME=(\S+)\s*$", _dockerfile(), re.MULTILINE) + assert home, "Dockerfile must set HOME explicitly for the non-root user" + mount_paths = set(re.findall(r"^\s*mountPath:\s*(\S+)\s*$", _runner_template(), re.MULTILINE)) + assert f"{home.group(1)}/.cache" in mount_paths + assert f"{home.group(1)}/.ssh" in mount_paths From 2c53da12fb171cd20653eafda06c3d2b095254e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 06:43:12 +0000 Subject: [PATCH 3/4] Fix init container copy failing as non-root Verifying the hardened defaults turned up a blocker: the setup-venv init container runs "cp -a ${SRC}/. /venv-writable/", and that form applies the source directory's attributes to the destination directory too. The destination is an emptyDir owned by root with fsGroup 1000, so uid 1000 cannot preserve its timestamps - cp copies every file but exits 1, the init container fails and the pod never starts. As root this always succeeded, so the failure only appears now that the runner is non-root. Copy the entries instead of ".", which never touches the destination directory's own attributes. Verified as uid 1000 against a simulated layout (root-owned destination, setgid, group 1000): the previous form exits 1, the new one exits 0 and preserves dotfiles, symlinks and subdirectories. Regression test asserts the copy never uses the "/." form. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016Xudoye1FXWumuQwq5eaHn --- helm/robusta/templates/runner.yaml | 7 ++++++- tests/test_helm_chart.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/helm/robusta/templates/runner.yaml b/helm/robusta/templates/runner.yaml index df4dc8a8d..d155ed851 100644 --- a/helm/robusta/templates/runner.yaml +++ b/helm/robusta/templates/runner.yaml @@ -77,12 +77,17 @@ spec: {{ else }} image: {{ .Values.image.registry }}/{{ .Values.runner.imageName }} {{- end }} + # Copies the baked site-packages into the emptyDir that shadows it, so runtime playbook + # pip installs have somewhere to write. Copies the entries rather than "cp -a ${SRC}/." + # because that form also tries to preserve timestamps on the destination directory, which + # the non-root user does not own - cp then exits 1 and the whole pod fails to start. command: - sh - -c - > SRC="/venv/lib/python$(python -V | cut -d' ' -f2 | cut -d. -f1,2)/site-packages" && - cp -a "${SRC}/." /venv-writable/ + cd "${SRC}" && + find . -mindepth 1 -maxdepth 1 -exec cp -a -t /venv-writable {} + volumeMounts: - name: venv-lib-volume mountPath: /venv-writable diff --git a/tests/test_helm_chart.py b/tests/test_helm_chart.py index bc5802fe3..efec50f29 100644 --- a/tests/test_helm_chart.py +++ b/tests/test_helm_chart.py @@ -98,3 +98,15 @@ def test_home_matches_cache_and_ssh_mounts(): mount_paths = set(re.findall(r"^\s*mountPath:\s*(\S+)\s*$", _runner_template(), re.MULTILINE)) assert f"{home.group(1)}/.cache" in mount_paths assert f"{home.group(1)}/.ssh" in mount_paths + +def test_venv_init_container_copy_works_as_non_root(): + # "cp -a /. /" also applies the source directory's attributes to the destination + # directory, which the non-root user does not own: cp exits 1, the init container fails and + # the pod never starts. The copy must therefore operate on the entries, not on ".". + template = _runner_template() + init_copy = [line.strip() for line in template.splitlines() if "/venv-writable" in line and "cp" in line] + assert init_copy, "no copy command targeting /venv-writable found" + for line in init_copy: + assert '/." /venv-writable' not in line, ( + f"copy form exits 1 as non-root because it preserves attrs on the destination dir: {line}" + ) From ffb61f858e975b65c488ed3c10c31b9451e8c809 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 08:02:20 +0000 Subject: [PATCH 4/4] Document runner pod security defaults and read-only FS limitation Signed-off-by: Claude --- .../external-playbook-repositories.rst | 9 +++++++++ docs/setup-robusta/privacy-and-security.rst | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/docs/playbook-reference/defining-playbooks/external-playbook-repositories.rst b/docs/playbook-reference/defining-playbooks/external-playbook-repositories.rst index 25638f6b1..e23a4d91b 100644 --- a/docs/playbook-reference/defining-playbooks/external-playbook-repositories.rst +++ b/docs/playbook-reference/defining-playbooks/external-playbook-repositories.rst @@ -165,6 +165,15 @@ install command for the package being installed will be run with `--no-build-iso the `pip docs `_ for details). +Read-Only Filesystem Limitation +********************************* + +The runner runs with a read-only root filesystem by default (see the ``runner.hardenedFs`` Helm value). +Runtime installs into Python's ``site-packages`` still work, but a package that declares +``console_scripts`` entry points cannot be pip-installed at runtime, because the scripts target the +read-only ``/venv/bin``. For such packages, either set ``runner.hardenedFs: false``, or bake the package +into a custom image as described below. + Baking Actions into a Custom Image -------------------------------------- diff --git a/docs/setup-robusta/privacy-and-security.rst b/docs/setup-robusta/privacy-and-security.rst index 710826995..79f26e228 100644 --- a/docs/setup-robusta/privacy-and-security.rst +++ b/docs/setup-robusta/privacy-and-security.rst @@ -18,6 +18,23 @@ Handling Secrets in Robusta's Helm Values ****************************************** Refer to :ref:`Managing Secrets`. +Runner Pod Security +****************************************** + +By default, the Robusta runner pod runs as a non-root user (uid 1000) with all Linux capabilities dropped, +``seccompProfile: RuntimeDefault`` and privilege escalation disabled. + +The runner container's root filesystem is also mounted read-only, with writable ``emptyDir`` volumes only +where the runner needs to write at runtime (``/tmp``, git playbook clones, pip caches and runtime-installed +Python packages). This is controlled by the ``runner.hardenedFs`` Helm value (default ``true``). Setting it +to ``false`` keeps a writable root filesystem while still running as non-root. An explicit +``runner.securityContext.container.readOnlyRootFilesystem`` value always takes precedence over ``hardenedFs``. + +.. note:: + + With the read-only filesystem enabled, external playbook packages that declare ``console_scripts`` + entry points cannot be pip-installed at runtime. Refer to :ref:`Loading External Actions`. + Limiting Robusta's Access in Your Cluster *******************************************