mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
fix(security): require no_log on every task that carries a credential
At -vvv Ansible prints each module argument and result, so a password in a command line, a request body, a header or a fact lands in the deploy log. Nothing enforced masking outside the MCP task files, and no check looked at parents at all. A new lint, test_credential_tasks_are_masked, finds tasks whose printed parts (module arguments, environment, loop) reference an identifier ending in password, secret, token, api_key or private_key, or secrets.credentials. A task passes when it or an enclosing block sets no_log, when every file that pulls it in does so through include_tasks with apply.no_log or import_tasks with no_log (transitively), when the secret only reaches a parameter the module masks itself, or when the module masks its values. no_log on an include_tasks task itself does not count, because it does not reach the included tasks. The rule can be suppressed per task with `# nocheck: credential-masking`, now listed in the suppression catalog, and the lint tests its parent and suppression logic on synthetic cases. It found 39 unmasked tasks in 19 roles, all real: join tokens, client secrets, API tokens in Cloudflare headers, database passwords in command lines, the ChatGPT bot login body and more. Each now sets no_log from MASK_CREDENTIALS_IN_LOGS. The sys-dns-hetzner-rdns alias hetzner_no_log, which only restated that switch, is gone. Not covered yet: template and copy tasks whose rendered file carries a secret print it as a diff under --diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
43e09ebf0d
commit
b3ce43a596
31 files changed
+403
-9
No files matched your search
@@ -59,6 +59,7 @@ The placement rule is per check. The catalog "Position" column uses these labels
|
||||
| `file-size` | head (first 30 lines) | [test_python_file_size.py](../../../../tests/lint/repository/structure/test_python_file_size.py) | Opts the entire `.py` file out of the 500-line cap. |
|
||||
| `comments-valid` | head (first 30 lines) | [test_comments_valid.py](../../../../tests/lint/repository/test_comments_valid.py) | Opts the entire file out of the useful-comment check. Reserved for vendored / upstream config templates (e.g. Discourse `app.yml`) whose inline documentation is all intentional. |
|
||||
| `info-media` | head of `meta/main.yml` (first 30 lines) | [test_info_homepage_video.py](../../../../tests/lint/ansible/roles/meta/test_info_homepage_video.py) | Opts an invokable role out of the required `meta/info.yml` `homepage` + `video` entries. Marker lives in `meta/main.yml` (a comment-only `info.yml` would fail the info schema lint). Reserved for fully self-written roles (backups, host plumbing) that have no upstream product page or demo video. |
|
||||
| `credential-masking` | same or above | [test_credential_tasks_are_masked.py](../../../../tests/lint/ansible/modules/test_credential_tasks_are_masked.py) | Allows the marked task to carry a credential without `no_log` on itself or a parent. Marker goes on or directly above the task's `- name:`. |
|
||||
| `project-walk` | same line / span | [test_no_raw_project_walk.py](../../../../tests/lint/filesystem/python/test_no_raw_project_walk.py) | Allows a raw `Path.rglob`, `os.walk`, or `glob.glob` call outside the `utils.cache.files.iter_project_files` contract. |
|
||||
| `cache-read` | same line / span | [test_no_uncached_file_reads.py](../../../../tests/lint/filesystem/python/test_no_uncached_file_reads.py) | Allows a raw `Path.read_text` / `Path.read_bytes` call in `tests/lint/` outside the LRU-cached `utils.cache.files.read_text` contract. |
|
||||
| `lookup-cache-import` | same or above | [test_no_cache_imports_in_lookups.py](../../../../tests/lint/filesystem/python/test_no_cache_imports_in_lookups.py) | Allows a lookup plugin (`plugins/lookup/` or `roles/*/lookup_plugins/`) to import a config-merge function from `utils.cache.applications` / `.domains` / `.users` instead of reaching the SPOT via `lookup_loader.get('applications'/'config'/'domains'/'users')`. Reserved for the SPOT providers (`applications` / `config` / `applications_current_play` / `users` / `domains`) and the raw-volume accessors (`volume` / `nginx`). |
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
compose_handlers_flush: true
|
||||
|
||||
- name: "🔍 Build consumer key list"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
ansible.builtin.set_fact:
|
||||
LITELLM_CONSUMER_KEYS: >-
|
||||
{%- set out = [] -%}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
- name: ⏳ Wait until postgres TCP auth works
|
||||
block:
|
||||
- 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
|
||||
'psql -h "{{ POSTGRES_LOCAL_HOST }}" -p "{{ POSTGRES_PORT }}"
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
changed_when: false
|
||||
|
||||
- name: "📣 Publish tokens + advertise addr as facts (cluster-wide)"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
ansible.builtin.set_fact:
|
||||
svc_swarm_worker_token: "{{ docker_swarm_worker_token.stdout }}"
|
||||
svc_swarm_manager_token: "{{ docker_swarm_manager_token.stdout }}"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
failed_when: docker_swarm_local_state.rc != 0
|
||||
|
||||
- name: "🐝 Join swarm cluster as manager (only when not already active)"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
ansible.builtin.command: >-
|
||||
container swarm join
|
||||
--token {{ svc_swarm_manager_token }}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
failed_when: docker_swarm_local_state.rc != 0
|
||||
|
||||
- name: "🐝 Join swarm cluster as worker (only when not already active)"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
ansible.builtin.command: >-
|
||||
container swarm join
|
||||
--token {{ svc_swarm_worker_token }}
|
||||
|
||||
@@ -11,5 +11,4 @@ HETZNER_ROBOT_BASE_URL: "https://robot-ws.your-server.de"
|
||||
hetzner_async_enabled: "{{ ASYNC_ENABLED | bool }}"
|
||||
hetzner_async_time: "{{ ASYNC_TIME }}"
|
||||
hetzner_async_poll: "{{ ASYNC_POLL }}"
|
||||
hetzner_no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
rdns_records: []
|
||||
@@ -7,13 +7,13 @@
|
||||
| default(lookup('env','HETZNER_API_TOKEN'), true)
|
||||
| default('', true)
|
||||
}}
|
||||
no_log: "{{ hetzner_no_log | bool }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: ✅ Assert hcloud token present
|
||||
ansible.builtin.assert:
|
||||
that: ["_hz_token | length > 0"]
|
||||
fail_msg: "HETZNER_API_TOKEN is required for the Cloud flavor."
|
||||
no_log: "{{ hetzner_no_log | bool }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
when: MODE_ASSERT | bool
|
||||
|
||||
- name: 📊 Collect hcloud servers if needed (server records without identifier)
|
||||
@@ -21,7 +21,7 @@
|
||||
api_token: "{{ _hz_token }}"
|
||||
register: _servers_info
|
||||
when: rdns_records | selectattr('resource','equalto','server') | selectattr('identifier','undefined') | list | length > 0
|
||||
no_log: "{{ hetzner_no_log | bool }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: 🎬 Init normalized records list
|
||||
set_fact:
|
||||
@@ -61,7 +61,7 @@
|
||||
(_rdns_records | selectattr('resource','equalto','server') | list | length)
|
||||
)
|
||||
fail_msg: "Could not resolve hcloud server by IPv4 for one or more records."
|
||||
no_log: "{{ hetzner_no_log | bool }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
when: MODE_ASSERT | bool
|
||||
|
||||
- name: ✅ Validate records (cloud)
|
||||
@@ -74,7 +74,7 @@
|
||||
- ( (_rdns_records | default(rdns_records)) | selectattr('resource','equalto','server') | selectattr('identifier','defined') | list | length +
|
||||
((_rdns_records | default(rdns_records)) | rejectattr('resource','equalto','server') | list | length) ) == ((_rdns_records | default(rdns_records)) |
|
||||
length)
|
||||
no_log: "{{ hetzner_no_log | bool }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
when: MODE_ASSERT | bool
|
||||
|
||||
- name: 📌 Apply rDNS via hcloud
|
||||
@@ -93,4 +93,4 @@
|
||||
# rationale: async; last task, not delegated, no notify, rDNS is read back by nothing
|
||||
async: "{{ hetzner_async_enabled | ternary(hetzner_async_time, omit) }}"
|
||||
poll: "{{ hetzner_async_enabled | ternary(hetzner_async_poll, omit) }}"
|
||||
no_log: "{{ hetzner_no_log | bool }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
@@ -10,7 +10,7 @@
|
||||
(HETZNER_ROBOT_USER | length) > 0
|
||||
and (HETZNER_ROBOT_PASSWORD | length) > 0
|
||||
}}
|
||||
no_log: "{{ hetzner_no_log | bool }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: "🔑 Note: both Cloud token and Robot creds provided; using Cloud flavor"
|
||||
debug:
|
||||
@@ -25,7 +25,7 @@
|
||||
ansible.builtin.include_tasks:
|
||||
file: flavors/robot.yml
|
||||
apply:
|
||||
no_log: "{{ hetzner_no_log | bool }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
when: (not _use_cloud) and _use_robot
|
||||
|
||||
- name: 🛑 Fail if no credentials provided
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# Safe to run repeatedly; only changes when the mode is not already "on".
|
||||
|
||||
- name: "📖 Read current Cloudflare development_mode setting"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
uri_retry:
|
||||
url: "https://api.cloudflare.com/client/v4/zones/{{ cf_zone_id }}/settings/development_mode"
|
||||
method: GET
|
||||
@@ -16,6 +17,7 @@
|
||||
when: ASYNC_ENABLED | bool
|
||||
|
||||
- name: "🟢 Enable Cloudflare Development Mode"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
uri_retry:
|
||||
url: "https://api.cloudflare.com/client/v4/zones/{{ cf_zone_id }}/settings/development_mode"
|
||||
method: PATCH
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
- name: "🧹 Purge everything from Cloudflare cache for domain {{ domain }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
uri_retry:
|
||||
url: "https://api.cloudflare.com/client/v4/zones/{{ cf_zone_id }}/purge_cache"
|
||||
method: POST
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
mode: "0755"
|
||||
|
||||
- name: 📦 Install plugin credentials file
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
copy:
|
||||
dest: "{{ CERTBOT_CREDENTIALS_FILE }}"
|
||||
content: |
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
changed_when: false
|
||||
|
||||
- name: "✅ Ensure PostgreSQL password matches POSTGRESQL_SECRET"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
ansible.builtin.shell: > # nocheck: container-address-quote compose_mode_force role
|
||||
container exec -i {{ BBB_POSTGRES_CONTAINER_ADDRESS | quote }}
|
||||
psql -U postgres -d postgres
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
changed_when: false
|
||||
|
||||
- name: "📌 apply API-bot user + bypass setup wizard"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
ansible.builtin.shell:
|
||||
cmd: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
|
||||
container exec -i
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
- name: "📦 bench new-site --install-app erpnext '{{ ERPNEXT_SITE_NAME }}'"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
ansible.builtin.shell: |
|
||||
set -euo pipefail
|
||||
# nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
- name: "📌 apply Social Login Key for Keycloak"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
ansible.builtin.shell:
|
||||
cmd: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
|
||||
container exec -i
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
- name: "📌 apply outbound Email Account (Mailu SMTP; inbound IMAP deferred)"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
ansible.builtin.shell:
|
||||
cmd: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
|
||||
container exec -i
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
changed_when: "'migrations completed' in migrate.stdout"
|
||||
|
||||
- name: "📁 Create initial admin user"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
shell: | # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
|
||||
container exec -i --user {{ GITEA_USER }} {{ GITEA_LOCAL_CID | quote }} \
|
||||
/app/gitea/gitea admin user create \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
- name: "➕ Add Keycloak OIDC Provider"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
shell: | # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
|
||||
container exec -i --user {{ GITEA_USER }} {{ GITEA_LOCAL_CID | quote }} \
|
||||
gitea admin auth add-oauth \
|
||||
@@ -32,6 +33,7 @@
|
||||
oidc_source_id: "{{ oidc_source_id_raw.stdout }}"
|
||||
|
||||
- name: "🔄 Update Keycloak OIDC Provider"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
shell: | # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
|
||||
container exec -i --user {{ GITEA_USER }} {{ GITEA_LOCAL_CID | quote }} \
|
||||
gitea admin auth update-oauth \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
- name: "✅ Ensure configuration.php DB settings match inventory"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
shell: >
|
||||
container exec -i
|
||||
-e J_CONFIG_FILE={{ JOOMLA_CONFIG_FILE | quote }}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
include_tasks: "01_database.yml"
|
||||
|
||||
- name: 🔨 Build OIDC settings JSON
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
set_fact:
|
||||
oidc_settings_json: >-
|
||||
{{ {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
- name: "🔑 Log the ChatGPT bridge bot in against Synapse"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
uri_retry:
|
||||
url: "http://127.0.0.1:{{ lookup('config', application_id, 'services.synapse.ports.local.http') }}/_matrix/client/v3/login"
|
||||
method: POST
|
||||
@@ -18,5 +19,6 @@
|
||||
until: matrix_chatgptbot_login.status | default(0) == 200
|
||||
|
||||
- name: "🔧 Publish the issued token to the bridge container env"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
ansible.builtin.set_fact:
|
||||
MATRIX_CHATGPT_ACCESS_TOKEN: "{{ matrix_chatgptbot_login.json.access_token }}"
|
||||
@@ -21,6 +21,7 @@
|
||||
delegate_to: "{{ MATTERMOST_HOST_NODE }}"
|
||||
|
||||
- name: "📁 Mattermost | Create system administrator user"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
ansible.builtin.shell: | # nocheck: container-exec-resolver MATTERMOST_LOCAL_CID resolved at delegate_to runtime; container_address would return a manager-only subshell
|
||||
container exec -i {{ MATTERMOST_LOCAL_CID | quote }} \
|
||||
/mattermost/bin/mmctl --local user create \
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
failed_when: false
|
||||
|
||||
- name: "🔧 Set Moodle OIDC configuration via CLI"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
loop:
|
||||
- { name: "idptype", value: 3 }
|
||||
- { name: "clientauthmethod", value: 1 }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
- name: "📦 Install core modules"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
|
||||
container exec {{ ODOO_LOCAL_CID | quote }}
|
||||
odoo -i {{ odoo_modules_to_install }}
|
||||
@@ -22,6 +23,7 @@
|
||||
when: ODOO_OIDC_ENABLED | bool
|
||||
block:
|
||||
- name: "📦 Install auth_oauth module for OIDC support"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
|
||||
container exec {{ ODOO_LOCAL_CID | quote }}
|
||||
odoo -i auth_oauth
|
||||
@@ -40,6 +42,7 @@
|
||||
or 'invalid module names' in (auth_oauth_install.stdout ~ auth_oauth_install.stderr)
|
||||
|
||||
- name: "📦 Install auth_signup module for OAuth user auto-creation"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
|
||||
container exec {{ ODOO_LOCAL_CID | quote }}
|
||||
odoo -i auth_signup
|
||||
@@ -58,6 +61,7 @@
|
||||
or 'invalid module names' in (auth_signup_install.stdout ~ auth_signup_install.stderr)
|
||||
|
||||
- name: "📦 Install auth_oauth_https module for HTTPS redirect URI fix"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
|
||||
container exec {{ ODOO_LOCAL_CID | quote }}
|
||||
odoo -i auth_oauth_https
|
||||
@@ -76,6 +80,7 @@
|
||||
or 'invalid module names' in (auth_oauth_https_install.stdout ~ auth_oauth_https_install.stderr)
|
||||
|
||||
- name: "📦 Install auth_ldap module for LDAP support"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
|
||||
container exec {{ ODOO_LOCAL_CID | quote }}
|
||||
odoo -i auth_ldap
|
||||
|
||||
@@ -26,5 +26,6 @@
|
||||
register: discourse_generated_api_key
|
||||
|
||||
- name: 💾 Store the new WP Discourse API key in a fact
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
set_fact:
|
||||
vault_discourse_api_key: "{{ discourse_generated_api_key.stdout | trim }}"
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
- block:
|
||||
- name: "🐛 DEBUG | superadmin check + pw hash compare (condensed)"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
script: >-
|
||||
shell/debug_superadmin.sh
|
||||
"{{ _super_check_ext.status }}"
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
changed_when: false
|
||||
|
||||
- name: "🔧 XWIKI | Set REST write headers"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
set_fact:
|
||||
xwiki_rest_headers_write:
|
||||
Content-Type: "application/xml"
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
changed_when: false
|
||||
|
||||
- name: "✅ ensure API-bot user in Zammad (Basic-auth REST API; all variants, no-auth too)"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -euo pipefail
|
||||
@@ -106,6 +107,7 @@
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: "📌 apply OIDC settings in Zammad (post-bootstrap; idempotent)"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
domain_apex: "{{ domain }}"
|
||||
|
||||
- name: "🔍 Cloudflare: Lookup zone id for {{ cf_zone_name }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
uri_retry:
|
||||
url: "https://api.cloudflare.com/client/v4/zones?name={{ cf_zone_name }}"
|
||||
method: GET
|
||||
@@ -50,6 +51,7 @@
|
||||
state: present
|
||||
|
||||
- name: "📋 Cloudflare Rulesets: list dynamic redirect rulesets"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
uri_retry:
|
||||
url: "https://api.cloudflare.com/client/v4/zones/{{ cf_zone_id }}/rulesets?phase=http_request_dynamic_redirect"
|
||||
method: GET
|
||||
@@ -85,6 +87,7 @@
|
||||
preserve_query_string: true
|
||||
|
||||
- name: "🔄 Update existing dynamic redirect ruleset (PUT)"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
when: cf_redirect_ruleset_id | length > 0
|
||||
vars:
|
||||
existing_rules: "{{ cf_redirect_ruleset.rules | default([]) }}"
|
||||
@@ -112,6 +115,7 @@
|
||||
changed_when: cf_ruleset_update.status in [200]
|
||||
|
||||
- name: "📁 Create dynamic redirect ruleset (POST)"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
when: cf_redirect_ruleset_id | length == 0
|
||||
uri_retry:
|
||||
url: "https://api.cloudflare.com/client/v4/zones/{{ cf_zone_id }}/rulesets"
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Lint: a task that carries a credential is masked by itself or by a parent.
|
||||
|
||||
At ``-vvv`` Ansible prints every module argument and every result, so a
|
||||
credential in a command line, a request body, an environment or a fact lands in
|
||||
the deploy log. A task counts as masked when any of these holds:
|
||||
|
||||
* it, or an enclosing ``block``/``rescue``/``always``, sets ``no_log``;
|
||||
* every file that pulls it in does so through ``include_tasks`` with
|
||||
``apply: {no_log: ...}`` or through ``import_tasks`` with ``no_log``, or sits
|
||||
in a masked parent itself (checked transitively);
|
||||
* the credential only reaches a parameter the module masks on its own, or the
|
||||
module masks its values (``mask_values``).
|
||||
|
||||
Suppression (see ``docs/contributing/actions/testing/suppression.md``):
|
||||
|
||||
* ``# nocheck: credential-masking`` on, or directly above, the task's ``- name:``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from utils.annotations.suppress import is_suppressed_at
|
||||
from utils.cache.files import iter_project_files_with_content
|
||||
from utils.cache.yaml import load_yaml_str
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
_RULE = "credential-masking"
|
||||
_JINJA = re.compile(r"\{\{.*?\}\}|\{%.*?%\}", re.DOTALL)
|
||||
_SECRET = re.compile(
|
||||
r"(?i)\b[a-z0-9_]*(?:password|secret|token|api_?key|private_?key)\b(?!\s*\()"
|
||||
r"|secrets\.credentials\."
|
||||
)
|
||||
_TRUTHY = re.compile(r"MASK_CREDENTIALS_IN_LOGS|^\s*(?:true|yes|on)\s*$", re.IGNORECASE)
|
||||
_NAME_LINE = re.compile(r"^\s*-\s+name:\s*(.+?)\s*$")
|
||||
_BLOCK_KEYS = ("block", "rescue", "always")
|
||||
_INCLUDE_KEYS = ("include_tasks", "ansible.builtin.include_tasks")
|
||||
_IMPORT_KEYS = ("import_tasks", "ansible.builtin.import_tasks")
|
||||
_SILENT_KEYS = frozenset(
|
||||
{
|
||||
"include_role",
|
||||
"import_role",
|
||||
"meta",
|
||||
"ansible.builtin.include_role",
|
||||
"ansible.builtin.import_role",
|
||||
"ansible.builtin.meta",
|
||||
}
|
||||
)
|
||||
_TASK_KEYWORDS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"when",
|
||||
"register",
|
||||
"until",
|
||||
"changed_when",
|
||||
"failed_when",
|
||||
"no_log",
|
||||
"tags",
|
||||
"notify",
|
||||
"listen",
|
||||
"delegate_to",
|
||||
"delegate_facts",
|
||||
"run_once",
|
||||
"retries",
|
||||
"delay",
|
||||
"loop_control",
|
||||
"become",
|
||||
"become_user",
|
||||
"become_method",
|
||||
"ignore_errors",
|
||||
"ignore_unreachable",
|
||||
"vars",
|
||||
"timeout",
|
||||
"async",
|
||||
"poll",
|
||||
"check_mode",
|
||||
"diff",
|
||||
"throttle",
|
||||
"any_errors_fatal",
|
||||
"connection",
|
||||
}
|
||||
)
|
||||
_MODULE_MASKED_PARAMS = frozenset(
|
||||
{"url_password", "login_password", "password", "api_token", "account_api_key"}
|
||||
)
|
||||
_PATH_ABSOLUTE = re.compile(r"path_absolute['\"]\s*,\s*['\"]([^'\"]+)['\"]")
|
||||
_ROLE_PATH = re.compile(r"\{\{\s*role_path\s*\}\}/(.+)$")
|
||||
|
||||
|
||||
def _masks(value: object) -> bool:
|
||||
return value is not None and value is not False and bool(_TRUTHY.search(str(value)))
|
||||
|
||||
|
||||
def _has_secret(value: object) -> bool:
|
||||
return any(_SECRET.search(span) for span in _JINJA.findall(str(value)))
|
||||
|
||||
|
||||
def carries_unmasked_secret(task: dict) -> bool:
|
||||
"""Whether a leaf task prints a credential no module masking hides.
|
||||
|
||||
Args:
|
||||
task: one parsed task mapping without block keys.
|
||||
"""
|
||||
for key, value in task.items():
|
||||
if key in _TASK_KEYWORDS:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
if _masks(value.get("mask_values")):
|
||||
return False
|
||||
printed = {k: v for k, v in value.items() if k not in _MODULE_MASKED_PARAMS}
|
||||
if _has_secret(printed):
|
||||
return True
|
||||
elif _has_secret(value):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _include_spec(task: dict) -> tuple[str, object]:
|
||||
for key in _INCLUDE_KEYS + _IMPORT_KEYS:
|
||||
if key in task:
|
||||
return key, task[key]
|
||||
return "", None
|
||||
|
||||
|
||||
def _resolve(including: Path, spec: object, known: set[Path]) -> Path | None:
|
||||
file = spec.get("file") if isinstance(spec, dict) else spec
|
||||
if not isinstance(file, str):
|
||||
return None
|
||||
role_dir = next(
|
||||
(p.parent for p in including.parents if p.name in ("tasks", "handlers")), None
|
||||
)
|
||||
candidates = []
|
||||
absolute = _PATH_ABSOLUTE.search(file)
|
||||
role_relative = _ROLE_PATH.search(file)
|
||||
if absolute:
|
||||
candidates.append(PROJECT_ROOT / absolute.group(1))
|
||||
elif role_relative and role_dir:
|
||||
candidates.append(role_dir / role_relative.group(1))
|
||||
elif "{{" not in file:
|
||||
candidates.append(including.parent / file)
|
||||
if role_dir:
|
||||
candidates.append(role_dir / "tasks" / file)
|
||||
for candidate in candidates:
|
||||
resolved = candidate.resolve()
|
||||
if resolved in known:
|
||||
return resolved
|
||||
return None
|
||||
|
||||
|
||||
def find_leaks(tasks_by_file: dict[Path, list]) -> list[tuple[Path, str]]:
|
||||
"""Return ``(file, task name)`` for every credential task no parent masks.
|
||||
|
||||
Args:
|
||||
tasks_by_file: parsed task lists keyed by resolved file path.
|
||||
"""
|
||||
known = set(tasks_by_file)
|
||||
includers: dict[Path, list[tuple[Path, bool]]] = defaultdict(list)
|
||||
leaks: list[tuple[Path, str]] = []
|
||||
|
||||
def walk(path: Path, tasks: object, inherited: bool) -> None:
|
||||
for task in tasks if isinstance(tasks, list) else ():
|
||||
if not isinstance(task, dict):
|
||||
continue
|
||||
masked = inherited or _masks(task.get("no_log"))
|
||||
if any(key in task for key in _BLOCK_KEYS):
|
||||
for key in _BLOCK_KEYS:
|
||||
walk(path, task.get(key), masked)
|
||||
continue
|
||||
kind, spec = _include_spec(task)
|
||||
if kind:
|
||||
target = _resolve(path, spec, known)
|
||||
if target is not None:
|
||||
apply = spec.get("apply") if isinstance(spec, dict) else None
|
||||
covers = (
|
||||
masked
|
||||
if kind in _IMPORT_KEYS
|
||||
else (
|
||||
inherited
|
||||
or (isinstance(apply, dict) and _masks(apply.get("no_log")))
|
||||
)
|
||||
)
|
||||
includers[target].append((path, covers))
|
||||
continue
|
||||
if _SILENT_KEYS & task.keys():
|
||||
continue
|
||||
if not masked and carries_unmasked_secret(task):
|
||||
leaks.append((path, str(task.get("name", "<unnamed>"))))
|
||||
|
||||
for path, tasks in tasks_by_file.items():
|
||||
walk(path, tasks, False)
|
||||
|
||||
def covered(path: Path, seen: frozenset) -> bool:
|
||||
parents = includers.get(path)
|
||||
if not parents or path in seen:
|
||||
return False
|
||||
return all(
|
||||
covers or covered(source, seen | {path}) for source, covers in parents
|
||||
)
|
||||
|
||||
return [(path, name) for path, name in leaks if not covered(path, frozenset())]
|
||||
|
||||
|
||||
def _task_files() -> dict[Path, tuple[list, list[str]]]:
|
||||
files = {}
|
||||
for path, content in iter_project_files_with_content(extensions=(".yml",)):
|
||||
parts = Path(path).relative_to(PROJECT_ROOT).parts
|
||||
if (
|
||||
len(parts) < 3
|
||||
or parts[0] != "roles"
|
||||
or parts[2] not in ("tasks", "handlers")
|
||||
):
|
||||
continue
|
||||
data = load_yaml_str(content)
|
||||
if isinstance(data, list):
|
||||
files[Path(path).resolve()] = (data, content.splitlines())
|
||||
return files
|
||||
|
||||
|
||||
def _unquote(value: str) -> str:
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def _name_line(lines: list[str], name: str) -> int:
|
||||
for number, line in enumerate(lines, start=1):
|
||||
match = _NAME_LINE.match(line)
|
||||
if match and _unquote(match.group(1)) == name:
|
||||
return number
|
||||
return 1
|
||||
|
||||
|
||||
def is_suppressed(lines: list[str], name: str) -> bool:
|
||||
return is_suppressed_at(lines, _name_line(lines, name), _RULE)
|
||||
|
||||
|
||||
def unmasked_credential_tasks() -> list[str]:
|
||||
files = _task_files()
|
||||
findings = []
|
||||
for path, name in find_leaks({p: data for p, (data, _) in files.items()}):
|
||||
lines = files[path][1]
|
||||
if is_suppressed(lines, name):
|
||||
continue
|
||||
number = _name_line(lines, name)
|
||||
rel = path.relative_to(PROJECT_ROOT.resolve())
|
||||
findings.append(f"{rel}:{number}: {name[:70]}")
|
||||
return findings
|
||||
|
||||
|
||||
class TestCredentialTasksAreMasked(unittest.TestCase):
|
||||
def test_every_credential_task_is_masked_by_itself_or_a_parent(self) -> None:
|
||||
findings = unmasked_credential_tasks()
|
||||
self.assertEqual(
|
||||
[],
|
||||
findings,
|
||||
f"task(s) print a credential without no_log ({len(findings)}); set "
|
||||
'`no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"` on the task or a '
|
||||
"parent:\n" + "\n".join(f" - {f}" for f in findings),
|
||||
)
|
||||
|
||||
def test_the_scan_reaches_role_task_files(self) -> None:
|
||||
self.assertGreater(len(_task_files()), 100)
|
||||
|
||||
|
||||
class TestParentMasking(unittest.TestCase):
|
||||
ROOT = PROJECT_ROOT.resolve() / "roles/x/tasks"
|
||||
SECRET_TASK: ClassVar[dict] = {
|
||||
"name": "leak",
|
||||
"ansible.builtin.shell": "run {{ X_PASSWORD }}",
|
||||
}
|
||||
|
||||
def _leaks(self, main: list, child: list | None = None) -> list[str]:
|
||||
files = {self.ROOT / "main.yml": main}
|
||||
if child is not None:
|
||||
files[self.ROOT / "child.yml"] = child
|
||||
return [name for _, name in find_leaks(files)]
|
||||
|
||||
def test_an_unmasked_credential_task_is_found(self) -> None:
|
||||
self.assertEqual(self._leaks([dict(self.SECRET_TASK)]), ["leak"])
|
||||
|
||||
def test_an_enclosing_block_masks(self) -> None:
|
||||
block = {
|
||||
"no_log": "{{ MASK_CREDENTIALS_IN_LOGS | bool }}",
|
||||
"block": [self.SECRET_TASK],
|
||||
}
|
||||
self.assertEqual(self._leaks([block]), [])
|
||||
|
||||
def test_an_include_apply_masks_the_included_file(self) -> None:
|
||||
include = {
|
||||
"include_tasks": {
|
||||
"file": "child.yml",
|
||||
"apply": {"no_log": "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"},
|
||||
}
|
||||
}
|
||||
self.assertEqual(self._leaks([include], [self.SECRET_TASK]), [])
|
||||
|
||||
def test_an_import_with_no_log_masks_the_imported_file(self) -> None:
|
||||
imported = {
|
||||
"import_tasks": "child.yml",
|
||||
"no_log": "{{ MASK_CREDENTIALS_IN_LOGS | bool }}",
|
||||
}
|
||||
self.assertEqual(self._leaks([imported], [self.SECRET_TASK]), [])
|
||||
|
||||
def test_no_log_on_an_include_itself_does_not_reach_the_included_file(self) -> None:
|
||||
include = {
|
||||
"include_tasks": "child.yml",
|
||||
"no_log": "{{ MASK_CREDENTIALS_IN_LOGS | bool }}",
|
||||
}
|
||||
self.assertEqual(self._leaks([include], [self.SECRET_TASK]), ["leak"])
|
||||
|
||||
def test_a_parameter_the_module_masks_needs_no_no_log(self) -> None:
|
||||
task = {
|
||||
"name": "login",
|
||||
"uri": {"url": "http://x", "url_password": "{{ X_PASSWORD }}"},
|
||||
}
|
||||
self.assertEqual(self._leaks([task]), [])
|
||||
|
||||
def test_a_path_that_only_names_a_secret_is_not_a_credential(self) -> None:
|
||||
task = {
|
||||
"name": "dir",
|
||||
"file": {"path": "{{ DIR_SECRETS }}", "state": "directory"},
|
||||
}
|
||||
self.assertEqual(self._leaks([task]), [])
|
||||
|
||||
|
||||
class TestSuppression(unittest.TestCase):
|
||||
NAME = "📦 bench new-site '{{ SITE }}'"
|
||||
|
||||
def test_a_marker_above_the_name_suppresses_the_task(self) -> None:
|
||||
lines = [
|
||||
"---",
|
||||
"- name: other",
|
||||
" shell: y",
|
||||
"# nocheck: credential-masking",
|
||||
f'- name: "{self.NAME}"',
|
||||
" shell: x",
|
||||
]
|
||||
self.assertTrue(is_suppressed(lines, self.NAME))
|
||||
|
||||
def test_a_marker_above_another_task_does_not_suppress(self) -> None:
|
||||
lines = [
|
||||
"# nocheck: credential-masking",
|
||||
"- name: other",
|
||||
" shell: y",
|
||||
f'- name: "{self.NAME}"',
|
||||
" shell: x",
|
||||
]
|
||||
self.assertFalse(is_suppressed(lines, self.NAME))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user