The uncomfortable part, and the reason this post belongs to this series: one of my three layers used to be a decoration.
Three moments, not three tools#
The first layer stops a leak before it ever enters history, which is the only place a leak is still cheap. It runs in the same pre-commit hook that the first post in this series brought back from the dead, and costs about 50ms:
# Staged-diff secret scan (GitHub push protection is the server-side
# backstop; this stops leaks before they enter history at all).
# NOTE: AWS's documentation example keys are allowlisted by gitleaks'
# default config — verified detection with realistic-looking canaries.
if command -v gitleaks >/dev/null 2>&1; then
if ! GIT_DIR="$(yadm introspect repo)" GIT_WORK_TREE="$HOME" \
gitleaks git --pre-commit --staged --no-banner --redact \
"$HOME"; then
exit 1
fi
fiThe second layer is GitHub’s push protection, which I do not have to maintain and cannot forget to run. The third is the one with the embarrassing past.
The scanner that could not fail#
The old TruffleHog step in CI used a base..head diff mode. On a direct push to master, base equals head, so the scan range was empty and the step passed by definition. It could not find anything because it was not looking at anything.
Better still, the step wore continue-on-error, added at some point because it kept failing for unrelated reasons. So the pipeline contained a secret scanner that scanned nothing and whose verdict was ignored anyway. Two independent ways to be decorative, stacked.
The current version scans the full history and blocks:
# BLOCKING secret scan over the FULL git history (not a base..head
# diff — the old diff mode failed with base==head on master pushes,
# which is why it was continue-on-error, i.e. decorative).
# --results=verified: only credentials that TruffleHog actively
# confirmed against the provider fail the build (no regex noise).
- name: Check for secrets (blocking)
uses: trufflesecurity/trufflehog@6f3c981e7b77f235fd2702dd74af25fc4b72bf11
with:
path: ./
extra_args: --results=verifiedThe --results=verified flag deserves its sentence. A blocking step that cries wolf on regex noise gets demoted back to advisory within a month, by the same social process that produced continue-on-error last time. Only credentials that TruffleHog actually confirmed against the provider fail the build, so a red result means something real, so the red stays blocking.
The workflows are attack surface too#
A secret scanner running inside a compromised workflow is not much of a defense, so the pipeline got the same treatment as the content:
- Every GitHub Action is pinned to a commit SHA. The
tj-actions/changed-filescompromise made the argument better than I can: a mutable tag means executing whatever upstream’s HEAD is at run time. - Dependabot keeps the pins current, and proved it was alive by filing its first PR within seconds of the merge.
zizmorandactionlintlint the workflows themselves. Every checkout setspersist-credentials: false.GITHUB_TOKENgets least privilege,contents: read.
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: falseIdentity: no private key on disk#
The strongest way to not leak a private key from your dotfiles is for the key to not be a file. GitHub auth goes over SSH through the 1Password agent, so no private key exists on disk. The same key signs commits via op-ssh-sign. The public halves live in a tracked allowed_signers; the signing configuration stays in the untracked ~/.gitconfig.
Git Credential Manager got retired in the process, and it left a landmine: a stale osxkeychain cached credential that makes HTTPS remotes fail with “Invalid username or token”. The fix is not to repair the credential. The fix is to stop using the road:
# "Invalid username or token" does not mean a broken credential;
# it means the HTTPS route itself is retired
yadm remote set-url origin [email protected]:<owner>/<repo>.gitThe three pits that actually cost time#
A signing key is not an authentication key. GitHub keeps those as two separate lists. Add your 1Password key as a signing key only, and commits sign beautifully while every push fails. The error does not hint at this.
IdentitiesOnly yes mutes the agent. My global SSH defaults set it, which makes ssh ignore any key the agent offers. Hosts that authenticate through 1Password need it explicitly off:
grep -A2 "^Host github.com" ~/.ssh/config.d/10-github-1password.conf
# this host needs IdentitiesOnly no, or ssh never sees the agent's keyThe example key that proves nothing. I wanted a negative test for the gitleaks layer, so the obvious move is planting AKIAIOSFODNN7EXAMPLE and watching it get caught. Except gitleaks’ default config allowlists AWS’s documentation example keys, so that test passes vacuously. The canary has to look real, and it has to be assembled at runtime so the repo never contains a secret-shaped literal:
printf 'aws_key = "%s%s"\n' "AKIA" "ZZZQK9X2M4P7L3TQ" \
> "$GL_TMP/leak.txt"Testing your defenses without tripping them yourself takes more thought than building them.
Why three layers#
The number itself does nothing. What matters is that each layer has an independent failure mode, and they fail at different times. The hook can be bypassed with --no-verify in a hurry. Push protection only sees patterns GitHub knows. The CI sweep runs after the fact. Any single layer being wrong leaves two others standing at different points on the timeline, and the previous two posts in this series are extended demonstrations of how quietly a single layer can be wrong.
Lessons#
- Layered defense means layered in time: before the commit, at the push, over the history. Three tools at the same moment is one layer with extra steps.
- A scan step that cannot fail is not security. It is a green light bulb.
- Make blocking checks precise enough to stay blocking. Noise is how gates get demoted to advisory.
- Verify each defense by attacking it with something realistic. The obvious test credential is exactly the one your scanner was configured to ignore.
- Pin your actions to SHAs. A mutable tag is someone else’s deploy button pointed at your CI.
References#
- gitleaks (staged-diff scanning; the default-config allowlist of AWS documentation keys is in its bundled config)
- TruffleHog (
--results=verifiedbehavior) - GitHub push protection
- tj-actions/changed-files compromise, CVE-2025-30066 (the case for SHA-pinning actions)
- zizmor and actionlint (workflow linting)
- 1Password SSH agent and commit signing
- The repo: nickboy/dotfiles
