New Secret Masking Feature - 2.22

New Ansible 2.22 Feature - Secret Masking

A new feature has just been merged in devel for ansible-core, a secret registration and masking system. The initial implementation is in ansible/ansible#87457 and the documentation is in review at ansible/ansible-documentation#3934. We would love feedback from anyone who can try it on devel before release, especially collection maintainers with custom modules or callback plugins.

Big shout out to @pkingstonxyz and @mkrizek for bringing this feature over the time.

What it is

Ansible keeps a registry of secret values for the life of the process. Any text that leaves Ansible through display output (screen and log_path), callback plugin results, or module logging on the managed node has registered secrets replaced with $REDACTED$.

Unlike the old VALUE_SPECIFIED_IN_NO_LOG_PARAMETER on module values with no_log=True, masking is now non-destructive. Variables, module arguments, and task results keep the real value which can be used in registered variables. Only the rendered output through callbacks of display calls will redact those value.

Values are registered automatically from various sources; vault content and passwords, no_log module options, vars_prompt and --ask-* prompts, connection and become passwords, and the password/unvault lookups and vault/unvault filters. Secrets can also be manually registered with the new register_secret filter plugin.

What it looks like

Two new filters, register_secret and mask_secrets, cover anything Ansible does not register for you:

- name: Generate a database password
  ansible.builtin.command: openssl rand -base64 32
  register:
    db_password: _task.result.stdout | register_secret
    password_result: _task.result
  no_log: true

- name: Usable, but masked in output
  ansible.builtin.debug:
    msg: "Setting database password to {{ db_password }}"

The last task prints Setting database password to $REDACTED$, and db_password still holds the real value for later tasks. Use mask_secrets when writing output somewhere Ansible does not control, such as a file on a target.

What it means for end users

  • no_log module options no longer return VALUE_SPECIFIED_IN_NO_LOG_PARAMETER in results. The real value is kept and masked only in output. Update any playbooks or tests that check for that placeholder.
  • The no_log task keyword is unchanged and still censors the whole task result. It does not register anything, so pair it with register_secret if a later task uses the value.
  • Secrets shorter than 4 characters are never masked to avoid collisions in normal out, and 4 to 6 character secrets are only masked as whole words. Only exact matches are masked, so hashed or base64 copies are not.
  • This is best effort and a safety net, not a replacement for Vault and no_log if you encounter one of the known limitations

What it means for module and plugin developers

  • A new public API in ansible.module_utils.secrets works in modules and all controller plugins:
from ansible.module_utils.secrets import register_secret, mask_secrets

token = register_secret(session['token'])  # returned unchanged, masked in output

mask_secrets(f"API Token Result {token}")  # API Token Result $REDACTED$

PowerShell modules get the same via the Ansible.Secrets C# util with [Ansible.Secrets.SecretMasker]::RegisterSecret() and ::MaskString().

  • Plugin options can be marked secret: true so the value is registered regardless of which source set it. Use this for any password or token option.
  • Callback plugins should set ANSIBLE_SUPPORTS_MASKING = True on the class and mask the results themselves. Callbacks that set it receive unmasked results and must mask anything written outside Display with mask_secrets(). Callbacks that do not set it keep receiving pre-masked results through a compatibility shim that will be deprecated and removed in a future release.
  • heuristic_log_sanitize(), remove_values(), and sanitize_keys() are deprecated for removal in 2.25. AnsibleModule.log() and run_command() no longer apply the old password heuristics, so register any secret passed on a command line that is not a no_log option.

Full details, including the length rules and limitations, are in the documentation PR Add documentation for secret masking by jborean93 · Pull Request #3934 · ansible/ansible-documentation · GitHub that is not yet live.

Known Problems

This is a list of known problems in the current implementation that we are aiming to either fix or explicitly document as a known limitation:

  • Module journalctl/syslog of invocation args do not redact module options with a value less than 4 characters in suboptions
  • A failure in register_secrets filter that contains the literal value leaks in the Ansible error origin statement - {{ 12345678 | register_secret }} (fails due to being non-str)
    • Still trying to find the best way forward for this

I’ll be editing this section so it should stay up to date.

7 Likes

Nice work on this, and the non-destructive part especially.

A question about the length floor. A value under four characters is discarded at registration with no warning and no error, and register_secret returns it unchanged either way, so nothing tells the author it did not take effect. The value then prints in full.

The floor assumes the threat is guessing the value, where three characters is no keyspace. Plenty of short values are not credentials at all: an environment code, a customer or org short name, a subdomain. Nobody needs to guess those. What matters is that the string shows up in a log that goes into a support ticket or a public CI job, and no longer value is available, because it is the author’s org name.

So the behavior is fail open, silently, for everyone. Over-redacting costs a log that is hard to read. Under-redacting publishes something explicitly marked as not for publication.

Is fail open the right thing to hard code here, or should it be a policy with a fail safe default? A warning would be the minimum. _log_invocation already takes the other path for no_log params, blanking them rather than trusting the masker, which seems like the right instinct.

The main problem with redacting short strings is that they tend to easily show up randomly in other strings. If you end up seeing $REDACTED$ in some Base64 encoded output because that 2- or 3-letter “secret” sequence randomly shows up in data, this is at least annoying and sometimes dangerous (since you can’t use the Base64 output anymore since you don’t know its actual value).

(This is also a reason why I would have preferred to use tagging over value redaction, since then you can also tag a one-letter string as secret without it resulting in that letter being redacted everywhere, but the problem with this approach is that it misses many places where this secret value shows up in, like composed URLs which contain username + password.)

Other services that redact strings from output, like GitLab CI redacting secrets from CI log, also only redact strings that have a certain length (and format, I think it also requires you to not use spaces IIRC).

(I’m waiting for the first person to use four spaces as a sensitive value somewhere, and then wondering why they end up having $REDACTED$ all over the place in output where something is indented by at least four spaces… I saw some issues where people complained that parts of URLs/data/… got censored in module output when they used apparently trivial passwords for no_log inputs, that happened to be similar to other strings that showed up. Like password test and URL test.example.com, where “obviously” the second test is totally unrelated to the first test and why on earth got the second one redacted?!)