No Plaintext Anywhere: A Secrets Pass Over the Whole Lab

I was about to set up automatic mirroring from my self-hosted GitLab to GitHub, which means every commit I make at home gets relayed to someone else’s servers within seconds of me making it. That seemed like the right moment to stop and ask what’s actually in these repos. Not just the current files, the whole history. A secret you deleted in the latest commit is still sitting in every clone of the repo, forever.

Scan the history, not just the tree

Every provider stamps a recognizable prefix on their tokens these days, which makes history scanning cheap insurance. This walks every commit on every branch and greps the patches:

git log -p --all | grep -nE \
  'ghp_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9_-]{20}|AKIA[0-9A-Z]{16}|BEGIN (RSA|OPENSSH|EC) PRIVATE KEY'

Run it per repo, and if you have a lot of repos, wrap it in a loop and go make coffee. Dedicated tools like gitleaks do this with a bigger pattern library, but the grep gets you 90% of the way with zero installs. My repos came back clean, which I’ll admit surprised me.

The leaks that never got committed

The current state of things was less clean, and neither find would show up in any repo scan, because neither was ever committed. One repo had an access token pasted directly into the git remote URL, the “just make it work” shortcut from months ago I’d forgotten:

git remote -v
# origin  https://oauth2:glpat-XXXXXXXX@gitlab.lab/me/repo.git   <-- there it is

Check every repo’s remotes; it takes seconds. The other was a stale directory copy of a repo with an API token hardcoded in a config file, and a container was still mounting and reading it. Secrets rot worst in the copies you forgot you made.

Encrypt what automation touches

The config backups were the part I’d already done right, and it’s what I’d tell anyone to do first. My switch and firewall configs get backed up automatically, and a raw Cisco config is a credential goldmine: enable secrets, SNMP strings, user hashes. The backup playbook runs every config through Ansible Vault before it touches the repo:

ansible-vault encrypt backups/switch01.cfg
# and to read one later:
ansible-vault view backups/switch01.cfg

The vault password isn’t committed either. Locally it’s read from a file in my home directory with 600 permissions; on the CI runner it comes from the runner’s encrypted credential store.

Scope the tokens like you scope enable

The old setup used one personal access token for everything, my entire account’s power in one string. Now the CI runner has a project access token that can clone exactly one repo, and the backup job has a separate write token that can push to exactly one repo:

GitLab: Project → Settings → Access Tokens
  runner token:  role Reporter,  scope read_repository
  backup token:  role Developer, scope write_repository

If either leaks, the blast radius is one project instead of my whole account. Same reason you don’t hand every tech the enable password. The token I found in that remote URL got revoked the same hour, git moved to SSH keys, and API access got a fresh token in a permissions-locked file.

Gitignore is a floor, not a ceiling

An ignored file still sits on disk in plaintext, still gets swept up when you copy a directory, still lands in backups. Gitignore keeps secrets out of git. It doesn’t make them safe. Encrypt what you can, chmod 600 what you can’t, and delete the stale copies outright.

None of this needed special tooling. Grep, Ansible Vault, and token scoping GitLab already ships. The expensive part was going and looking in the places where past me took shortcuts, because every one of those shortcuts was invisible until the day the repos were about to leave the house.