Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,76 @@ Changed
Added
~~~~~
* added raw_string type to allow template strings to pass through variable processing (by @guzzijones12@gmail.com) #6351
* Optional unrooting of ``st2actionrunner`` and ``st2workflowengine`` with a configurable security mode
(by @guzzijones12@gmail.com).

**What changed.** The ``st2actionrunner@`` and ``st2workflowengine`` systemd units now run as the
unprivileged ``st2`` user (group ``st2packs``) instead of ``root``. The non-templated
``st2actionrunner.service`` remains a ``root`` ``oneshot`` because it only calls
``systemctl start/stop st2actionrunner@N`` (via ``runners.sh``); it executes no action code. A new
``[system_security]`` config section is added with two options: ``security_mode`` (``legacy`` or
``restricted``, default ``legacy``) and ``allowed_run_as_users`` (default ``stanley,root``).

**Does not break consensus / backward compatible.** The default is ``legacy``, which preserves the
historical behavior: broad ``NOPASSWD: ALL`` sudo for the ``st2`` user (to ``stanley`` and ``root``).
Existing installs and packs continue to work unchanged after upgrade. Unrooting and the tighter
``restricted`` mode are strictly opt-in, so this is not a breaking consensus change -- operators who
want the hardening choose it, everyone else keeps today's behavior.

**Two enforcement layers (they are NOT redundant).**

1. *Application-level pre-check* -- ``local_runner`` reads ``cfg.CONF.system_security`` at runtime on
every action. In ``restricted`` mode it rejects a target user not in ``allowed_run_as_users`` and a
script ``entry_point`` outside ``base_path``. This is enforced inside the ``st2`` process, so it is a
first-line convenience/early-failure guard with helpful errors -- NOT a boundary against a
compromised or buggy runner, and it does not path-restrict arbitrary local commands (``cmd=...``).

2. *OS-level sudoers* -- ``/etc/sudoers.d/st2`` is the real security boundary, enforced by ``sudo``
(setuid-root), and it holds even if the ``st2`` process is compromised. It does two things the app
check cannot: (a) it *grants* the privilege in the first place -- once unrooted, the unprivileged
``st2`` user can only ``sudo`` to ``stanley``/``root`` because this file allows it, so the file is
required for local ``sudo``/run-as-user actions to work at all; and (b) in ``restricted`` mode it is
the hard ceiling -- sudo execution is scoped to commands under ``/opt/stackstorm`` and dangerous
commands (``passwd``, ``su``, ``visudo``, ``sudo``) are denied, so a tricked runner still cannot
``sudo`` arbitrary binaries as root.

**How the sudoers file is generated.** A new ``st2-setup-sudo`` script (packaged into
``/opt/stackstorm/st2/bin``) reads ``security_mode`` from ``st2.conf`` and writes ``/etc/sudoers.d/st2``
accordingly, validating it with ``visudo -c`` (and removing the file on syntax error). It is invoked by
the package post-install as ``root``.

**How to enable restricted mode.**

1. Set ``allowed_run_as_users`` to the exact set of users your actions actually run as. For most
deployments this is just the default system user, ``stanley``. Keep ``root`` in the list only if you
genuinely have actions that run as ``root``; dropping it tightens the surface. Example::

[system_security]
security_mode = restricted
allowed_run_as_users = stanley

2. Ensure any local script actions live under ``/opt/stackstorm`` (packs already install there), since
``restricted`` mode only permits sudo execution of commands under that path. Actions that call
external scripts outside ``/opt/stackstorm`` must be moved into a pack or will be rejected.
3. Regenerate the sudoers file as ``root`` so the OS-level boundary matches the new mode::

sudo /opt/stackstorm/st2/bin/st2-setup-sudo

4. Restart the st2 services (e.g. ``sudo st2ctl restart``) and exercise a representative action. If an
action fails with a "Security violation" error, add the missing user to ``allowed_run_as_users`` or
move the script under ``/opt/stackstorm`` -- do not fall back to ``legacy`` unless you must.

A safe transition path is: upgrade on ``legacy`` (no behavior change), then flip a single node to
``restricted``, validate your packs, and roll it out.

**Containers/Kubernetes.** Because the sudoers file is generated at package post-install (image-build)
time, a container image that wants ``restricted`` mode must be built with an ``st2.conf`` that already
sets ``security_mode = restricted`` (so ``st2-setup-sudo`` writes the scoped sudoers into the image).
* Run ``st2actionrunner`` and ``st2workflowengine`` as the unprivileged ``st2`` user instead of ``root``, and
added an optional ``[system_security] security_mode`` (``legacy``/``restricted``) with ``allowed_run_as_users``.
In ``restricted`` mode the local runner limits execution to scripts under ``base_path`` and to allowed run-as
users. A new ``st2-setup-sudo`` script generates a scoped ``/etc/sudoers.d/st2`` from the configured mode and is
invoked from the package post-install. Defaults to ``legacy`` for backward compatibility. (by @guzzijones12@gmail.com)

3.9.0 - October 10, 2025
------------------------
Expand Down
7 changes: 7 additions & 0 deletions conf/st2.conf.sample
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,13 @@ validate_trigger_parameters = True
# True to validate payload for non-system trigger types when dispatching a trigger inside the sensor. By default, only payload for system triggers is validated.
validate_trigger_payload = True

[system_security]
# List of users that st2 service can run commands as. Only applies in restricted mode.
allowed_run_as_users = stanley,root # comma separated list allowed here.
# Security mode for action execution. legacy: Full sudo access (backward compatible). restricted: Limited to /opt/stackstorm paths only.
# Valid values: legacy, restricted
security_mode = legacy

[system_user]
# SSH private key for the system user.
ssh_key_file = /home/stanley/.ssh/stanley_rsa
Expand Down
40 changes: 40 additions & 0 deletions contrib/runners/local_runner/local_runner/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,46 @@ def pre_run(self):
RUNNER_TIMEOUT, runner_constants.LOCAL_RUNNER_DEFAULT_ACTION_TIMEOUT
)

# Validate security restrictions in restricted mode
self._validate_security_restrictions()

def _validate_security_restrictions(self):
"""
Validate action execution against security mode restrictions.
Only enforced in restricted mode.
"""
security_mode = cfg.CONF.system_security.security_mode

if security_mode != "restricted":
# Legacy mode - no restrictions
return

# Restricted mode validations
allowed_users = cfg.CONF.system_security.allowed_run_as_users

# Check if target user is allowed
if self._user and self._user not in allowed_users:
raise ValueError(
f"Security violation: User '{self._user}' is not in allowed_run_as_users. "
f"Allowed users: {', '.join(allowed_users)}. "
f"Update [system_security] allowed_run_as_users in st2.conf to allow this user."
)

# In restricted mode, validate that entry_point is under /opt/stackstorm
if self.entry_point:
base_path = cfg.CONF.system.base_path
if not self.entry_point.startswith(base_path + "/"):
raise ValueError(
f"Security violation: Script '{self.entry_point}' is outside {base_path}. "
f"In restricted mode, only scripts under {base_path} are allowed. "
f"Switch to legacy mode in st2.conf if you need to run external scripts."
)

LOG.debug(
f"Security validation passed: mode={security_mode}, user={self._user}, "
f"entry_point={self.entry_point}"
)

def _run(self, action):
env_vars = self._env

Expand Down
2 changes: 1 addition & 1 deletion lockfiles/st2.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3085,7 +3085,7 @@
"artifacts": [
{
"algorithm": "sha256",
"hash": "491767e81c1bb11a54fb68d1a24119bdeede593a2beccca5bc09bfed36fdb35c",
"hash": "b9feb1769b48102061fe4fc59b2f5ad600bc2ac0b55cf12ef5fe49464ac0d230",
"url": "git+https://github.com/StackStorm/orquesta.git"
}
],
Expand Down
13 changes: 13 additions & 0 deletions packaging/deb/scripts/post-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,19 @@ case "$1" in
done
extract_st2_pack examples --target /usr/share/doc/st2/examples || :

# Setup sudo configuration based on security mode
if [ -x /opt/stackstorm/st2/bin/st2-setup-sudo ]; then
/opt/stackstorm/st2/bin/st2-setup-sudo || :
fi

# Fix file permissions for st2 user
chown -R st2:st2packs /opt/stackstorm/packs 2>/dev/null || :
chown -R st2:st2packs /opt/stackstorm/virtualenvs 2>/dev/null || :
chown -R st2:st2 /var/log/st2 2>/dev/null || :
chown -R st2:st2 /etc/st2 2>/dev/null || :
chmod 2775 /opt/stackstorm/packs 2>/dev/null || :
chmod 2775 /opt/stackstorm/virtualenvs 2>/dev/null || :

# shellcheck disable=SC2086
systemd_enable_and_restart ${_ST2_SERVICES}
;;
Expand Down
2 changes: 1 addition & 1 deletion packaging/deb/systemd/st2actionrunner@.service
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ JoinsNamespaceOf=st2actionrunner.service

[Service]
Type=simple
User=root
User=st2
Group=st2packs
UMask=002
Environment="DAEMON_ARGS=--config-file /etc/st2/st2.conf"
Expand Down
13 changes: 13 additions & 0 deletions packaging/rpm/scripts/post-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ for pack in ${_ST2_PACKS}; do
done
extract_st2_pack examples --target /usr/share/doc/st2/examples || :

# Setup sudo configuration based on security mode
if [ -x /opt/stackstorm/st2/bin/st2-setup-sudo ]; then
/opt/stackstorm/st2/bin/st2-setup-sudo || :
fi

# Fix file permissions for st2 user
chown -R st2:st2packs /opt/stackstorm/packs 2>/dev/null || :
chown -R st2:st2packs /opt/stackstorm/virtualenvs 2>/dev/null || :
chown -R st2:st2 /var/log/st2 2>/dev/null || :
chown -R st2:st2 /etc/st2 2>/dev/null || :
chmod 2775 /opt/stackstorm/packs 2>/dev/null || :
chmod 2775 /opt/stackstorm/virtualenvs 2>/dev/null || :

# Native .rpm specs use macros that get expanded into shell snippets.
# We are using nfpm, so we inline the macro expansion here.
# %systemd_post
Expand Down
2 changes: 1 addition & 1 deletion packaging/rpm/systemd/st2actionrunner@.service
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ JoinsNamespaceOf=st2actionrunner.service

[Service]
Type=simple
User=root
User=st2
Group=st2packs
UMask=002
Environment="DAEMON_ARGS=--config-file /etc/st2/st2.conf"
Expand Down
1 change: 1 addition & 0 deletions st2common/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ st2_component_python_distribution(
"bin/st2-run-pack-tests:shell",
"bin/st2ctl:shell",
"bin/st2-self-check:shell",
"bin/st2-setup-sudo:shell",
# dev scripts we might want to include
# "bin/st2-generate-schemas",
],
Expand Down
11 changes: 9 additions & 2 deletions st2common/bin/BUILD
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
python_sources(
sources=["*.py", "st2*", "!st2ctl", "!st2-self-check", "!st2-run-pack-tests"],
sources=[
"*.py",
"st2*",
"!st2ctl",
"!st2-self-check",
"!st2-run-pack-tests",
"!st2-setup-sudo",
],
skip_flake8=True,
# skip until resolved: https://github.com/PyCQA/pylint/issues/2095
skip_pylint=True,
Expand All @@ -13,7 +20,7 @@ python_sources(

st2_shell_sources_and_resources(
name="shell",
sources=["st2ctl", "st2-self-check", "st2-run-pack-tests"],
sources=["st2ctl", "st2-self-check", "st2-run-pack-tests", "st2-setup-sudo"],
skip_shellcheck=True,
skip_shfmt=True,
overrides={
Expand Down
110 changes: 110 additions & 0 deletions st2common/bin/st2-setup-sudo
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/bin/bash
# Generates sudo configuration for ST2 based on security mode
# Copyright 2020 The StackStorm Authors.

set -e

ST2_CONF="${ST2_CONF:-/etc/st2/st2.conf}"
SUDOERS_FILE="/etc/sudoers.d/st2"

# Function to read config value from st2.conf
get_config_value() {
local section="$1"
local key="$2"
local default="$3"

# Try to read from config file
if [ -f "$ST2_CONF" ]; then
# Look for the section and key
value=$(awk -v section="$section" -v key="$key" '
/^\[/ { in_section=0 }
$0 == "["section"]" { in_section=1; next }
in_section && $0 ~ "^"key" *= *" {
sub("^"key" *= *", "");
gsub(/^[ \t]+|[ \t]+$/, "");
print;
exit
}
' "$ST2_CONF")

if [ -n "$value" ]; then
echo "$value"
return
fi
fi

# Return default if not found
echo "$default"
}

# Read security mode from config (defaults to legacy)
SECURITY_MODE=$(get_config_value "system_security" "security_mode" "legacy")

echo "Configuring ST2 sudo access in ${SECURITY_MODE} mode..."

case "$SECURITY_MODE" in
restricted)
cat > "$SUDOERS_FILE" << 'EOF'
# ST2 Restricted Security Mode
# ST2 service can only execute commands from /opt/stackstorm
# This provides enhanced security by limiting the scope of sudo access

# Allow execution of pack actions, sensors, and ST2 scripts
Cmnd_Alias ST2_COMMANDS = /opt/stackstorm/packs/*/actions/*, \
/opt/stackstorm/packs/*/sensors/*, \
/opt/stackstorm/st2/bin/*, \
/usr/bin/bash -c /opt/stackstorm/*, \
/bin/bash -c /opt/stackstorm/*

# Allow st2 to run commands as stanley and root users
# Only commands from ST2_COMMANDS alias are allowed
st2 ALL=(stanley) NOPASSWD: ST2_COMMANDS
st2 ALL=(root) NOPASSWD: ST2_COMMANDS

# Explicitly deny dangerous security-related commands
Cmnd_Alias DANGEROUS = /usr/bin/passwd, \
/usr/sbin/visudo, \
/bin/su, \
/usr/bin/sudo

st2 ALL=(ALL) !DANGEROUS
EOF
;;

legacy|*)
cat > "$SUDOERS_FILE" << 'EOF'
# ST2 Legacy Security Mode (backward compatible)
# ST2 service has broad sudo access to maintain compatibility with existing actions
# This is the default mode to ensure smooth upgrades

# Allow st2 to run as stanley and root users with full sudo access
st2 ALL=(stanley,root) NOPASSWD: ALL

# Still explicitly deny changing security settings to prevent privilege escalation
Cmnd_Alias DANGEROUS = /usr/bin/passwd root, \
/usr/sbin/visudo

st2 ALL=(ALL) !DANGEROUS
EOF
;;
esac

# Set correct permissions on sudoers file
chmod 0440 "$SUDOERS_FILE"

# Validate the sudoers file
if command -v visudo >/dev/null 2>&1; then
if visudo -c -f "$SUDOERS_FILE" >/dev/null 2>&1; then
echo "✓ Sudo configuration validated and updated: $SUDOERS_FILE"
else
echo "✗ ERROR: Generated sudoers file has syntax errors!"
echo " Removing invalid file to prevent system issues..."
rm -f "$SUDOERS_FILE"
exit 1
fi
else
echo "⚠ Warning: visudo not found, skipping validation"
echo " Sudo configuration updated: $SUDOERS_FILE"
fi

exit 0
1 change: 1 addition & 0 deletions st2common/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"bin/st2ctl",
"bin/st2-generate-symmetric-crypto-key",
"bin/st2-self-check",
"bin/st2-setup-sudo",
"bin/st2-track-result",
"bin/st2-validate-pack",
"bin/st2-validate-pack-config",
Expand Down
23 changes: 23 additions & 0 deletions st2common/st2common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,29 @@ def register_opts(ignore_errors=False):

do_register_opts(system_user_opts, "system_user", ignore_errors)

system_security_opts = [
cfg.StrOpt(
"security_mode",
default="legacy",
choices=["legacy", "restricted"],
help=(
"Security mode for action execution. "
"legacy: Full sudo access (backward compatible). "
"restricted: Limited to /opt/stackstorm paths only."
),
),
cfg.ListOpt(
"allowed_run_as_users",
default=["stanley", "root"],
help=(
"List of users that st2 service can run commands as. "
"Only applies in restricted mode."
),
),
]

do_register_opts(system_security_opts, "system_security", ignore_errors)

schema_opts = [
cfg.IntOpt("version", default=4, help="Version of JSON schema to use."),
cfg.StrOpt(
Expand Down
Loading