fix(roles): keep credentials out of the argv list form too

`container exec` was handed 25 live secrets across 10 roles through an
`argv:` list, where `-e` sits on one entry and `NAME={{ secret }}` on the
next. Each value is an argv element of the host-side process, so `ps` shows
it to every local account while the call runs, and `no_log` masks Ansible's
output rather than the process table. All 25 now travel as `-e NAME` with
the value in the task's `environment:`.

The three container lints added earlier this week all missed this, because
they scan line by line and the two halves of the flag never share a line.
`container-exec-env-value` therefore reads `argv:` lists out of the parsed
YAML as well.

Widening it exposed the name heuristic as too blunt: `_TOKEN_NAME`,
`_API_KEY_NAME` and `_LIFETIME_DAYS` match `TOKEN` and `KEY` while carrying
nothing secret. Nine `nocheck` markers would have papered over that, so the
rule now disqualifies a name whose last segment describes a credential
instead of holding one. A secret reached through such a name is still caught
by its source expression.

Counting by variable name hid one site: discourse carries the OIDC client
secret in three tasks, and deduplicating by name collapsed them to one. The
sweep is verified by the raw occurrence count instead, which is why this is
25 rather than the 24 first reported.

Roles touched: bigbluebutton, checkmk, discourse, gitlab, jellyfin, n8n,
nextcloud, openproject, openwebui, snipe-it.

With `command` and `shell` the value still appears once on the module
wrapper's line, which `Shell.env_prefix` builds, so the exposure shrinks to
a single process rather than vanishing.

feature/designer fixes the same bigbluebutton defect in 90d491b5a7 and goes
further: it drops the base64 encoding, which only ever existed to survive
argv quoting and is pointless once the value skips the shell, and it repairs
an administrator created unverified. That side wins at the merge, except for
its `lookup('users', 'administrator')`, which this branch forbids outside
user-administrator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-13 01:47:23 +02:00
1 parent 6c46868e9d
commit 463053e061
15 files changed
+162 -30

No files matched your search

@@ -26,12 +26,14 @@
- -e
- "NEW_EMAIL={{ lookup('users', 'platform_administrator').email }}"
- -e
- "NEW_PASSWORD_B64={{ lookup('users', 'platform_administrator').password | b64encode }}"
- NEW_PASSWORD_B64
- "{{ BBB_GREENLIGHT_CONTAINER_ADDRESS | quote }}" # nocheck: container-address-quote, container-address-needs-shell compose_mode_force role, so the lookup can only yield a bare name
- bash
- -lc
- "bundle exec rails runner -"
stdin: "{{ lookup('ansible.builtin.file', role_path ~ '/files/ruby/greenlight_ensure_admin.rb') }}"
environment:
NEW_PASSWORD_B64: "{{ lookup('users', 'platform_administrator').password | b64encode }}"
register: greenlight_admin_map
changed_when: "'CHANGED' in greenlight_admin_map.stdout"
failed_when: greenlight_admin_map.rc != 0
+5 -2
View File
@@ -23,17 +23,20 @@
- -e
- "CHECKMK_ADMIN_USER=cmkadmin"
- -e
- "CHECKMK_ADMIN_PASSWORD={{ CHECKMK_ADMIN_PASSWORD }}"
- CHECKMK_ADMIN_PASSWORD
- -e
- "CHECKMK_SITE={{ CHECKMK_SITE }}"
- -e
- "MCP_USER={{ CHECKMK_MCP_UPSTREAM_USER }}"
- -e
- "MCP_SECRET={{ CHECKMK_MCP_UPSTREAM_KEY }}"
- MCP_SECRET
- "{{ CHECKMK_IDENTITY_LOCAL_CID }}" # nocheck: container-exec-resolver
- python3
- "-"
stdin: "{{ lookup('file', 'python/provision_mcp_identity.py') }}"
environment:
CHECKMK_ADMIN_PASSWORD: "{{ CHECKMK_ADMIN_PASSWORD }}"
MCP_SECRET: "{{ CHECKMK_MCP_UPSTREAM_KEY }}"
register: checkmk_mcp_identity
retries: 30
delay: 10
@@ -20,13 +20,15 @@
- -e
- "DISCOURSE_OIDC_CLIENT_ID={{ OIDC.CLIENT.ID if DISCOURSE_OIDC_ENABLED | bool else '' }}"
- -e
- "DISCOURSE_OIDC_CLIENT_SECRET={{ OIDC.CLIENT.SECRET if DISCOURSE_OIDC_ENABLED | bool else '' }}"
- DISCOURSE_OIDC_CLIENT_SECRET
- -e
- "DISCOURSE_OIDC_LOGOUT_REDIRECT={{ DISCOURSE_OIDC_LOGOUT_REDIRECT_URL if DISCOURSE_OIDC_ENABLED | bool else '' }}"
- "{{ DISCOURSE_CONTAINER }}" # nocheck: container-exec-resolver the launcher names the container itself, so the bare name resolves in both modes
- rails
- r
- "{{ lookup('file', 'ruby/apply_settings.rb') }}"
environment:
DISCOURSE_OIDC_CLIENT_SECRET: "{{ OIDC.CLIENT.SECRET if DISCOURSE_OIDC_ENABLED | bool else '' }}"
changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -41,11 +43,13 @@
- -e
- "DISCOURSE_ADMIN_USERNAME={{ lookup('users', 'platform_administrator').username }}"
- -e
- "DISCOURSE_ADMIN_PASSWORD={{ lookup('users', 'platform_administrator').password }}"
- DISCOURSE_ADMIN_PASSWORD
- "{{ DISCOURSE_CONTAINER }}" # nocheck: container-exec-resolver the launcher names the container itself, so the bare name resolves in both modes
- rails
- r
- "{{ lookup('file', 'ruby/ensure_admin_user.rb') }}"
environment:
DISCOURSE_ADMIN_PASSWORD: "{{ lookup('users', 'platform_administrator').password }}"
changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -58,15 +62,18 @@
- -e
- "DISCOURSE_ADMIN_EMAIL={{ lookup('users', 'platform_administrator').email }}"
- -e
- "DISCOURSE_ADMIN_PASSWORD={{ lookup('users', 'platform_administrator').password }}"
- DISCOURSE_ADMIN_PASSWORD
- -e
- "DISCOURSE_OIDC_ENABLED={{ DISCOURSE_OIDC_ENABLED | bool | string | lower }}"
- -e
- "DISCOURSE_OIDC_CLIENT_SECRET={{ OIDC.CLIENT.SECRET if DISCOURSE_OIDC_ENABLED | bool else '' }}"
- DISCOURSE_OIDC_CLIENT_SECRET
- "{{ DISCOURSE_CONTAINER }}" # nocheck: container-exec-resolver the launcher names the container itself, so the bare name resolves in both modes
- rails
- r
- "{{ lookup('file', 'ruby/verify_settings.rb') }}"
environment:
DISCOURSE_ADMIN_PASSWORD: "{{ lookup('users', 'platform_administrator').password }}"
DISCOURSE_OIDC_CLIENT_SECRET: "{{ OIDC.CLIENT.SECRET if DISCOURSE_OIDC_ENABLED | bool else '' }}"
register: discourse_settings_readback
changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -14,11 +14,13 @@
- -e
- "GITLAB_MCP_PROTOCOL_VERSION={{ GITLAB_MCP_PROTOCOL_VERSION }}"
- -e
- "GITLAB_MCP_TOKEN={{ gitlab_mcp_token }}"
- GITLAB_MCP_TOKEN
- "{{ GITLAB_MCP_LOCAL_CID }}" # nocheck: container-exec-resolver
- ruby
- "-"
stdin: "{{ lookup('file', 'ruby/probe_mcp_token.rb') }}"
environment:
GITLAB_MCP_TOKEN: "{{ gitlab_mcp_token }}"
register: gitlab_mcp_probe
retries: 30
delay: 10
@@ -14,7 +14,7 @@
- -e
- "GITLAB_MCP_TOKEN_OWNER_EMAIL={{ GITLAB_MCP_TOKEN_OWNER_EMAIL }}"
- -e
- "GITLAB_MCP_TOKEN_OWNER_PASSWORD={{ GITLAB_MCP_TOKEN_OWNER_PASSWORD }}"
- GITLAB_MCP_TOKEN_OWNER_PASSWORD
- -e
- "GITLAB_MCP_TOKEN_LIFETIME_DAYS={{ GITLAB_MCP_TOKEN_LIFETIME_DAYS }}"
- "{{ GITLAB_MCP_LOCAL_CID }}" # nocheck: container-exec-resolver
@@ -22,6 +22,8 @@
- runner
- "-"
stdin: "{{ lookup('file', 'ruby/provision_mcp_token.rb') }}"
environment:
GITLAB_MCP_TOKEN_OWNER_PASSWORD: "{{ GITLAB_MCP_TOKEN_OWNER_PASSWORD }}"
register: gitlab_mcp_mint
retries: 30
delay: 10
+7 -3
View File
@@ -28,19 +28,23 @@
- -e
- "JELLYFIN_ADMIN_USER={{ JELLYFIN_ADMIN_USERNAME }}"
- -e
- "JELLYFIN_ADMIN_PASSWORD={{ JELLYFIN_ADMIN_PASSWORD }}"
- JELLYFIN_ADMIN_PASSWORD
- -e
- "MCP_USER={{ JELLYFIN_MCP_UPSTREAM_USER }}"
- -e
- "MCP_PASSWORD={{ JELLYFIN_MCP_UPSTREAM_PASSWORD }}"
- MCP_PASSWORD
- -e
- "MCP_DEVICE_ID={{ JELLYFIN_MCP_UPSTREAM_DEVICE_ID }}"
- -e
- "MCP_CURRENT_TOKEN={{ MCP_UPSTREAM_CURRENT_KEY }}"
- MCP_CURRENT_TOKEN
- "{{ JELLYFIN_IDENTITY_LOCAL_CID }}" # nocheck: container-exec-resolver
- python3
- "-"
stdin: "{{ lookup('file', 'python/provision_mcp_identity.py') }}"
environment:
JELLYFIN_ADMIN_PASSWORD: "{{ JELLYFIN_ADMIN_PASSWORD }}"
MCP_PASSWORD: "{{ JELLYFIN_MCP_UPSTREAM_PASSWORD }}"
MCP_CURRENT_TOKEN: "{{ MCP_UPSTREAM_CURRENT_KEY }}"
register: jellyfin_mcp_identity
retries: 30
delay: 10
+3 -1
View File
@@ -79,11 +79,13 @@
- -e
- "N8N_OWNER_EMAIL={{ N8N_OWNER_EMAIL }}"
- -e
- "N8N_OWNER_PASSWORD={{ N8N_OWNER_PASSWORD }}"
- N8N_OWNER_PASSWORD
- "{{ N8N_LOCAL_CID }}" # nocheck: container-exec-resolver
- node
- "-"
stdin: "{{ lookup('file', 'javascript/reconcile/owner_password.js') }}"
environment:
N8N_OWNER_PASSWORD: "{{ N8N_OWNER_PASSWORD }}"
register: n8n_owner_password
retries: 30
delay: 10
@@ -15,7 +15,7 @@
- -e
- "NC_CLIENT_NAME={{ nextcloud_crm_client_name }}"
- -e
- "NC_CLIENT_SECRET={{ nextcloud_crm_client_secret }}"
- NC_CLIENT_SECRET
- "{{ nextcloud_crm_container }}" # nocheck: container-exec-resolver runs only behind the bare-name partner probe gate, compose co-location
- php
- -d
@@ -23,6 +23,8 @@
- -
chdir: "{{ nextcloud_crm_instance_dir }}"
stdin: "{{ lookup('file', 'php/addons/suitecrm/provision_oauth_password_client.php') }}"
environment:
NC_CLIENT_SECRET: "{{ nextcloud_crm_client_secret }}"
register: nextcloud_crm_provision
changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -24,7 +24,7 @@
- -u
- "{{ NEXTCLOUD_DOCKER_USER }}"
- -e
- "OC_PASS={{ NEXTCLOUD_MCP_USER_PASSWORD }}"
- OC_PASS
- "{{ NEXTCLOUD_LOCAL_CID }}" # nocheck: container-exec-resolver
- php
- "{{ NEXTCLOUD_INTERNAL_OCC_COMMAND }}"
@@ -32,6 +32,8 @@
- --password-from-env
- "--display-name={{ NEXTCLOUD_MCP_USER_DISPLAY_NAME }}"
- "{{ NEXTCLOUD_MCP_USERNAME }}"
environment:
OC_PASS: "{{ NEXTCLOUD_MCP_USER_PASSWORD }}"
when: NEXTCLOUD_MCP_USERNAME not in (nextcloud_mcp_accounts.stdout | from_json)
changed_when: true
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -46,13 +48,15 @@
- -u
- "{{ NEXTCLOUD_DOCKER_USER }}"
- -e
- "OC_PASS={{ NEXTCLOUD_MCP_USER_PASSWORD }}"
- OC_PASS
- "{{ NEXTCLOUD_LOCAL_CID }}" # nocheck: container-exec-resolver
- php
- "{{ NEXTCLOUD_INTERNAL_OCC_COMMAND }}"
- user:resetpassword
- --password-from-env
- "{{ NEXTCLOUD_MCP_USERNAME }}"
environment:
OC_PASS: "{{ NEXTCLOUD_MCP_USER_PASSWORD }}"
changed_when: true
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
delegate_to: "{{ NEXTCLOUD_HOST_NODE }}"
@@ -107,7 +111,7 @@
- -u
- "{{ NEXTCLOUD_DOCKER_USER }}"
- -e
- "NC_PASS={{ NEXTCLOUD_MCP_USER_PASSWORD }}"
- NC_PASS
- "{{ NEXTCLOUD_LOCAL_CID }}" # nocheck: container-exec-resolver
- php
- "{{ NEXTCLOUD_INTERNAL_OCC_COMMAND }}"
@@ -115,6 +119,8 @@
- "{{ NEXTCLOUD_MCP_USERNAME }}"
- --password-from-env
- "--name={{ NEXTCLOUD_MCP_TOKEN_NAME }}"
environment:
NC_PASS: "{{ NEXTCLOUD_MCP_USER_PASSWORD }}"
register: nextcloud_mcp_token_mint
changed_when: true
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
+3 -1
View File
@@ -13,7 +13,7 @@
- exec
- -i
- -e
- "OPENPROJECT_ADMIN_PASSWORD={{ OPENPROJECT_ADMINISTRATOR_PASSWORD }}"
- OPENPROJECT_ADMIN_PASSWORD
- -e
- "OPENPROJECT_ADMIN_LOGIN={{ OPENPROJECT_ADMINISTRATOR_USERNAME }}"
- -e
@@ -23,6 +23,8 @@
- -lc
- "cd /app && bundle exec rails runner -"
stdin: "{{ lookup('file', 'ruby/ensure_admin_user.rb') }}"
environment:
OPENPROJECT_ADMIN_PASSWORD: "{{ OPENPROJECT_ADMINISTRATOR_PASSWORD }}"
delegate_to: "{{ OPENPROJECT_WEB_HOST_NODE }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -10,18 +10,22 @@
- -e
- "OPENPROJECT_GITLAB_BOT_MAIL={{ OPENPROJECT_GITLAB_BOT.email }}"
- -e
- "OPENPROJECT_GITLAB_BOT_PASSWORD={{ OPENPROJECT_GITLAB_BOT.password }}"
- OPENPROJECT_GITLAB_BOT_PASSWORD
- -e
- "OPENPROJECT_GITLAB_TOKEN_NAME={{ openproject_addon.value.config.token_name }}"
- -e
- "OPENPROJECT_GITLAB_WEBHOOK_SECRET={{ OPENPROJECT_GITLAB_WEBHOOK_SECRET }}"
- OPENPROJECT_GITLAB_WEBHOOK_SECRET
- -e
- "OPENPROJECT_GITLAB_API_TOKEN={{ OPENPROJECT_GITLAB_TOKEN_HELD }}"
- OPENPROJECT_GITLAB_API_TOKEN
- "{{ OPENPROJECT_WEB_LOCAL_CID }}" # nocheck: container-exec-resolver CID resolved at delegate_to runtime; container_address targets a manager-only subshell
- bash
- -lc
- "cd /app && bundle exec rails runner -"
stdin: "{{ lookup('file', 'ruby/addons/gitlab/ensure_gitlab_webhook.rb') }}"
environment:
OPENPROJECT_GITLAB_BOT_PASSWORD: "{{ OPENPROJECT_GITLAB_BOT.password }}"
OPENPROJECT_GITLAB_WEBHOOK_SECRET: "{{ OPENPROJECT_GITLAB_WEBHOOK_SECRET }}"
OPENPROJECT_GITLAB_API_TOKEN: "{{ OPENPROJECT_GITLAB_TOKEN_HELD }}"
delegate_to: "{{ OPENPROJECT_WEB_HOST_NODE }}"
register: openproject_gitlab_provision
changed_when: "'CHANGED' in openproject_gitlab_provision.stdout"
@@ -19,17 +19,20 @@
- -e
- "OPENWEBUI_ADMIN_NAME={{ OPENWEBUI_API_BOT_USER.username }}"
- -e
- "OPENWEBUI_ADMIN_PASSWORD={{ OPENWEBUI_API_BOT_USER.password }}"
- OPENWEBUI_ADMIN_PASSWORD
- -e
- "OPENWEBUI_GATEWAY_URL={{ LITELLM_BASE_LOCAL_URL if (OPENWEBUI_LITELLM_ENABLED | bool) else '' }}"
- -e
- "OPENWEBUI_GATEWAY_KEY={{ OPENWEBUI_LITELLM_API_KEY if (OPENWEBUI_LITELLM_ENABLED | bool) else '' }}"
- OPENWEBUI_GATEWAY_KEY
- -e
- "OPENWEBUI_AGENT_MODELS={{ OPENWEBUI_AGENT_MODELS | to_json }}"
- "{{ OPENWEBUI_LOCAL_CID }}" # nocheck: container-exec-resolver
- python3
- "-"
stdin: "{{ lookup('file', 'python/agent_model_grants.py') }}"
environment:
OPENWEBUI_ADMIN_PASSWORD: "{{ OPENWEBUI_API_BOT_USER.password }}"
OPENWEBUI_GATEWAY_KEY: "{{ OPENWEBUI_LITELLM_API_KEY if (OPENWEBUI_LITELLM_ENABLED | bool) else '' }}"
register: openwebui_model_grants
retries: 30
delay: 10
@@ -12,7 +12,7 @@
- -e
- "OPENWEBUI_ADMIN_NAME={{ OPENWEBUI_API_BOT_USER.username }}"
- -e
- "OPENWEBUI_ADMIN_PASSWORD={{ OPENWEBUI_API_BOT_USER.password }}"
- OPENWEBUI_ADMIN_PASSWORD
- -e
- "OPENWEBUI_MCP_GROUPS={{ openwebui_mcp_groups | to_json }}"
- -e
@@ -23,6 +23,8 @@
- python3
- "-"
stdin: "{{ lookup('file', 'python/provision_mcp_grants.py') }}"
environment:
OPENWEBUI_ADMIN_PASSWORD: "{{ OPENWEBUI_API_BOT_USER.password }}"
register: openwebui_mcp_grants
retries: 30
delay: 10
@@ -29,18 +29,22 @@
- -u
- apache
- -e
- "APP_KEY={{ SNIPE_IT_APP_KEY }}"
- APP_KEY
- -e
- "MCP_USER={{ SNIPEIT_MCP_UPSTREAM_USER }}"
- -e
- "MCP_PASSWORD={{ SNIPEIT_MCP_UPSTREAM_PASSWORD }}"
- MCP_PASSWORD
- -e
- "MCP_TOKEN_NAME={{ SNIPEIT_MCP_UPSTREAM_TOKEN_NAME }}"
- -e
- "MCP_CURRENT_TOKEN={{ MCP_UPSTREAM_CURRENT_KEY }}"
- MCP_CURRENT_TOKEN
- "{{ SNIPEIT_IDENTITY_LOCAL_CID }}" # nocheck: container-exec-resolver
- php
stdin: "{{ lookup('file', 'php/provision_mcp_identity.php') }}"
environment:
APP_KEY: "{{ SNIPE_IT_APP_KEY }}"
MCP_PASSWORD: "{{ SNIPEIT_MCP_UPSTREAM_PASSWORD }}"
MCP_CURRENT_TOKEN: "{{ MCP_UPSTREAM_CURRENT_KEY }}"
register: snipeit_mcp_identity
retries: 30
delay: 10
@@ -21,6 +21,10 @@ The check is on the flag, not on the command before it: associating an ``-e``
with its ``container exec`` is unreliable across a YAML block scalar, and
``docker run -e`` leaks the same way.
An ``argv:`` list spells the same thing across two entries, ``-e`` on one and
``NAME={{ … }}`` on the next, so a line-oriented scan never sees them
together. Those lists are therefore read from the parsed YAML as well.
A literal value cannot carry a secret, so ``-e RAILS_ENV=production`` passes.
Of the interpolated ones the rule takes those carrying a credential, by the
variable's name **or** by a credential source in the expression behind it.
@@ -44,6 +48,7 @@ from pathlib import Path
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_any
from . import PROJECT_ROOT
@@ -62,10 +67,62 @@ _SECRET_SOURCE = re.compile(
)
_NOT_A_SECRET_NAME = re.compile(
r"(?:_NAME|_EMAIL|_USER|_USERNAME|_OWNER|_ID|_URL|_HOST|_PORT|_PATH|_DAYS)$",
re.IGNORECASE,
)
_ARGV_ASSIGNMENT = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$", re.DOTALL)
_MODULE_KEYS = (
"command",
"ansible.builtin.command",
"shell",
"ansible.builtin.shell",
)
def _is_scan_target(rel_path: str) -> bool:
return rel_path.startswith("roles/") and rel_path.endswith(_EXTENSIONS)
def _carries_credential(name: str, value: str) -> bool:
"""Decide whether an ``NAME=VALUE`` pair hands over a secret.
Args:
name: the variable name to the left of the ``=``.
value: everything to its right, Jinja included.
Returns:
True when the name reads like a credential or the expression draws
from a credential source. A name whose last segment is ``_NAME``,
``_OWNER``, ``_EMAIL`` or similar describes the credential rather
than carrying it, so the name alone does not convict it; a
credential source in the value still does.
"""
by_name = bool(_SECRET_NAME.search(name)) and not _NOT_A_SECRET_NAME.search(name)
return by_name or bool(_SECRET_SOURCE.search(value))
def _argv_lists(node):
"""Yield every ``argv:`` list a task hands to a command module.
Args:
node: a parsed YAML document, or any fragment of one.
Yields:
Each ``argv`` list found under a ``command``/``shell`` mapping.
"""
if isinstance(node, dict):
for key, value in node.items():
if key in _MODULE_KEYS and isinstance(value, dict):
argv = value.get("argv")
if isinstance(argv, list):
yield argv
yield from _argv_lists(value)
elif isinstance(node, list):
for item in node:
yield from _argv_lists(item)
class TestContainerExecEnvPassesNoValue(unittest.TestCase):
def test_container_exec_env_flags_carry_no_value(self) -> None:
findings: list[tuple[str, int, str]] = []
@@ -87,15 +144,45 @@ class TestContainerExecEnvPassesNoValue(unittest.TestCase):
value = line[match.end() : stop]
if not _INTERPOLATED.search(value):
continue
if not (
_SECRET_NAME.search(name) or _SECRET_SOURCE.search(value)
):
if not _carries_credential(name, 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 not rel.endswith((".yml", ".yaml")):
continue
try:
doc = load_yaml_any(path_str)
except Exception: # pragma: no cover - malformed YAML is another rule's
continue
for argv in _argv_lists(doc):
for element in argv:
if not isinstance(element, str):
continue
assignment = _ARGV_ASSIGNMENT.match(element)
if not assignment:
continue
name, value = assignment.group(1), assignment.group(2)
if not _INTERPOLATED.search(value):
continue
if not _carries_credential(name, value):
continue
line_no = next(
(
number
for number, line in enumerate(lines, 1)
if f"{name}=" in line
),
1,
)
if is_suppressed_at(lines, line_no, _RULE, mode="same-or-above"):
continue
findings.append(
(rel, line_no, f"argv {name}={value.strip()[:52]}")
)
if findings:
formatted = "\n".join(
f"- {p}:{ln}: {snip}"