diff options
| author | Zuul <zuul@review.opendev.org> | 2026-04-14 12:47:02 +0000 |
|---|---|---|
| committer | Gerrit Code Review <review@openstack.org> | 2026-04-14 12:47:02 +0000 |
| commit | 376d41a4459a61374f126f5010760e51b1d891aa (patch) | |
| tree | 9f95f5aaebd7383b08e2f00ab4ccd63d2158d038 /roles | |
| parent | 6e149a21e2836b5f29a2f1f95531b9c893268be4 (diff) | |
| parent | 6a82fd00e6e0e030e3f77ed42dcf60d3a05636f3 (diff) | |
| download | kolla-ansible-376d41a4459a61374f126f5010760e51b1d891aa.tar.gz kolla-ansible-376d41a4459a61374f126f5010760e51b1d891aa.zip | |
Merge "CI: Test for leaked passwords in syslog"
Diffstat (limited to 'roles')
| -rw-r--r-- | roles/kolla-ansible-check/defaults/main.yml | 13 | ||||
| -rw-r--r-- | roles/kolla-ansible-check/library/kolla_ansible_password_check.py | 413 | ||||
| -rw-r--r-- | roles/kolla-ansible-check/tasks/main.yml | 4 | ||||
| -rw-r--r-- | roles/kolla-ansible-check/tasks/passwords.yml | 7 |
4 files changed, 437 insertions, 0 deletions
diff --git a/roles/kolla-ansible-check/defaults/main.yml b/roles/kolla-ansible-check/defaults/main.yml new file mode 100644 index 000000000..c435ac5ab --- /dev/null +++ b/roles/kolla-ansible-check/defaults/main.yml @@ -0,0 +1,13 @@ +--- +kolla_ansible_check_passwords_file: /etc/kolla/passwords.yml +kolla_ansible_check_ignore_keys: + # TODO(mnasiadka): Fix in ansible/roles/mariadb/tasks/bootstrap_cluster.yml#L14 + - database_password + # TODO(mnasiadka): Fix in ansible/roles/heat/tasks/bootstrap_service.yml#L22 + - heat_domain_admin_password + # TODO(mnasiadka): Fix openstack_auth all over the place in kolla_toolbox module_args + - keystone_admin_password + # TODO(mnasiadka): Fix in ansible/roles/nova/tasks/map_cell0.yml#L12 + - nova_database_password + # TODO(mnasiadka) Fix in ansible/roles/rabbitmq/defaults/main.yml#L11 + - rabbitmq_cluster_cookie diff --git a/roles/kolla-ansible-check/library/kolla_ansible_password_check.py b/roles/kolla-ansible-check/library/kolla_ansible_password_check.py new file mode 100644 index 000000000..277138638 --- /dev/null +++ b/roles/kolla-ansible-check/library/kolla_ansible_password_check.py @@ -0,0 +1,413 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright 2026 StackHPC Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ansible.module_utils.basic import AnsibleModule +import atexit +import os +import subprocess +import tempfile + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: kolla_ansible_password_check +short_description: Check kolla-ansible passwords.yml for leaked secrets +description: + - Reads a kolla-ansible passwords.yml file and searches the systemd journal + for any occurrences of the password values. + - Reports which variable names had their secrets found in the journal. + - Fails if any leaked secrets are detected. + - All secret values are handled internally and never appear in Ansible + output, logs, or return values — only variable names are ever surfaced. +version_added: "1.0.0" +author: + - Michal Nasiadka <mnasiadka@gmail.com> +options: + passwords_file: + description: + - Path to the kolla-ansible passwords.yml file on the remote host. + type: path + required: true + min_password_length: + description: + - Minimum length a password value must have to be checked. + - Very short values (e.g. single characters) are skipped to avoid + false positives. + type: int + default: 8 + journalctl_args: + description: + - Extra arguments passed verbatim to journalctl + (e.g. C(--since "1 hour ago")). + - Do not include C(--no-pager) or C(-g) / C(--grep) as these are added + automatically. + type: str + default: "" + fail_on_leaked: + description: + - Whether to fail the task when leaked secrets are found. + - Set to C(false) to report leaks without failing, useful for dry-runs. + type: bool + default: true + return_stdout: + description: + - When C(true), include the journalctl output for each leaked entry + in the C(leaked_entries) return value. + - Has no effect when there are no leaks. + - Note that journalctl output for a leaked entry will contain the + secret value. Use C(no_log) on any subsequent tasks that display + C(leaked_entries) if you need to protect it. + type: bool + default: false + ignore_keys: + description: + - List of variable names whose presence in the journal should not be + treated as a leak. + - Entries in this list are still checked against the journal; they are + just excluded from C(leaked_keys), C(leaked_entries), and the failure + condition. + - Matched entries are reported separately in C(ignored_leaked_keys) so + there is a full audit trail of what was intentionally allowed. + type: list + elements: str + default: [] +notes: + - Requires C(journalctl) and C(grep) to be available on the remote host. + - Must be run with sufficient privileges to read the journal + (typically requires C(become: true)). + - The journal is dumped once to a temporary file and then C(grep -F) is + run per password against that file, which is significantly faster than + running C(journalctl --grep) once per password. + - The temporary file is deleted automatically when the module exits. + - Secret values are B(never) included in module return data, only variable + names are returned, making this safe to use without C(no_log). +requirements: + - journalctl (systemd) + - PyYAML +""" + +EXAMPLES = r""" +- name: Check kolla passwords for journal leaks + kolla_ansible_password_check: + passwords_file: /etc/kolla/passwords.yml + become: true + register: leak_result + +- name: Show which keys leaked + ansible.builtin.debug: + msg: "Leaked keys: {{ leak_result.leaked_keys }}" + when: leak_result.leaked_keys | length > 0 + +# Non-failing variant for reporting +- name: Audit journal leaks without failing + kolla_ansible_password_check: + passwords_file: /etc/kolla/passwords.yml + fail_on_leaked: false + journalctl_args: '--since "24 hours ago"' + become: true + register: audit_result +""" + +RETURN = r""" +leaked_keys: + description: + - List of variable names from passwords.yml whose values were found in + the journal. Never contains the actual secret values. + type: list + elements: str + returned: always + sample: ["keystone_db_password", "nova_keystone_password"] +checked_count: + description: Number of password entries that were actually checked. + type: int + returned: always + sample: 42 +skipped_count: + description: + - Number of entries skipped because their value was empty, null, + non-string, or shorter than min_password_length. + type: int + returned: always + sample: 5 +msg: + description: Human-readable summary of the check result. + type: str + returned: always + sample: "No secrets found in journal. Checked 42 entries." +leaked_entries: + description: + - Mapping of leaked variable names to their journalctl output. + - Only populated when C(return_stdout: true) and leaks are found. + - The journalctl output will contain the secret value — treat accordingly. + type: dict + returned: when return_stdout is true and leaks are found + sample: {"keystone_db_password": "Mar 26 ... keystone_db_password=s3cr3t"} +ignored_leaked_keys: + description: + - List of variable names that were found in the journal but are present + in C(ignore_keys) and therefore excluded from C(leaked_keys). + - Always returned so there is a full audit trail of intentional allowances. + type: list + elements: str + returned: always + sample: ["deployment_ssh_key"] +""" + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + + +def load_passwords(module, passwords_file): + """Load and parse the kolla passwords YAML file. + + Returns a dict of {variable_name: password_value}. + Aborts the module on any read/parse error. + """ + if not os.path.isfile(passwords_file): + module.fail_json( + msg="passwords_file not found: {}".format(passwords_file)) + + try: + with open(passwords_file, "r") as fh: + content = fh.read() + except OSError as exc: + module.fail_json( + msg="Cannot read passwords_file: {}".format(str(exc))) + + try: + data = yaml.safe_load(content) + except yaml.YAMLError as exc: + module.fail_json( + msg="Failed to parse passwords YAML: {}".format(str(exc))) + + if data is None: + return {} + if not isinstance(data, dict): + module.fail_json( + msg="passwords_file must contain a YAML mapping at the top level" + ) + + return data + + +def dump_journal(module, extra_args): + """Dump the full journal to a temporary file. + + Returns the path to the temp file. The file is registered for deletion + via atexit so it is cleaned up even if the module exits unexpectedly. + """ + try: + tmp = tempfile.NamedTemporaryFile( + prefix="kolla_password_check_", + suffix=".log", + delete=False, + ) + tmp.close() + except OSError as exc: + module.fail_json(msg="Failed to create temp file: {}".format(str(exc))) + + # Register cleanup before the subprocess so the file is always removed. + atexit.register(_unlink_silent, tmp.name) + + cmd = ["journalctl", "--no-pager"] + if extra_args: + cmd.extend(extra_args.split()) + + try: + with open(tmp.name, "w") as fh: + result = subprocess.run( + cmd, + stdout=fh, + stderr=subprocess.PIPE, + ) + except OSError as exc: + module.fail_json(msg="Failed to run journalctl: {}".format(str(exc))) + + if result.returncode != 0: + stderr_text = result.stderr.decode("utf-8", errors="replace").strip() + module.fail_json( + msg="journalctl exited with code {}: {}".format( + result.returncode, stderr_text + ) + ) + + return tmp.name + + +def grep_journal(module, journal_file, value, return_stdout): + """Search journal_file for value using grep -F (fixed string). + + Returns (found, matching_lines) where matching_lines is a string of + all matching lines (only populated when return_stdout is True). + """ + # -F: fixed string (no regex, no escaping needed) + # -q: quiet — exit 0 immediately on first match when return_stdout is False + cmd = ["grep", "-F"] + if not return_stdout: + cmd.append("-q") + cmd.extend(["--", value, journal_file]) + + try: + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except OSError as exc: + module.fail_json(msg="Failed to run grep: {}".format(str(exc))) + + # grep exits 0 = match found, 1 = no match, 2+ = error. + if result.returncode == 0: + stdout = result.stdout.decode( + "utf-8", errors="replace") if return_stdout else "" + return True, stdout + elif result.returncode == 1: + return False, "" + else: + stderr_text = result.stderr.decode("utf-8", errors="replace").strip() + module.warn( + "grep returned exit code {} while checking an entry. " + "stderr: {}".format(result.returncode, stderr_text) + ) + return False, "" + + +def _unlink_silent(path): + """Delete a file, ignoring errors (used as atexit handler).""" + try: + os.unlink(path) + except OSError: + pass + + +def run_module(): + module_args = dict( + passwords_file=dict(type="path", required=True), + min_password_length=dict(type="int", default=8), + journalctl_args=dict(type="str", default=""), + fail_on_leaked=dict(type="bool", default=True), + return_stdout=dict(type="bool", default=False), + ignore_keys=dict(type="list", elements="str", default=[]), + ) + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + ) + + if not HAS_YAML: + module.fail_json(msg="PyYAML is required for this module.") + + passwords_file = module.params["passwords_file"] + min_length = module.params["min_password_length"] + extra_args = module.params["journalctl_args"] + fail_on_leaked = module.params["fail_on_leaked"] + return_stdout = module.params["return_stdout"] + ignore_keys = set(module.params["ignore_keys"]) + + passwords = load_passwords(module, passwords_file) + + leaked_keys = [] + leaked_entries = {} + ignored_leaked_keys = [] + checked_count = 0 + skipped_count = 0 + + if not module.check_mode: + journal_file = dump_journal(module, extra_args) + + for idx, (key, value) in enumerate(passwords.items()): + # Skip null, non-string, or suspiciously short values. + if not isinstance(value, str): + skipped_count += 1 + continue + if len(value) < min_length: + skipped_count += 1 + continue + + checked_count += 1 + + # In check mode we report what *would* be checked without hitting + # journalctl, since we can't know what the journal contains. + if module.check_mode: + continue + + found, stdout = grep_journal( + module, journal_file, value, return_stdout) + if found: + if key in ignore_keys: + # Found in journal but explicitly allowed — track separately + # for audit purposes but do not treat as a failure. + ignored_leaked_keys.append(key) + else: + # Only the key name is stored by default — the secret value is + # intentionally never written to any Ansible data structure + # unless return_stdout is explicitly requested. + leaked_keys.append(key) + if return_stdout: + leaked_entries[key] = stdout + + if leaked_keys: + summary = ( + "Leaked secrets found in journal for {} variable(s): {}. " + "Checked {} entries, skipped {}.".format( + len(leaked_keys), + ", ".join(leaked_keys), + checked_count, + skipped_count, + ) + ) + else: + summary = ( + "No secrets found in journal. " + "Checked {} entries, skipped {}.""".format( + checked_count, + skipped_count + ) + ) + + if ignored_leaked_keys: + summary += " Ignored {} allowed variable(s): {}.".format( + len(ignored_leaked_keys), ", ".join(ignored_leaked_keys) + ) + + result = dict( + changed=False, + leaked_keys=leaked_keys, + leaked_entries=leaked_entries, + ignored_leaked_keys=ignored_leaked_keys, + checked_count=checked_count, + skipped_count=skipped_count, + msg=summary, + ) + + if leaked_keys and fail_on_leaked: + module.fail_json(**result) + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == "__main__": + main() diff --git a/roles/kolla-ansible-check/tasks/main.yml b/roles/kolla-ansible-check/tasks/main.yml new file mode 100644 index 000000000..c027438d9 --- /dev/null +++ b/roles/kolla-ansible-check/tasks/main.yml @@ -0,0 +1,4 @@ +--- +- name: Import passwords.yml + ansible.builtin.import_tasks: passwords.yml + when: not is_upgrade diff --git a/roles/kolla-ansible-check/tasks/passwords.yml b/roles/kolla-ansible-check/tasks/passwords.yml new file mode 100644 index 000000000..6919090b2 --- /dev/null +++ b/roles/kolla-ansible-check/tasks/passwords.yml @@ -0,0 +1,7 @@ +--- +- name: Test for leaked passwords in journal + become: true + kolla_ansible_password_check: + passwords_file: "{{ kolla_ansible_check_passwords_file }}" + ignore_keys: "{{ kolla_ansible_check_ignore_keys }}" + when: inventory_hostname == 'primary' |
