fix(roles): keep credentials out of the container exec argv, and lint for it

`container exec -e NAME=VALUE` puts VALUE in the argv of the host-side
process, where ps shows it to every local account for as long as the call runs.
no_log cannot mask a process table. The credentials that travel this way are
live: platform_administrator and every secrets.credentials.* value rotate
between the two passes of a matrix-deploy round.

tests/lint/ansible/shell/container/test_exec_env_passes_no_value.py rejects the
form for credential-bearing values, recognised by either the variable's name or
a credential source in the expression behind it. The second signal is what
keeps it from being a name denylist, which would be no protection on its own: a
secret handed over as -e FOO={{ lookup('users','x').password }} is caught by its
source although its name says nothing. Literal values pass; ports, paths and
model names are not worth moving.

The 24 sites it found now pass the name alone and bind the value in the task's
environment. Value-shaping filters stay, because the container needs the shaped
form; the shell-quoting | quote goes, and that is safe: shlex.quote builds
armour the host shell consumes while parsing argv, so what docker receives is
byte-identical either way, checked with a password of @, space, apostrophe
and $.

Three sites needed more than the pattern. set_postgres_superuser_password.sh
took the password as $2, so it stood in the argv of the script and of three
container exec calls; it now reads POSTGRES_SUPERUSER_PASSWORD, and both callers
follow. svc-db-elasticsearch built an exec prefix with the password inside it for
five tasks, so the binding moved to the two include sites and is inherited.
web-app-taiga had its upsert as a semicolon-chained one-liner inside the command;
it is a file read on stdin now, which took the name and the e-mail out of the
argv too.

What this does not do, stated because the docstring first claimed otherwise:
with ansible.builtin.script the value reaches no argv, but with shell and
command ansible-core still prefixes NAME='value' onto the module wrapper's own
line, from Shell.env_prefix in build_module_command. The window shrinks from the
whole container call, re-executed on every pass of an until: loop, to the
module's runtime. It does not vanish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-12 22:54:33 +02:00
1 parent 0dd8dbbb89
commit ffed838bc7
32 files changed
+313 -61

No files matched your search

@@ -3,6 +3,8 @@
when: run_once_svc_db_elasticsearch is not defined
vars:
application_id: svc-db-elasticsearch
environment:
ELASTIC_PW: "{{ ELASTICSEARCH_PASSWORD }}"
- include_tasks: utils/load/handlers.yml
vars:
@@ -11,3 +13,5 @@
- name: "🎬 Initialize dedicated index namespace"
include_tasks: 01_init.yml
when: database_init | bool
environment:
ELASTIC_PW: "{{ ELASTICSEARCH_PASSWORD }}"
+1 -1
View File
@@ -14,4 +14,4 @@ ELASTICSEARCH_HEAP_MB: "{{ lookup('service_memory_mb', application_id, entity_na
ELASTICSEARCH_RETRIES: 5
ELASTICSEARCH_DELAY: 2
ELASTICSEARCH_API_BASE: "http://{{ ELASTICSEARCH_LOCAL_HOST }}:{{ ELASTICSEARCH_INTERNAL_PORT }}"
ELASTICSEARCH_EXEC_SHELL: "container exec -e ELASTIC_PW={{ ELASTICSEARCH_PASSWORD | quote }} {{ ELASTICSEARCH_CONTAINER_ADDRESS }} sh -lc"
ELASTICSEARCH_EXEC_SHELL: "container exec -e ELASTIC_PW {{ ELASTICSEARCH_CONTAINER_ADDRESS }} sh -lc"
+2 -1
View File
@@ -28,11 +28,12 @@ SLEEP_SECONDS="${SLEEP_SECONDS:-15}"
DB_PASSWORD="$(printf '%s' "${DB_PASSWORD_B64}" | base64 -d 2>/dev/null)"
[ -n "${DB_PASSWORD}" ] || { echo "[FATAL] DB_PASSWORD_B64 missing or undecodable" >&2; exit 2; }
export MYSQL_PWD="${DB_PASSWORD}"
# Local: authenticate inside the container over TCP (127.0.0.1) so the server
# enforces password auth — a genuine credential check.
auth_local() {
container exec -e MYSQL_PWD="${DB_PASSWORD}" "${DB_CONTAINER}" \
container exec -e MYSQL_PWD "${DB_CONTAINER}" \
mariadb -h 127.0.0.1 -u "${DB_USER}" -N -B -e 'SELECT 1' 2>/dev/null |
grep -qx 1
}
@@ -2,7 +2,10 @@
set -euo pipefail
# Usage:
# set_postgres_superuser_password.sh <container_name> <new_password> <role>
# set_postgres_superuser_password.sh <container_name> <role>
# POSTGRES_SUPERUSER_PASSWORD: the password to write, read from the
# environment only, so it reaches neither this script's argv nor that of the
# container exec calls it makes.
#
# The role is the cluster's superuser: "postgres" for the shared engine, and
# whatever POSTGRES_USER a dedicated one was initialised with. Both are reached
@@ -11,14 +14,22 @@ set -euo pipefail
# written into an engine nobody can log into any more.
container_raw="${1:-}"
new_pw="${2:-}"
role="${3:-}"
role="${2:-}"
new_pw="${POSTGRES_SUPERUSER_PASSWORD:-}"
if [[ -z "$container_raw" || -z "$new_pw" || -z "$role" ]]; then
echo "Usage: $0 <container_name> <new_password> <role>" >&2
if [[ -z "$container_raw" || -z "$role" ]]; then
echo "Usage: $0 <container_name> <role>" >&2
exit 2
fi
if [[ -z "$new_pw" ]]; then
echo "[FAIL] POSTGRES_SUPERUSER_PASSWORD is empty; refusing to set an empty password." >&2
exit 1
fi
export PGPASSWORD="$new_pw"
export NEW_POSTGRES_PASSWORD="$new_pw"
container=""
eval "container=$container_raw"
@@ -31,7 +42,7 @@ pg_exec() {
auth_test() {
for _ in {1..15}; do
# shellcheck disable=SC2016
if container exec -e PGPASSWORD="$new_pw" -e PG_ROLE="$role" "$container" bash -lc \
if container exec -e PGPASSWORD -e PG_ROLE="$role" "$container" bash -lc \
'psql -h 127.0.0.1 -p 5432 -U "$PG_ROLE" -d postgres -Atc "SELECT 1" >/dev/null 2>&1'; then
return 0
fi
@@ -81,7 +92,7 @@ for _ in {1..30}; do
if auth_test; then pre_ok=1; fi
# shellcheck disable=SC2016
if ! container exec -e NEW_POSTGRES_PASSWORD="$new_pw" -e PG_ROLE="$role" "$container" bash -lc '
if ! container exec -e NEW_POSTGRES_PASSWORD -e PG_ROLE="$role" "$container" bash -lc '
psql -U "$PG_ROLE" -d postgres -v ON_ERROR_STOP=1 -v role="$PG_ROLE" -v password="$NEW_POSTGRES_PASSWORD" <<'"'"'SQL'"'"'
ALTER USER :"role" WITH PASSWORD :'"'"'password'"'"';
SQL
+2 -1
View File
@@ -29,11 +29,12 @@ SLEEP_SECONDS="${SLEEP_SECONDS:-15}"
DB_PASSWORD="$(printf '%s' "${DB_PASSWORD_B64}" | base64 -d 2>/dev/null)"
[ -n "${DB_PASSWORD}" ] || { echo "[FATAL] DB_PASSWORD_B64 missing or undecodable" >&2; exit 2; }
export PGPASSWORD="${DB_PASSWORD}"
# Local: authenticate inside the container over TCP (127.0.0.1) so pg_hba
# enforces password auth — a genuine credential check, not a peer/trust socket.
auth_local() {
container exec -e PGPASSWORD="${DB_PASSWORD}" "${DB_CONTAINER}" \
container exec -e PGPASSWORD "${DB_CONTAINER}" \
psql -h 127.0.0.1 -U "${DB_USER}" -d "${DB_NAME}" -Atqc 'SELECT 1' 2>/dev/null |
grep -qx 1
}
+3 -1
View File
@@ -42,7 +42,9 @@
- name: "✅ POSTGRES | Ensure postgres superuser password matches inventory"
ansible.builtin.script: > # nocheck: container-address-quote script evals the fragment itself
shell/set_postgres_superuser_password.sh {{ POSTGRES_CONTAINER_ADDRESS | quote }} {{ POSTGRES_PASSWORD | quote }} postgres
shell/set_postgres_superuser_password.sh {{ POSTGRES_CONTAINER_ADDRESS | quote }} postgres
environment:
POSTGRES_SUPERUSER_PASSWORD: "{{ POSTGRES_PASSWORD }}"
register: postgres_pw_result
delegate_to: "{{ STACK_HOST }}"
run_once: true
+3 -1
View File
@@ -25,9 +25,11 @@
- name: ⏳ Wait for Postgres TCP auth
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
ansible.builtin.shell: > # nocheck: container-exec-resolver POSTGRES_LOCAL_CID resolved at delegate_to runtime
container exec -e PGPASSWORD={{ POSTGRES_PASSWORD | quote }} {{ POSTGRES_LOCAL_CID | quote }} sh -lc
container exec -e PGPASSWORD {{ POSTGRES_LOCAL_CID | quote }} sh -lc
'psql -h "{{ POSTGRES_LOCAL_HOST }}" -p "{{ POSTGRES_PORT }}"
-U postgres -d postgres -c "SELECT 1"'
environment:
PGPASSWORD: "{{ POSTGRES_PASSWORD }}"
delegate_to: "{{ POSTGRES_HOST_NODE }}"
register: pg_ready
until: pg_ready.rc == 0
+6 -2
View File
@@ -58,8 +58,10 @@
- name: "📁 Create user: {{ rabbitmq_consumer_username }}"
ansible.builtin.shell: >
container exec -e RMQ_PW={{ rabbitmq_consumer_password | quote }} {{ RABBITMQ_CONTAINER_ADDRESS }} sh -lc
container exec -e RMQ_PW {{ RABBITMQ_CONTAINER_ADDRESS }} sh -lc
'rabbitmqctl -q add_user {{ rabbitmq_consumer_username | quote }} "$RMQ_PW"'
environment:
RMQ_PW: "{{ rabbitmq_consumer_password }}"
register: rmq_add_user
changed_when: rmq_add_user.rc == 0
retries: "{{ RABBITMQ_RETRIES }}"
@@ -70,8 +72,10 @@
- name: "✅ Ensure user password matches inventory: {{ rabbitmq_consumer_username }}"
ansible.builtin.shell: >
container exec -e RMQ_PW={{ rabbitmq_consumer_password | quote }} {{ RABBITMQ_CONTAINER_ADDRESS }} sh -lc
container exec -e RMQ_PW {{ RABBITMQ_CONTAINER_ADDRESS }} sh -lc
'rabbitmqctl -q change_password {{ rabbitmq_consumer_username | quote }} "$RMQ_PW"'
environment:
RMQ_PW: "{{ rabbitmq_consumer_password }}"
register: rmq_change_password
changed_when: false
retries: "{{ RABBITMQ_RETRIES }}"
+3 -1
View File
@@ -47,13 +47,15 @@
- name: "🔧 REDIS | Provision ACL user {{ redis_acl_username }} (auth-isolated)"
ansible.builtin.shell: >
container exec
-e REDIS_ACL_PASSWORD={{ redis_acl_password | quote }}
-e REDIS_ACL_PASSWORD
{{ REDIS_CONTAINER_ADDRESS }} sh -lc
'{{ REDIS_CLI }} ACL SETUSER {{ redis_acl_username }}
on resetkeys resetchannels
">$REDIS_ACL_PASSWORD"
allkeys allchannels
+@all'
environment:
REDIS_ACL_PASSWORD: "{{ redis_acl_password }}"
register: redis_acl_set
until: redis_acl_set.rc == 0
retries: "{{ REDIS_RETRIES }}"
@@ -28,8 +28,9 @@
ansible.builtin.script: > # nocheck: container-address-quote script evals the fragment itself
{{ lookup('path_absolute', 'roles/svc-db-postgres/files/shell/set_postgres_superuser_password.sh') }}
{{ lookup('database', application_id, 'container') | quote }}
{{ lookup('database', application_id, 'password') | quote }}
{{ lookup('database', application_id, 'username') | quote }}
environment:
POSTGRES_SUPERUSER_PASSWORD: "{{ lookup('database', application_id, 'password') }}"
register: _app_db_realign
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
changed_when: _app_db_realign.stdout is search('\\[CHANGED\\]')
@@ -53,13 +53,15 @@
-e LDAP_SERVER={{ CHECKMK_LDAP_SERVER | quote }}
-e LDAP_PORT={{ CHECKMK_LDAP_PORT | quote }}
-e LDAP_BIND_DN={{ CHECKMK_LDAP_BIND_DN | quote }}
-e LDAP_BIND_PASSWORD={{ CHECKMK_LDAP_BIND_PASSWORD | quote }}
-e LDAP_BIND_PASSWORD
-e LDAP_USER_DN={{ CHECKMK_LDAP_USER_DN | quote }}
-e LDAP_GROUP_DN={{ CHECKMK_LDAP_GROUP_DN | quote }}
-e LDAP_UID_ATTR={{ CHECKMK_LDAP_USER_ID_ATTR | quote }}
{{ CHECKMK_LOCAL_CID | quote }}
bash -s
stdin: "{{ lookup('file', 'shell/configure-sso.sh') }}"
environment:
LDAP_BIND_PASSWORD: "{{ CHECKMK_LDAP_BIND_PASSWORD }}"
delegate_to: "{{ CHECKMK_HOST_NODE }}"
when: (CHECKMK_SSO_ENABLED | bool) or (CHECKMK_LDAP_ENABLED | bool)
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
+3 -1
View File
@@ -77,11 +77,13 @@
ansible.builtin.shell: > # nocheck: container-exec-resolver DECIDIM_LOCAL_CID resolved at delegate_to runtime
container exec -i -e RAILS_ENV={{ ENVIRONMENT | quote }}
-e DECIDIM_ADMIN_EMAIL={{ lookup('users', 'platform_administrator').email | quote }}
-e DECIDIM_ADMIN_PASSWORD={{ lookup('users', 'platform_administrator').password | quote }}
-e DECIDIM_ADMIN_PASSWORD
{{ DECIDIM_LOCAL_CID | quote }}
bundle exec rails runner -
args:
stdin: "{{ lookup('file', 'ruby/ensure/admin_user.rb') }}"
environment:
DECIDIM_ADMIN_PASSWORD: "{{ lookup('users', 'platform_administrator').password }}"
register: decidim_admin_result
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
changed_when: "'Admin user ready' in decidim_admin_result.stdout"
@@ -3,11 +3,13 @@
ansible.builtin.shell:
cmd: >-
container exec -i --user root
-e CONFIG_KEY={{ item.key }}
-e CONFIG_VALUE={{ item.value | quote }}
-e CONFIG_FILE={{ ESPOCRM_CONFIG_FILE_PRIVATE | quote }}
-e CONFIG_KEY -e CONFIG_VALUE -e CONFIG_FILE
{{ ESPOCRM_LOCAL_CID | quote }} sh
stdin: "{{ lookup('file', 'shell/patch_config_key.sh') }}"
environment:
CONFIG_KEY: "{{ item.key }}"
CONFIG_VALUE: "{{ item.value }}"
CONFIG_FILE: "{{ ESPOCRM_CONFIG_FILE_PRIVATE }}"
loop:
- { key: host, value: "{{ lookup('database', application_id, 'host') }}" }
- { key: dbname, value: "{{ lookup('database', application_id, 'name') }}" }
@@ -14,8 +14,10 @@
- name: 🩹 Patch Friendica local.config.php (DB password)
shell: >
container exec --user {{ FRIENDICA_USER }}
-e PATCH_PASSWORD={{ lookup('database', application_id, 'password') | sed_escape('/') | quote }}
-e PATCH_PASSWORD
{{ FRIENDICA_LOCAL_CID | quote }}
sh -c "sed -ri -e \"s/('password'\\s*=>\\s*')[^']*(',)/\\1\${PATCH_PASSWORD}\\2/\" {{ FRIENDICA_CONFIG_FILE }}"
environment:
PATCH_PASSWORD: "{{ lookup('database', application_id, 'password') | sed_escape('/') }}"
delegate_to: "{{ FRIENDICA_HOST_NODE }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
+3 -1
View File
@@ -7,11 +7,13 @@
-e J_DBTYPE={{ JOOMLA_DB_CONNECTOR | quote }}
-e J_DBHOST={{ (lookup('database', application_id, 'host') ~ ':' ~ lookup('database', application_id, 'port')) | quote }}
-e J_DBUSER={{ lookup('database', application_id, 'username') | quote }}
-e J_DBPASS={{ lookup('database', application_id, 'password') | quote }}
-e J_DBPASS
-e J_DBNAME={{ lookup('database', application_id, 'name') | quote }}
{{ JOOMLA_LOCAL_CID | quote }} php --
args:
stdin: "{{ lookup('file', role_path ~ '/files/php/configure_db.php') }}"
environment:
J_DBPASS: "{{ lookup('database', application_id, 'password') }}"
register: cfg_patch
changed_when: cfg_patch.stdout == "changed"
failed_when: cfg_patch.rc != 0
@@ -17,10 +17,12 @@
- name: "🔎 Check for Mailu DB domain table"
ansible.builtin.shell: > # nocheck: container-exec-resolver MAILU_DB_LOCAL_CID resolved at delegate_to runtime
container exec
-e MARIADB_PWD={{ lookup('database', application_id, 'password') | quote }}
-e MARIADB_PWD
{{ MAILU_DB_LOCAL_CID | quote }} sh -c
'mariadb -u {{ lookup('database', application_id, 'username') }} -p"$MARIADB_PWD"
-e "SHOW TABLES FROM {{ lookup('database', application_id, 'name') }} LIKE \"domain\";"'
environment:
MARIADB_PWD: "{{ lookup('database', application_id, 'password') }}"
register: mailu_db_schema_check
retries: 30
delay: 5
@@ -14,7 +14,7 @@
- name: "🔑 Point {{ realign_localpart }} at the declared password"
ansible.builtin.shell:
cmd: >-
container exec -i -e PGPASSWORD={{ MATRIX_POSTGRES_PASSWORD | quote }}
container exec -i -e PGPASSWORD
{{ lookup('database', application_id, 'address') }}
psql -v ON_ERROR_STOP=1 -U {{ MATRIX_POSTGRES_USER | quote }}
-d {{ MATRIX_POSTGRES_DATABASE | quote }}
@@ -24,5 +24,7 @@
stdin: >-
{{ lookup('file', 'sql/realign_user_password.sql') }}
executable: /bin/bash
environment:
PGPASSWORD: "{{ MATRIX_POSTGRES_PASSWORD }}"
register: matrix_password_update
changed_when: "'UPDATE 1' in matrix_password_update.stdout"
@@ -7,13 +7,15 @@
container exec -i --user {{ MOODLE_RUNTIME_USER }}
-e MOODLE_CONFIG={{ MOODLE_CONFIG | quote }}
-e AI_PROVIDER_NAME={{ MOODLE_LITELLM_PROVIDER_NAME | quote }}
-e AI_API_KEY={{ MOODLE_LITELLM_API_KEY | quote }}
-e AI_API_KEY
-e AI_CHAT_ENDPOINT={{ MOODLE_LITELLM_CHAT_ENDPOINT | quote }}
-e AI_CHAT_MODEL={{ LITELLM_CHAT_MODEL | quote }}
-e AI_IMAGE_ENDPOINT={{ MOODLE_LITELLM_IMAGE_ENDPOINT | quote }}
{{ MOODLE_CONTAINER_ADDRESS }} php
args:
stdin: "{{ lookup('file', 'php/provision/ai_gateway.php') }}"
environment:
AI_API_KEY: "{{ MOODLE_LITELLM_API_KEY }}"
register: moodle_ai_provider
changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -8,13 +8,15 @@
-e MCP_SERVICE_FUNCTIONS={{ MOODLE_MCP_EXTERNAL_FUNCTIONS | join(',') | quote }} \
-e MCP_ACCOUNT_USERNAME={{ MOODLE_MCP_ACCOUNT_USERNAME | quote }} \
-e MCP_ACCOUNT_EMAIL={{ MOODLE_MCP_ACCOUNT_EMAIL | quote }} \
-e MCP_ACCOUNT_PASSWORD={{ MOODLE_MCP_ACCOUNT_PASSWORD | quote }} \
-e MCP_ACCOUNT_PASSWORD \
-e MCP_ROLE_SHORTNAME={{ MOODLE_MCP_ROLE_SHORTNAME | quote }} \
-e MCP_ROLE_NAME={{ MOODLE_MCP_ROLE_NAME | quote }} \
-e MCP_ROLE_CAPABILITIES={{ MOODLE_MCP_ROLE_CAPABILITIES | join(',') | quote }} \
{{ MOODLE_MCP_LOCAL_CID | quote }} php
args:
stdin: "{{ lookup('file', 'php/provision/mcp.php') }}"
environment:
MCP_ACCOUNT_PASSWORD: "{{ MOODLE_MCP_ACCOUNT_PASSWORD }}"
register: moodle_mcp_provision
changed_when: false
delegate_to: "{{ MOODLE_MCP_HOST_NODE }}"
@@ -33,7 +33,7 @@ if [[ "$current_sha" == "$declared_sha" ]]; then
exit 0
fi
if ! container exec -u "$run_as" -e "OC_PASS=$OC_PASS" "$container" \
if ! container exec -u "$run_as" -e OC_PASS "$container" \
php "$occ" user:resetpassword --password-from-env "$admin_user" >/dev/null; then
echo "[FAIL] occ user:resetpassword failed for '$admin_user'." >&2
exit 1
@@ -22,10 +22,12 @@
container exec -i
-e NEXTCLOUD_HOST={{ lookup('tls', 'web-app-nextcloud', 'url.base') | quote }}
-e STORAGE_NAME={{ nextcloud_op_storage_name | quote }}
-e NC_HAS_OP_SECRET={{ nextcloud_op_client_secret_current.stdout | trim | quote }}
-e NC_HAS_OP_SECRET
{{ NEXTCLOUD_OPENPROJECT_LOCAL_CID | quote }}
bash -lc 'cd /app && bundle exec rails runner -'
stdin: "{{ lookup('file', 'ruby/utils/doorkeeper_allow_onion_redirect.rb') ~ '\n' ~ lookup('file', 'ruby/addons/openproject/ensure_nextcloud_storage.rb') }}"
environment:
NC_HAS_OP_SECRET: "{{ nextcloud_op_client_secret_current.stdout | trim }}"
delegate_to: "{{ NEXTCLOUD_OPENPROJECT_HOST_NODE }}"
register: nextcloud_op_storage_create
changed_when: "'\"oauth_application_created\":true' in nextcloud_op_storage_create.stdout"
@@ -99,11 +101,13 @@
-e NEXTCLOUD_HOST={{ lookup('tls', 'web-app-nextcloud', 'url.base') | quote }}
-e STORAGE_NAME={{ nextcloud_op_storage_name | quote }}
-e NC_OAUTH_CLIENT_ID={{ nextcloud_op_ncclient.clientId | quote }}
-e NC_OAUTH_CLIENT_SECRET={{ nextcloud_op_ncclient.clientSecret | quote }}
-e NC_OAUTH_CLIENT_SECRET
{{ NEXTCLOUD_OPENPROJECT_LOCAL_CID | quote }}
bash -lc 'cd /app && bundle exec rails runner -'
stdin: "{{ lookup('file', 'ruby/utils/doorkeeper_allow_onion_redirect.rb') ~ '\n' ~ lookup('file', 'ruby/addons/openproject/ensure_nextcloud_storage.rb')
}}"
environment:
NC_OAUTH_CLIENT_SECRET: "{{ nextcloud_op_ncclient.clientSecret }}"
delegate_to: "{{ NEXTCLOUD_OPENPROJECT_HOST_NODE }}"
register: nextcloud_op_client_store
changed_when: true
@@ -6,7 +6,7 @@
* Runs INSIDE the Shopware web container (piped via
* `container exec -i ... php < this-file`).
*
* Required env, supplied via `container exec -e KEY=VALUE`:
* Required env, supplied by the calling task via `container exec -e KEY`:
* SHOPWARE_API_BASE base URL of the Shopware API as seen from the container
* ADMIN_USER admin username for the password grant
* ADMIN_PASSWORD admin password for the password grant
@@ -4,7 +4,7 @@
# Runs INSIDE the Shopware web container (piped via
# `container exec -i ... sh < this-file`).
#
# Required env, supplied via `container exec -e KEY=VALUE`:
# Required env, supplied by the calling task via `container exec -e KEY`:
# SHOPWARE_ROOT absolute path to the Shopware install
# ADMIN_USER username of the admin user
# ADMIN_PASSWORD password to set/enforce
+3 -1
View File
@@ -5,13 +5,15 @@
container exec -i --user {{ SHOPWARE_USER }}
-e SHOPWARE_ROOT={{ SHOPWARE_ROOT | quote }}
-e ADMIN_USER={{ lookup('users', 'platform_administrator').username | quote }}
-e ADMIN_PASSWORD={{ lookup('users', 'platform_administrator').password | quote }}
-e ADMIN_PASSWORD
-e ADMIN_FIRST_NAME={{ lookup('users', 'platform_administrator').username | quote }}
-e ADMIN_LAST_NAME={{ DOMAIN_PRIMARY | lower | quote }}
-e ADMIN_EMAIL={{ lookup('users', 'platform_administrator').email | quote }}
{{ SHOPWARE_WEB_LOCAL_CID | quote }}
sh
stdin: "{{ lookup('ansible.builtin.file', role_path ~ '/files/shell/admin/user.sh') }}"
environment:
ADMIN_PASSWORD: "{{ lookup('users', 'platform_administrator').password }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🛡️ Ensure the Shopware admin still holds the admin privilege"
+5 -2
View File
@@ -22,15 +22,18 @@
container exec -i --user {{ SHOPWARE_USER }}
-e SHOPWARE_API_BASE={{ SHOPWARE_API_BASE | quote }}
-e ADMIN_USER={{ lookup('users', 'platform_administrator').username | quote }}
-e ADMIN_PASSWORD={{ lookup('users', 'platform_administrator').password | quote }}
-e ADMIN_PASSWORD
-e OIDC_AUTH_SERVER={{ OIDC.URL | quote }}
-e OIDC_INTERNAL_BASE={{ SHOPWARE_OIDC_INTERNAL_BASE | quote }}
-e OIDC_REALM={{ OIDC.CLIENT.REALM | quote }}
-e OIDC_CLIENT_ID={{ OIDC.CLIENT.ID | quote }}
-e OIDC_CLIENT_SECRET={{ OIDC.CLIENT.SECRET | quote }}
-e OIDC_CLIENT_SECRET
{{ SHOPWARE_WEB_LOCAL_CID | quote }}
php
stdin: "{{ lookup('ansible.builtin.file', role_path ~ '/files/php/oidc/client.php') }}"
environment:
ADMIN_PASSWORD: "{{ lookup('users', 'platform_administrator').password }}"
OIDC_CLIENT_SECRET: "{{ OIDC.CLIENT.SECRET }}"
changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
+3 -1
View File
@@ -25,10 +25,12 @@
- name: ▶️ Run Snipe-IT migrations (idempotent)
ansible.builtin.shell: >-
container exec -i
-e APP_KEY="{{ SNIPE_IT_APP_KEY }}"
-e APP_KEY
-e XDG_CONFIG_HOME=/tmp
{{ SNIPE_IT_CONTAINER_ADDRESS }}
php artisan migrate --force
environment:
APP_KEY: "{{ SNIPE_IT_APP_KEY }}"
register: snipeit_migrate
changed_when: >
('Migrating:' in snipeit_migrate.stdout)
+5 -2
View File
@@ -15,11 +15,11 @@
ansible.builtin.shell:
cmd: >-
container exec -i
-e APP_KEY={{ SNIPE_IT_APP_KEY | quote }}
-e APP_KEY
-e LDAP_SERVER_URI={{ LDAP.SERVER.URI | quote }}
-e LDAP_SERVER_PORT={{ LDAP.SERVER.PORT | quote }}
-e LDAP_BIND_DN={{ LDAP.DN.ADMINISTRATOR.DATA | quote }}
-e LDAP_BIND_PASSWORD_B64={{ LDAP.BIND_CREDENTIAL | b64encode | quote }}
-e LDAP_BIND_PASSWORD_B64
-e LDAP_BASEDN={{ LDAP.DN.OU.USERS | quote }}
-e LDAP_FILTER_USERS_ALL={{ SNIPE_IT_LDAP_FILTER_FRAGMENT | quote }}
-e LDAP_USER_ID_ATTR={{ LDAP.USER.ATTRIBUTES.ID | quote }}
@@ -30,6 +30,9 @@
-e OIDC_RESET_URL={{ OIDC.CLIENT.RESET_CREDENTIALS | quote }}
{{ SNIPE_IT_CONTAINER_ADDRESS }} php
stdin: "{{ lookup('file', 'php/apply/ldap_config.php') }}"
environment:
APP_KEY: "{{ SNIPE_IT_APP_KEY }}"
LDAP_BIND_PASSWORD_B64: "{{ LDAP.BIND_CREDENTIAL | b64encode }}"
changed_when: true
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
+17 -12
View File
@@ -2,7 +2,7 @@
- name: "🔎 Check if Snipe-IT admin user already exists (by username)"
ansible.builtin.shell: |
container exec -i \
-e APP_KEY="{{ SNIPE_IT_APP_KEY }}" \
-e APP_KEY \
{{ SNIPE_IT_CONTAINER_ADDRESS }} \
php -r '
require "vendor/autoload.php";
@@ -13,22 +13,27 @@
->exists();
exit($exists ? 0 : 1);
'
environment:
APP_KEY: "{{ SNIPE_IT_APP_KEY }}"
register: snipe_it_admin_exists
changed_when: false
failed_when: false
- name: "📁 Create Snipe-IT administrator account"
ansible.builtin.shell: >
container exec -i
-e APP_KEY="{{ SNIPE_IT_APP_KEY }}"
{{ SNIPE_IT_CONTAINER_ADDRESS }}
php artisan snipeit:create-admin
--first_name "{{ lookup('users', 'platform_administrator').firstname }}"
--last_name "{{ lookup('users', 'platform_administrator').lastname }}"
--username "{{ lookup('users', 'platform_administrator').username }}"
--email "{{ lookup('users', 'platform_administrator').email }}"
--password {{ lookup('users', 'platform_administrator').password | quote }}
--no-interaction
ansible.builtin.shell: |
container exec -i -e APP_KEY -e SNIPE_IT_REALIGN_PASSWORD \
{{ SNIPE_IT_CONTAINER_ADDRESS }} sh -lc '
php artisan snipeit:create-admin \
--first_name "{{ lookup('users', 'platform_administrator').firstname }}" \
--last_name "{{ lookup('users', 'platform_administrator').lastname }}" \
--username "{{ lookup('users', 'platform_administrator').username }}" \
--email "{{ lookup('users', 'platform_administrator').email }}" \
--password "$SNIPE_IT_REALIGN_PASSWORD" \
--no-interaction
'
environment:
APP_KEY: "{{ SNIPE_IT_APP_KEY }}"
SNIPE_IT_REALIGN_PASSWORD: "{{ lookup('users', 'platform_administrator').password }}"
when: snipe_it_admin_exists.rc != 0
register: snipe_it_admin_create
changed_when: "'New user created' in snipe_it_admin_create.stdout"
@@ -0,0 +1,39 @@
# nocheck: mirrored-unit-test - runs inside `manage.py shell`, against the live
# Django user model and database; there is nothing to exercise outside it
"""Bring the Taiga superuser in line with what the deployment declares.
Read on stdin by ``manage.py shell``, which execs it when stdin is not a tty.
Env (from the container):
TAIGA_SUPERUSER_NAME login to upsert
TAIGA_SUPERUSER_EMAIL address the account must carry
DJANGO_SUPERUSER_PASSWORD password it must accept afterwards
Prints ``CHANGED=1`` when it wrote anything, ``CHANGED=0`` when the account
already matched.
"""
import os
from django.contrib.auth import get_user_model
name = os.environ["TAIGA_SUPERUSER_NAME"]
email = os.environ["TAIGA_SUPERUSER_EMAIL"]
password = os.environ["DJANGO_SUPERUSER_PASSWORD"]
User = get_user_model()
user, created = User.objects.get_or_create(username=name)
before = (user.email, user.is_staff, user.is_superuser, user.is_active)
user.email = email
user.is_staff = True
user.is_superuser = True
user.is_active = True
after = (user.email, user.is_staff, user.is_superuser, user.is_active)
needs_password = not user.check_password(password)
if needs_password:
user.set_password(password)
user.save()
print("CHANGED=1" if created or before != after or needs_password else "CHANGED=0")
+14 -10
View File
@@ -21,11 +21,13 @@
- name: "📁 Create Taiga admin user (idempotent)"
ansible.builtin.shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
container exec -i
-e DJANGO_SUPERUSER_PASSWORD={{ TAIGA_SUPERUSER_PASSWORD | quote }}
-e DJANGO_SUPERUSER_PASSWORD
{{ TAIGA_MANAGER_LOCAL_CID | quote }}
python manage.py createsuperuser --noinput
--username {{ TAIGA_SUPERUSER_NAME }}
--email {{ TAIGA_SUPERUSER_EMAIL }}
environment:
DJANGO_SUPERUSER_PASSWORD: "{{ TAIGA_SUPERUSER_PASSWORD }}"
register: taiga_create_admin
delegate_to: "{{ TAIGA_MANAGER_HOST_NODE }}"
changed_when: taiga_create_admin.rc == 0
@@ -37,15 +39,17 @@
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "📝 Upsert Taiga admin via manage.py shell"
ansible.builtin.shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
container exec -i
-e DJANGO_SUPERUSER_PASSWORD={{ TAIGA_SUPERUSER_PASSWORD | quote }}
{{ TAIGA_MANAGER_LOCAL_CID | quote }}
python manage.py shell -c
"from django.contrib.auth import get_user_model; import os; U=get_user_model(); u,created=U.objects.get_or_create(username='{{ TAIGA_SUPERUSER_NAME }}'); changed=bool(created);
old=(u.email,u.is_staff,u.is_superuser,u.is_active); u.email='{{ TAIGA_SUPERUSER_EMAIL }}'; u.is_staff=True; u.is_superuser=True; u.is_active=True; changed =
changed or old!=(u.email,u.is_staff,u.is_superuser,u.is_active); pwd=os.environ.get('DJANGO_SUPERUSER_PASSWORD'); assert pwd, 'Missing DJANGO_SUPERUSER_PASSWORD';
need_pwd = not u.check_password(pwd); changed = changed or need_pwd; need_pwd and u.set_password(pwd); u.save(); print('CHANGED=1' if changed else 'CHANGED=0')"
ansible.builtin.shell: # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
cmd: >
container exec -i
-e TAIGA_SUPERUSER_NAME -e TAIGA_SUPERUSER_EMAIL -e DJANGO_SUPERUSER_PASSWORD
{{ TAIGA_MANAGER_LOCAL_CID | quote }}
python manage.py shell
stdin: "{{ lookup('file', 'python/upsert_admin.py') }}"
environment:
TAIGA_SUPERUSER_NAME: "{{ TAIGA_SUPERUSER_NAME }}"
TAIGA_SUPERUSER_EMAIL: "{{ TAIGA_SUPERUSER_EMAIL }}"
DJANGO_SUPERUSER_PASSWORD: "{{ TAIGA_SUPERUSER_PASSWORD }}"
register: taiga_upsert_admin
when: taiga_create_admin.rc != 0
delegate_to: "{{ TAIGA_MANAGER_HOST_NODE }}"
@@ -30,11 +30,17 @@
notify: compose-up
- name: 🔄 Update DB password in {{ WORDPRESS_CONFIG_FILE }}
# Exception: the backslash in `\${PATCH_PASSWORD}` is load-bearing. The task
# environment puts the value in the host shell too, so an unescaped `$` would
# expand there and write the password straight back into the container exec
# argv, which is the leak this form exists to avoid.
shell: >
container exec --user root
-e PATCH_PASSWORD={{ lookup('database', application_id, 'password') | sed_escape('/') | quote }}
-e PATCH_PASSWORD
{{ WORDPRESS_CONTAINER_ADDRESS }}
sh -c "sed -i \"s/define(\\s*'DB_PASSWORD'\\s*,\\s*'[^']*'\\s*);/define( 'DB_PASSWORD', '\${PATCH_PASSWORD}' );/i\" {{ WORDPRESS_DOCKER_CONF_PATH }}"
environment:
PATCH_PASSWORD: "{{ lookup('database', application_id, 'password') | sed_escape('/') }}"
notify: compose-up
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -0,0 +1,138 @@
"""Ban ``-e NAME=VALUE`` in role files wherever VALUE is not a literal.
Rationale
=========
``container exec -e NAME=VALUE`` puts VALUE in the argv of the host-side
process, where ``ps`` shows it to every local account for as long as the call
runs. ``no_log`` does not help: it masks Ansible's output, not the process
table. A deployment rotates its credentials between the two passes of a
matrix-deploy round, so the values that travel this way are live secrets.
The safer form is ``-e NAME`` with no value. The engine then forwards the
variable from its own environment, which an Ansible task fills with a
task-level ``environment:`` mapping and a shell script with ``export``.
What that buys, stated exactly, because the reduction is real but partial:
with ``ansible.builtin.script`` the value reaches no argv at all, since the
script reads it from an inherited environment. With ``shell`` and ``command``
it still appears once, on the module wrapper's own command line: ansible-core
assembles ``NAME='value' /usr/bin/python …`` from ``Shell.env_prefix`` in
``build_module_command``. The window shrinks from the whole container call,
re-executed on every pass of an ``until:`` retry loop, to the module's own
runtime, and the value no longer repeats per retry. It does not vanish.
The check is on the flag, not on the command in front of it. Associating an
``-e`` with its ``container exec`` is unreliable across a YAML block scalar,
where the flag sits on its own line with no continuation marker, and it would
buy nothing: ``docker run -e`` and ``podman exec -e`` leak the value exactly
the same way.
A literal value cannot carry a secret, so ``-e RAILS_ENV=production`` and
``-e XDG_CONFIG_HOME=/tmp`` pass. Of the interpolated ones the rule takes
those that carry a credential, by either of two independent signals: the
variable's own name, or a credential source in the expression behind it
(``.password``, ``secrets.credentials.``, a ``'password'`` lookup key, a
``_SECRET``/``_TOKEN``/``_PW`` variable).
The second signal is what keeps this from being a name denylist, which would
be no protection on its own: a secret handed over as ``-e FOO={{
lookup('users', 'x').password }}`` is caught by its source even though its
name says nothing. What still slips through is a secret from a source this
rule does not know, under a name that does not say so. Ports, paths and model
names pass deliberately; they are not worth the churn of moving every
interpolated value in the repository.
Per-line opt-out
================
``# nocheck: container-exec-env-value -- <reason>`` on the same line or the
immediately preceding non-empty line. Reserved for a templated value that
provably carries no secret, such as a service or role name; state which.
"""
from __future__ import annotations
import re
import unittest
from pathlib import Path
from utils.annotations.suppress import is_suppressed_at
from utils.cache.files import iter_project_files_with_content
from . import PROJECT_ROOT
_RULE = "container-exec-env-value"
_EXTENSIONS = (".yml", ".yaml", ".j2", ".sh")
_ENV_WITH_VALUE = re.compile(r"(?:^|\s)-e\s+([A-Za-z_][A-Za-z0-9_]*)=")
_INTERPOLATED = re.compile(r"\{\{|\$|`")
_SECRET_NAME = re.compile(r"PASS|PWD|SECRET|TOKEN|KEY|CRED", re.IGNORECASE)
_SECRET_SOURCE = re.compile(
r"\.password\b"
r"|secrets\.credentials\."
r"|['\"]password['\"]"
r"|\.secret\b"
r"|[A-Za-z0-9_]*(?:PASSWORD|SECRET|TOKEN|_PW|_KEY)\b",
re.IGNORECASE,
)
def _is_scan_target(rel_path: str) -> bool:
return rel_path.startswith("roles/") and rel_path.endswith(_EXTENSIONS)
class TestContainerExecEnvPassesNoValue(unittest.TestCase):
def test_container_exec_env_flags_carry_no_value(self) -> None:
findings: list[tuple[str, int, str]] = []
for path_str, content in iter_project_files_with_content(
extensions=_EXTENSIONS
):
rel = Path(path_str).relative_to(PROJECT_ROOT).as_posix()
if not _is_scan_target(rel):
continue
lines = content.splitlines()
for idx, line in enumerate(lines):
if line.lstrip().startswith("#"):
continue
matches = list(_ENV_WITH_VALUE.finditer(line))
for position, match in enumerate(matches):
following = matches[position + 1 :]
stop = following[0].start() if following else len(line)
name = match.group(1)
value = line[match.end() : stop]
if not _INTERPOLATED.search(value):
continue
if not (
_SECRET_NAME.search(name) or _SECRET_SOURCE.search(value)
):
continue
line_no = idx + 1
if is_suppressed_at(lines, line_no, _RULE, mode="same-or-above"):
continue
findings.append((rel, line_no, f"-e {name}={value.strip()[:60]}"))
if findings:
formatted = "\n".join(
f"- {p}:{ln}: {snip}"
for p, ln, snip in sorted(set(findings), key=lambda x: (x[0], x[1]))
)
self.fail(
f"Found {len(set(findings))} `-e NAME=VALUE` flags carrying an "
"interpolated value. The value lands in the argv of the "
"host-side container call, where `ps` shows it to every local "
"account while the call runs, and `no_log` cannot mask a "
"process table.\n\n"
"Fix one of:\n"
" - pass the name alone, `-e NAME`, and supply the value "
"through the task's `environment:` mapping (Ansible) or an "
"`export NAME=...` in the script (shell). With `script` the "
"value then reaches no argv; with `shell` and `command` it "
"appears once on the module wrapper's line instead of on "
"every container call;\n"
" - for a templated value that provably carries no secret, "
"add `# nocheck: container-exec-env-value -- <reason>` on the "
"same line or the line above, naming what the value is.\n\n"
f"Offending lines:\n{formatted}"
)
if __name__ == "__main__": # pragma: no cover
unittest.main()