Reported symptoms
A secret scanner reports a high-severity finding against the deployment pipeline’s build log. The flagged string is the production database password.
The security team’s first questions all get reassuring answers, which is what makes the next hour confusing:
- Is the vault file committed in plain text? No. It is encrypted and its header and modification time are unchanged.
- Did somebody decrypt it into the repository? No.
- Is there a
debugtask printing the password? No. There is nodebugtask anywhere in the role. - Can it be reproduced? A developer runs the same playbook from a workstation against a staging target. The output is clean.
At that point the theory shifts to a compromised CI runner, and an hour goes into examining a machine that is behaving perfectly correctly.
Eleven days of build logs contain the credential. Roughly 400 people can read them.
Evidence provided
$ grep -n -m1 'app_db_dsn' build-4471.logok: [app01] => {"ansible_facts": {"app_db_dsn": "postgresql://app:REPLACE_ME_DB_PASSWORD@db-prod.example.com:5432/app"}, "changed": false}$ ansible-playbook -i inventory deploy.yml --limit staging01 | grep -c app_db_dsn0$ ansible-playbook -i inventory deploy.yml --limit staging01 -v | grep -c app_db_dsn1$ git log -p --since=14.days -- .gitlab-ci.yml | grep -E '^[-+].*ansible-playbook'- ansible-playbook -i inventory deploy.yml
+ ansible-playbook -i inventory deploy.yml -v$ git log -p --since=14.days -- roles/app/tasks/main.yml | grep -E '^[-+].*(no_log|set_fact|app_db_dsn)' ansible.builtin.set_fact:
app_db_dsn: "postgresql://app:{{ vault_db_password }}@{{ db_host }}:5432/app"
- no_log: true$ ansible-vault view --vault-password-file /etc/ansible/vault-pass group_vars/production/vault.yml | head -1; stat -c '%y' group_vars/production/vault.ymlvault_db_password: REPLACE_ME_DB_PASSWORD
2026-05-14 09:22:41.000 +0000Work the evidence before reading on
The vault is fine. The runner is fine. Nothing prints the password on purpose.
- The local run is clean and the CI run is not. List every difference between the two invocations. There is exactly one that matters.
set_factsets a fact. What does the result of a task look like at verbosity 1, and what does the result of aset_facttask contain?- Two commits landed in the same fortnight. Which one is the vulnerability and which one is the exploit?
Before continuing: if you restored no_log: true and left -v in
place, would you be safe? If you removed -v and left no_log off,
would you be safe? What does that tell you about how to think about
either change on its own?
Root cause
1. Verbosity prints the full result of every task
At default verbosity Ansible prints a status word and a host name. From
-v upwards it prints the complete result dictionary the module
returned.
For most modules that is dull. For set_fact it is the fact itself:
ok: [app01] => {"ansible_facts": {"app_db_dsn": "postgresql://app:REPLACE_ME_DB_PASSWORD@..."}, "changed": false}
Nothing here is a bug. The callback is doing precisely what verbosity asks it to do, and the module result genuinely contains the value the module was asked to set.
2. no_log protects a task, not a variable
no_log: true suppresses the result of the task it is written on. It is
not a taint marker that follows a value around.
The set_fact that assembles the connection string had it, and lost it
in a refactor. From that moment the task’s result was printable, and the
value it contained was the credential.
The reverse mistake is just as common. no_log on the task that reads a
secret does not protect a later task that consumes it: a debug on a
derived variable, a command with the value on its argument line, or a
uri task that echoes a failed request body will all print it.
Protection has to be applied at every task that touches the value.
3. Neither change was reviewable in isolation
The -v in the pipeline definition is a one-word diff in a YAML file,
proposed to make failing builds easier to read, and it is a completely
reasonable request.
The no_log removal is a one-line deletion inside a role, made while
debugging a templating problem, in a commit that also changed the thing
being debugged. Both passed review because both are individually
sensible and neither reviewer could see the other.
The combination is a disclosure of a production credential to everyone with CI access, held for 90 days by the log retention policy.
Resolution
- Rotate the credential first. Everything after this is cleanup, and the cleanup does not undo the disclosure. Treat the value as known to everyone who has had CI access for eleven days.
- Establish the true exposure before deleting anything. How many builds, over what period, and where else did the logs go - artefact stores, log shipping, chat notifications, email on failure, screenshots in tickets.
- Restore
no_log: trueon theset_facttask, and audit every task downstream that consumes a value derived from the credential. Protection is per task, so a single restoration is unlikely to be sufficient. - Remove
-vfrom the pipeline definition. If verbose output is needed to diagnose a specific failure, raise verbosity for that run, deliberately, and account for where the output lands. - Purge the affected build logs and every copy you identified. Do this after the rotation, not instead of it.
- Verify the old credential is refused by the database rather than verifying that a new one was issued. Rotation is not complete until the old value fails.
- Notify according to policy. A production credential readable by 400 people for eleven days is a reportable disclosure in most organisations regardless of whether anyone read it.
- Add automated secret scanning to build logs, and treat a hit as an incident with rotation rather than a warning to triage. This finding came from a scanner that already existed; the gap was in what happened next.
Verification
- The pipeline no longer emits the value. Run it at the verbosity it uses in production, capture the output, and grep for the credential; the count must be zero.
- The search itself works. Plant a distinctive marker value in a scratch run at
-vvv, and confirm the same grep finds it. A negative result from an untested search is not evidence, and this is the check that can fail. - The censoring is visible where it should be. In a verbose run against a scratch target, the protected tasks show the censored placeholder rather than a result, including in a loop and including on a deliberately failed task.
- The old credential is refused. Attempt authentication with it and require a failure. A new credential existing is not the same as the old one being dead.
- Every copy is accounted for. The exposure inventory is closed: build logs, artefact stores, log-shipping destinations, notification channels, and anything with a longer retention than the CI system itself.
- The scanner covers this path. Reintroduce a marker into a branch build and confirm the scanner fires and that the response is rotation rather than a triage note.
- A review rule exists. Removing
no_lognow requires explicit approval, and there is a test that fails if the protected tasks lose it.
Prevention
- Never run automation at raised verbosity by default. Verbosity is for a specific investigation, with a known destination for the output.
- Apply
no_logto every task that handles a secret and every task that handles anything derived from one. It protects a task, not a value, and a derived value is just as sensitive as the original. - Review
no_logremovals as security changes. A one-line deletion inside a refactor is the most likely way this protection is lost, and it reads as noise in a diff. - Prefer fetching a credential at the point of use to assembling it into a fact. A fact persists for the play and is available to every subsequent task result, which is a wide surface for something needed by one task.
- Scan build logs continuously, keep retention short, and treat any hit as a disclosure requiring rotation. Logs are the least-guarded place secrets go, and they are copied, indexed and cached by systems nobody lists.
- Reproduce faithfully when investigating. The local run was clean because it differed from the pipeline in one flag, and that difference sent an hour of the investigation at an innocent machine.