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:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-11 21:52:18 +02:00
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`). |
+1
View File
@@ -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 = [] -%}
+1
View File
@@ -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 }}
+1
View File
@@ -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 }}"
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 \
+2
View File
@@ -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
View File
@@ -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 \
+1
View File
@@ -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()