mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
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:
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 }}"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user