We will see today how to use an Ansible callback plugin to hide sensitive data from ansible-playbook output.

Example playbook

Let’s say you need to create some MySQL databases with ansible, you will propably write a playbook like the one below, and protect your passwords with ansible-vault:

---
- hosts: mysql-server
  gather_facts: no
  become: true
  vars:
    databases:
      - name: foo
        password: !vault |
          $ANSIBLE_VAULT;1.2;AES256;main
          37666232623836363831383162313333376436616662396334313033626663383239366266323334
          3665363939326132333261633165326561313262613634320a363036646533333530663434303764
          33666138343666363832303930653031646265366165656564646166393736326435356666366139
          3362363331396464370a373437306232353039323861303931386630316533323538336166353332
          65313538623561353461383539303265323336636465396264326230623663386439
      - name: bar
        password: !vault |
          $ANSIBLE_VAULT;1.2;AES256;main
          35343463336563396665616163623264633163326564323435613837323164633661623038383464
          6334323632653862623836363165316339623465633362610a366630386164656363626237643234
          34336339633966663830356562316536663138356331626265313731303134313733636363366437
          6236623665663430300a663866646536363764623332373261623134383530643634373632343962
          32343837316534373561303062656332666433366366353361613339656234653537
      - name: toto
        password: !vault |
          $ANSIBLE_VAULT;1.2;AES256;main
          37396563636435616461313331323463656239383266383766353363326135306133646635326363
          3039353630306265633838333438386136386635653433650a363762386462633832666361346161
          36343938616464663661623632656634363762366434373435323765656135666138333836303937
          6135303330386264340a356235633433373165316465643938666135646564396665663766616539
          31383238363761326139336365383639323663333235356664643232353438353766
      - name: tata
        password: !vault |
          $ANSIBLE_VAULT;1.2;AES256;main
          65616638393834613334623633383233326465623863613531636463636636383532313538643832
          3335333637363138663630663336333163326238323235610a343730666335346361633939333335
          63306165323239636530366463626632613138666663373735626531386361303063613932373830
          3136306435666131390a643434643836366135336662376538633861633637613663633962346565
          34613966353462306134636537306636346662383932353332373636643633633061
  tasks:
    - name: Install MySQL packages
      ansible.builtin.package:
        name:
          - mysql-server
          - adminer
          - python3-pymysql

    - name: Setup adminer
      ansible.builtin.file:
        src: /etc/apache2/conf-available/adminer.conf
        dest: /etc/apache2/conf-enabled/adminer.conf
        owner: root
        group: root
        state: link
      notify:
        - Restart apache

    - name: Create databases
      community.mysql.mysql_db:
        name: "{{ item.name }}"
        state: present
        login_unix_socket: /run/mysqld/mysqld.sock
      loop: "{{ databases }}"

    - name: Create databases users
      community.mysql.mysql_user:
        name: "{{ item.name }}"
        password: "{{ item.password }}"
        priv: "{{ item.name }}.*:ALL"
        state: present
        login_unix_socket: /var/run/mysqld/mysqld.sock
      loop: "{{ databases }}"

  handlers:
    - name: Restart apache
      ansible.builtin.service:
        name: apache2
        state: restarted
...

What’s the problem?

As you have several databases to create, you used a loop to invoke the ansible.builtin.mysql_db module, and ansible is displaying your passwords in plain-text!

...
TASK [Create databases] *******************************************************
Saturday 02 September 2023  11:25:02 +0200 (0:00:00.083)       0:00:02.451 ****
ok: [mysql-server] => (item={'name': 'foo', 'password': 'my-vaulted-password'})
ok: [mysql-server] => (item={'name': 'bar', 'password': 'my-vaulted-password'})
ok: [mysql-server] => (item={'name': 'toto', 'password': 'my-vaulted-password'})
ok: [mysql-server] => (item={'name': 'tata', 'password': 'my-vaulted-password'})

TASK [Create databases users] *************************************************
Saturday 02 September 2023  11:25:02 +0200 (0:00:00.285)       0:00:02.736 ****
ok: [mysql-server] => (item={'name': 'foo', 'password': 'my-vaulted-password'})
ok: [mysql-server] => (item={'name': 'bar', 'password': 'my-vaulted-password'})
ok: [mysql-server] => (item={'name': 'toto', 'password': 'my-vaulted-password'})
ok: [mysql-server] => (item={'name': 'tata', 'password': 'my-vaulted-password'})

...

How to solve this?

With no_log parameter

Usually, ansible folks solve this issue by adding no_log: true to the ansible tasks. But it hides the whole output by replacing the item content with None value:

TASK [Create databases] **********************************************************************************************************************************************************************
Saturday 02 September 2023  11:42:53 +0200 (0:00:00.083)       0:00:02.489 ****
ok: [mysql-server] => (item=None)
ok: [mysql-server] => (item=None)
ok: [mysql-server] => (item=None)
ok: [mysql-server] => (item=None)
ok: [mysql-server]

In case of an error, how will you guess which database has failed?

With callback plugin

With a custom ansible callback plugin you can obfuscate only password:

TASK [Create databases] **********************************************************************************************************************************************************************
Saturday 02 September 2023  11:50:36 +0200 (0:00:00.085)       0:00:02.499 ****
ok: [mysql-server] => (item={'name': 'foo', 'password': '********'})
ok: [mysql-server] => (item={'name': 'bar', 'password': '********'})
ok: [mysql-server] => (item={'name': 'toto', 'password': '********'})
ok: [mysql-server] => (item={'name': 'tata', 'password': '********'})

Show me how to use this callback plugin!

In your ansible project, create a plugins/callback folder and put this protect_data.py file:

from ansible_collections.community.general.plugins.callback.yaml import CallbackModule as CallbackModule_default
import collections

DOCUMENTATION = """
author: Nelson G. & Jean-Christophe Vassort <anatomicjc@open-web.fr>
name: protect_data
type: stdout
short_description: hide sensitive data such as passwords from screen output
description:
    - hide passwords from screen output
    - https://serverfault.com/questions/754860/how-can-i-reduce-the-verbosity-of-certain-ansible-tasks-to-not-leak-passwords-in/809509#809509
extends_documentation_fragment:
  - default_callback
requirements:
  - set as stdout in configuration
options:
  sensitive_keywords:
    description:
        - a list of sensitive keywords to hide separated with a comma
    type: str
    env:
        - name: PROTECT_DATA_SENSITIVE_KEYWORDS
    ini:
        - section: callback_protect_data
          key: sensitive_keywords
    default: vault,pwd,pass
"""

EXAMPLES = r'''
ansible.cfg: >
  # Enable plugin
  [defaults]
  stdout_callback = protect_data

  [callback_protect_data]
  sensitive_keywords = vault,pwd,pass
'''

class CallbackModule(CallbackModule_default):
    CALLBACK_VERSION = 2.0
    CALLBACK_TYPE = "stdout"
    CALLBACK_NAME = "protect_data"

    def __init__(self):
        super(CallbackModule, self).__init__()
        self.sensitive_keywords = ''

    def set_options(self, task_keys=None, var_options=None, direct=None):

        super(CallbackModule, self).set_options(task_keys=task_keys, var_options=var_options, direct=direct)

        self.sensitive_keywords = self.get_option('sensitive_keywords').split(',')
        
        # Debug info
        #print(self.sensitive_keywords)


    def v2_runner_on_start(self, host, task):
        return True

    def _get_item_label(self, result):
        """retrieves the value to be displayed as a label for an item entry from a result object"""
        result = self.hide_password(result)
        if result.get("_ansible_no_log", False):
            item = "(censored due to no_log)"
        else:
            item = result.get("_ansible_item_label", result.get("item"))
        return item

    def hide_password(self, result):
        ret = {}
        for key, value in result.items():
            sensitive_content = False
            if isinstance(value, (collections.ChainMap, dict)):
                ret[key] = self.hide_password(value)
            else:
                # Each variable containing sensitive_keywords will be hidden from output
                for sensitive_keyword in self.sensitive_keywords:
                    if sensitive_keyword in key:
                        ret[key] = "********"
                        sensitive_content = True
                if not sensitive_content:
                    ret[key] = value
        return ret

    def _dump_results(self, result, indent=None, sort_keys=True, keep_invocation=False):
        return super(CallbackModule, self)._dump_results(
            self.hide_password(result), indent, sort_keys, keep_invocation
        )

And enable it in your ansible.cfg:

[defaults]
callback_plugins = plugins/callback
stdout_callback = protect_data

[callback_protect_data]
sensitive_keywords = vault,pwd,pass

Customize variables you want to hide

By default the plugin will obfuscate variables containing vault, pwd or pass (my_vault or passwd will be obfuscated too).

If you want to hide let’s say any variable containing key, you can customize the sensitive_keywords parameter from the [callback_protect_data] in your ansible.cfg configuration file:

[callback_protect_data]
sensitive_keywords = vault,pwd,pass,key

Credits

Thanks to this serverfault page: