diff --git a/roles/svc-db-elasticsearch/tasks/main.yml b/roles/svc-db-elasticsearch/tasks/main.yml index 395e6136a6..9548b6b757 100644 --- a/roles/svc-db-elasticsearch/tasks/main.yml +++ b/roles/svc-db-elasticsearch/tasks/main.yml @@ -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 }}" diff --git a/roles/svc-db-elasticsearch/vars/main.yml b/roles/svc-db-elasticsearch/vars/main.yml index f1fbaaf9c8..86076cdf04 100644 --- a/roles/svc-db-elasticsearch/vars/main.yml +++ b/roles/svc-db-elasticsearch/vars/main.yml @@ -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" diff --git a/roles/svc-db-mariadb/files/test/test.sh b/roles/svc-db-mariadb/files/test/test.sh index 3a450320eb..3142d2e647 100644 --- a/roles/svc-db-mariadb/files/test/test.sh +++ b/roles/svc-db-mariadb/files/test/test.sh @@ -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 } diff --git a/roles/svc-db-postgres/files/shell/set_postgres_superuser_password.sh b/roles/svc-db-postgres/files/shell/set_postgres_superuser_password.sh index 1571cdac4e..6e1093c302 100644 --- a/roles/svc-db-postgres/files/shell/set_postgres_superuser_password.sh +++ b/roles/svc-db-postgres/files/shell/set_postgres_superuser_password.sh @@ -2,7 +2,10 @@ set -euo pipefail # Usage: -# set_postgres_superuser_password.sh +# set_postgres_superuser_password.sh +# 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 " >&2 +if [[ -z "$container_raw" || -z "$role" ]]; then + echo "Usage: $0 " >&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 diff --git a/roles/svc-db-postgres/files/test/test.sh b/roles/svc-db-postgres/files/test/test.sh index 87f706cbf3..416cc534a0 100644 --- a/roles/svc-db-postgres/files/test/test.sh +++ b/roles/svc-db-postgres/files/test/test.sh @@ -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 } diff --git a/roles/svc-db-postgres/tasks/00_core.yml b/roles/svc-db-postgres/tasks/00_core.yml index 8e1c9fa77c..5013604c73 100644 --- a/roles/svc-db-postgres/tasks/00_core.yml +++ b/roles/svc-db-postgres/tasks/00_core.yml @@ -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 diff --git a/roles/svc-db-postgres/tasks/01_init.yml b/roles/svc-db-postgres/tasks/01_init.yml index ad258a13c8..4c02f96af1 100644 --- a/roles/svc-db-postgres/tasks/01_init.yml +++ b/roles/svc-db-postgres/tasks/01_init.yml @@ -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 diff --git a/roles/svc-db-rabbitmq/tasks/01_init.yml b/roles/svc-db-rabbitmq/tasks/01_init.yml index d0fcc2e1a3..49d4a6b15c 100644 --- a/roles/svc-db-rabbitmq/tasks/01_init.yml +++ b/roles/svc-db-rabbitmq/tasks/01_init.yml @@ -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 }}" diff --git a/roles/svc-db-redis/tasks/01_init.yml b/roles/svc-db-redis/tasks/01_init.yml index 97546042fd..025c30f43e 100644 --- a/roles/svc-db-redis/tasks/01_init.yml +++ b/roles/svc-db-redis/tasks/01_init.yml @@ -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 }}" diff --git a/roles/sys-svc-rdbms/tasks/utils/realign_dedicated_db.yml b/roles/sys-svc-rdbms/tasks/utils/realign_dedicated_db.yml index 92eb25ac8e..7ffea172d1 100644 --- a/roles/sys-svc-rdbms/tasks/utils/realign_dedicated_db.yml +++ b/roles/sys-svc-rdbms/tasks/utils/realign_dedicated_db.yml @@ -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\\]') diff --git a/roles/web-app-checkmk/tasks/01_manager_ops.yml b/roles/web-app-checkmk/tasks/01_manager_ops.yml index d660b4aae8..3c7a4410da 100644 --- a/roles/web-app-checkmk/tasks/01_manager_ops.yml +++ b/roles/web-app-checkmk/tasks/01_manager_ops.yml @@ -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 }}" diff --git a/roles/web-app-decidim/tasks/01_setup.yml b/roles/web-app-decidim/tasks/01_setup.yml index 538143cba1..0b4e40ccab 100644 --- a/roles/web-app-decidim/tasks/01_setup.yml +++ b/roles/web-app-decidim/tasks/01_setup.yml @@ -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" diff --git a/roles/web-app-espocrm/tasks/01_patch_config.yml b/roles/web-app-espocrm/tasks/01_patch_config.yml index 3a1b4111fe..970c539189 100644 --- a/roles/web-app-espocrm/tasks/01_patch_config.yml +++ b/roles/web-app-espocrm/tasks/01_patch_config.yml @@ -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') }}" } diff --git a/roles/web-app-friendica/tasks/utils/patch/config.yml b/roles/web-app-friendica/tasks/utils/patch/config.yml index 5f0bc0e29d..ef560b6498 100644 --- a/roles/web-app-friendica/tasks/utils/patch/config.yml +++ b/roles/web-app-friendica/tasks/utils/patch/config.yml @@ -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 }}" diff --git a/roles/web-app-joomla/tasks/05_patch.yml b/roles/web-app-joomla/tasks/05_patch.yml index 858fed6455..9b1505c080 100644 --- a/roles/web-app-joomla/tasks/05_patch.yml +++ b/roles/web-app-joomla/tasks/05_patch.yml @@ -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 diff --git a/roles/web-app-mailu/tasks/03_wait_for_mariadb.yml b/roles/web-app-mailu/tasks/03_wait_for_mariadb.yml index 8d113eb805..5ddf68fbdf 100644 --- a/roles/web-app-mailu/tasks/03_wait_for_mariadb.yml +++ b/roles/web-app-mailu/tasks/03_wait_for_mariadb.yml @@ -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 diff --git a/roles/web-app-matrix/tasks/flavor/compose/utils/realign_synapse_password.yml b/roles/web-app-matrix/tasks/flavor/compose/utils/realign_synapse_password.yml index 40ba1ffbb3..ef7dec73d3 100644 --- a/roles/web-app-matrix/tasks/flavor/compose/utils/realign_synapse_password.yml +++ b/roles/web-app-matrix/tasks/flavor/compose/utils/realign_synapse_password.yml @@ -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" diff --git a/roles/web-app-moodle/tasks/utils/ai_gateway.yml b/roles/web-app-moodle/tasks/utils/ai_gateway.yml index 55832a8079..531408605a 100644 --- a/roles/web-app-moodle/tasks/utils/ai_gateway.yml +++ b/roles/web-app-moodle/tasks/utils/ai_gateway.yml @@ -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 }}" diff --git a/roles/web-app-moodle/tasks/utils/mcp/provision.yml b/roles/web-app-moodle/tasks/utils/mcp/provision.yml index 725661b800..b85ea4b362 100644 --- a/roles/web-app-moodle/tasks/utils/mcp/provision.yml +++ b/roles/web-app-moodle/tasks/utils/mcp/provision.yml @@ -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 }}" diff --git a/roles/web-app-nextcloud/files/shell/realign_admin_password.sh b/roles/web-app-nextcloud/files/shell/realign_admin_password.sh index a929300ef8..21dd4b335c 100644 --- a/roles/web-app-nextcloud/files/shell/realign_admin_password.sh +++ b/roles/web-app-nextcloud/files/shell/realign_admin_password.sh @@ -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 diff --git a/roles/web-app-nextcloud/tasks/addons/integration_openproject_provision.yml b/roles/web-app-nextcloud/tasks/addons/integration_openproject_provision.yml index f572a43585..227828be7d 100644 --- a/roles/web-app-nextcloud/tasks/addons/integration_openproject_provision.yml +++ b/roles/web-app-nextcloud/tasks/addons/integration_openproject_provision.yml @@ -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 diff --git a/roles/web-app-shopware/files/php/oidc/client.php b/roles/web-app-shopware/files/php/oidc/client.php index aa37e32b7e..369bbd737a 100644 --- a/roles/web-app-shopware/files/php/oidc/client.php +++ b/roles/web-app-shopware/files/php/oidc/client.php @@ -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 diff --git a/roles/web-app-shopware/files/shell/admin/user.sh b/roles/web-app-shopware/files/shell/admin/user.sh index efb7d5dc35..ceddea10f3 100644 --- a/roles/web-app-shopware/files/shell/admin/user.sh +++ b/roles/web-app-shopware/files/shell/admin/user.sh @@ -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 diff --git a/roles/web-app-shopware/tasks/01_admin.yml b/roles/web-app-shopware/tasks/01_admin.yml index 1c23380779..c1a5ad972e 100644 --- a/roles/web-app-shopware/tasks/01_admin.yml +++ b/roles/web-app-shopware/tasks/01_admin.yml @@ -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" diff --git a/roles/web-app-shopware/tasks/setup/oidc.yml b/roles/web-app-shopware/tasks/setup/oidc.yml index b42b68bbd4..5e08e83cb4 100644 --- a/roles/web-app-shopware/tasks/setup/oidc.yml +++ b/roles/web-app-shopware/tasks/setup/oidc.yml @@ -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 }}" diff --git a/roles/web-app-snipe-it/tasks/01_install.yml b/roles/web-app-snipe-it/tasks/01_install.yml index f3fdf73e21..062566ef97 100644 --- a/roles/web-app-snipe-it/tasks/01_install.yml +++ b/roles/web-app-snipe-it/tasks/01_install.yml @@ -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) diff --git a/roles/web-app-snipe-it/tasks/02_ldap.yml b/roles/web-app-snipe-it/tasks/02_ldap.yml index ad55ff4b58..5b493989ef 100644 --- a/roles/web-app-snipe-it/tasks/02_ldap.yml +++ b/roles/web-app-snipe-it/tasks/02_ldap.yml @@ -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 }}" diff --git a/roles/web-app-snipe-it/tasks/03_admin.yml b/roles/web-app-snipe-it/tasks/03_admin.yml index 4851929947..1cd2e6fb09 100644 --- a/roles/web-app-snipe-it/tasks/03_admin.yml +++ b/roles/web-app-snipe-it/tasks/03_admin.yml @@ -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" diff --git a/roles/web-app-taiga/files/python/upsert_admin.py b/roles/web-app-taiga/files/python/upsert_admin.py new file mode 100644 index 0000000000..67700fe1b0 --- /dev/null +++ b/roles/web-app-taiga/files/python/upsert_admin.py @@ -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") diff --git a/roles/web-app-taiga/tasks/01_administrator.yml b/roles/web-app-taiga/tasks/01_administrator.yml index b00b8f9d88..c83253aa32 100644 --- a/roles/web-app-taiga/tasks/01_administrator.yml +++ b/roles/web-app-taiga/tasks/01_administrator.yml @@ -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 }}" diff --git a/roles/web-app-wordpress/tasks/01_patch_config.yml b/roles/web-app-wordpress/tasks/01_patch_config.yml index 4fcac0a7df..c4d37c0171 100644 --- a/roles/web-app-wordpress/tasks/01_patch_config.yml +++ b/roles/web-app-wordpress/tasks/01_patch_config.yml @@ -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 }}" diff --git a/tests/lint/ansible/shell/container/test_exec_env_passes_no_value.py b/tests/lint/ansible/shell/container/test_exec_env_passes_no_value.py new file mode 100644 index 0000000000..54491ddd30 --- /dev/null +++ b/tests/lint/ansible/shell/container/test_exec_env_passes_no_value.py @@ -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 -- `` 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 -- ` 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()