How to register secrets - as plugin developers, and users?

This is another discussion about the new Secret masking feature of ansible-core 2.22. I started working on some PRs in community.general to make lookup plugins that retrieve secrets automatically register sensitive values they look up as secrets (PR 1, PR 2, PR 3, PR 4, PR 5, PR 6). While working on them, I started having doubts on whether this is actually a good idea (for some plugins in community.sops, I changed the default to “do not register anything as secrets, unless the user explicitly asks for it”). This is more critical now that ansible-core 2.22 will likely also register and mask secrets that are less than 4 letters long, and mask 4 and 5 letter long secrets also inside words and not just at word boundaries: accidentally registering non-secret values can cause big pain, especially for short and generic texts (like 0, 5, test, user, localhost, …). Think of a random playbook output where all these texts (even the mentioned individual digits!) are replaced by $REDACTED$.

Right now, I only merged code that automatically registers secrets (I explicitly exclude values to options that are marked with secret: true here to restrict scope of the discussion) in case the values are obviously secrets, such as GitHub tokens or Docker Swarm tokens. Everything that retrieves values created by humans, such as passwords stored in password/secret/vault managers (see most of the PRs linked above) IMO have a certain chance of containing non-sensitive, trivial strings (see examples above) that are only stored in such places for convenience reasons, and would be dangerous to register.

I think it would be better (instead of registering such values) to make it easier for users to register output of plugins as secrets. Right now, there’s the ansible.builtin.register_secret filter which registers individual strings. If you have a data structure where some parts are secrets, it’s pretty painful to register these secrets, especially if you want to do it very close to the lookup to prevent secrets to show up in logs (think of verbose output which prints the results of a task).

IMO it would be best if you can directly register parts of the output of a lookup / filter plugin or a module as secrets.

Think of a lookup / filter which returns a data structure:

- foo: this is a secret  # this is sensitive
  bar: hello
- foo: another secret  # this is sensitive
  bar: world
- foo: true  # if you feed this to ansible.builtin.register_secret, it will barf
  members:
    - user: foo
      password: bar  # this is sensitive
    - user: baz
      token: asdlkjfa98321329  # this is sensitive

Try to come up with some Jinja2 that takes this result - as a first step, assume it has already been stored in a variable - and registers all the secrets in it with ansible.builtin.register_secret, without accidentally passing true to it. That’s definitely not fun. (I’d likely use community.general.json_query as a building block.)

And then, as the next level, try to do this as a list of filters directly after lookup(...) / query(...) / | filter so that you actually get the unmodified values, while at the same time registering the secrets. (I don’t think this is possible without creating a new filter plugin.) Otherwise, if you do:

- name: Retrieve data
  ansible.builtin.set_fact:
    my_value: '{{ lookup("foo.bar.lookup", whatever) }}'-
- name: Register secrets:
  ansible.builtin.set_fact:
    _secrets: >-
      {{ my_value | community.general.json_query(...) | filter(...) | map("ansible.builtin.register_secret") }}

and run the playbook with -v, the secret values are printed in clear during the first task. So you really want to write:

- name: Retrieve data and register secrets
  ansible.builtin.set_fact:
    my_value: '{{ lookup("foo.bar.lookup", whatever) | ...magic happens here... }}'-

so that only the secrets are registered, but you end up with the same (complex) data structure as my_value.

I think we need some more flexible filter plugins that allow to register specific parts of their input as secrets, while ignoring things that shouldn’t be registered as secrets (like booleans, and maybe also too short strings or specific strings), while returning the original output.

For module results, this is likely more complex. What you could do is use register projections together with such filters to also register the secrets before the task is actually done; I haven’t used register projections yet (since I had to support older ansible-core versions as well) so I’m not sure how exactly they interact with callbacks etc., and whether this can actually be used to properly remove secret values returned by modules (that don’t themselves register these values as secrets)…

I’m starting this thread so we can collect ideas of how to do this - what kind of filters / other kind of plugins would be needed/userful, how to write tasks/… where secrets can appear in that should be hidden, etc.

Having Jinja deal with lists of dict values and selecting/transforming/filtering on specific attributes/values has always been confusing and a mess to me. On the flip side what constitutes a secret is very much a content/user driven choice so having a generic filter to deal with this is hard to get right. That’s why we have restricted the builtin filter to accept a single value and leave it up to the caller to redact. I think register projections make things simpler but I can respect that some people need to support older Ansible versions before this. The example we have in Masking secrets in Ansible output — Ansible Community Documentation I think shows a nice way for a module return result to register a value based on a content driven choice. Maybe having more examples here to show how to deal with different structure formats like selecting a specific attribute in a list would be nice to show here. I know we still need to update the docs to reflect the new length rules and behaviour in Remove secret masking floor and ceiling by jborean93 · Pull Request #87648 · ansible/ansible · GitHub but the general patterns would probably stay the same.

I’m sorry I don’t have a good suggestion here, I’m just not sure how this can be done generically without overcomplicating the implementation or UX of such a plugin.

While I do lament we are ending up moving away from the floor and word boundary checks I think in the end Ansible cannot make this decision for people and the alternative ends up being constant CVE reports and a lack of confidence in the mechanism if their short secrets end up being exposed in the output. We didn’t even get to the first beta before we had our first report :slight_smile: There’s always a chance we will revisit this decision and at least put the floor at 2 or 3 but only time will tell if these short secrets become an actual problem. Ultimately caution won out over what we deemed to be practical.

I don’t think there is a generic way to handle this for all cases, but I think that a (small) set of more generic filters could cover most cases.

A filter which accepts input (that it returns unchanged, unless some eat=true parameter is passed) and a sequence of inclusion/exclusion JSON query paths (as used by community.general.json_query) / jq expressions (used in other contexts) / something similar to select secret values (or reject non-secret values), would likely already cover a lot of cases.

Being able to define schemas for this (maybe stored as ansible vars, or also in other ways?) and reusing them in multiple places would likely also be useful, if you need to process similar lookup/query results in different places.

That’s a good suggestion, having some sort of jquery syntax to specify such things makes it a lot more versatile and fits into an existing language that someone may already know. I believe the json_query had some issues with dependencies way back when and I don’t know if the same is true for jquery. The last time I checked jq (not jquery on PyPI) could be an option as it ships the actual jq engine as part of some wheels so it’s simple enough to install.

Looks like you would like to mark all the secrets in one go, but you do not have to. E.g. for your example:

my_value:
- foo: this is a secret  # this is sensitive
  bar: hello
- foo: another secret  # this is sensitive
  bar: world
- foo: true  # if you feed this to ansible.builtin.register_secret, it will barf
  members:
    - user: foo
      password: bar  # this is sensitive
    - user: baz
      token: asdlkjfa98321329  # this is sensitive
- name: Register secrets:
  ansible.builtin.set_fact:
    __register_secret1: >-
      {{ my_value | selectattr('foo', defined) | selectattr('bar', defined) | 
 map(attribute='foo') | map("ansible.builtin.register_secret") }}
    __register_secret2: >-
      {{ my_value | selectattr('foo', defined) | selectattr('members', defined) | 
 map(attribute='members') | flatten | selectattr('password', defined) | map(attribute='password') | map("ansible.builtin.register_secret") }}
    __register_secret3: >-
      {{ my_value | selectattr('foo', defined) | selectattr('members', defined) | 
 map(attribute='members') | flatten | selectattr('token', defined) | map(attribute='token') | map("ansible.builtin.register_secret") }}

So all the pieces needed are present in ansible (for majority of use cases). Here the fact that all the variables in set_fact will be evaluated comes in handy.

PS not quite sure that jinjas are correct, hope you get the idea.