Skip to main content
  1. Posts/

Three layers of secret defense for a public dotfiles repo. One was decorative.

··1190 words·6 mins·
Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
Hardening My Dotfiles - This article is part of a series.
Part 3: This Article
My dotfiles repo is public, which means any slip with a credential is permanent. History rewrites do not un-leak a key that a scraper already saw. So the defense cannot be one layer, and the interesting part of layering is not the count of tools. It is that each layer intercepts at a different moment: one before the commit exists, one at the moment of push, one sweeping the entire history in CI.

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
#

commit-time
gitleaks scans the staged diff
inside the yadm pre-commit hook
(~50ms)

push-time
GitHub push protection,
server-side, at the moment
of push

CI-time
TruffleHog sweeps the FULL
git history, blocking

plus: repo-level secret
scanning enabled

commit-time
gitleaks scans the staged diff
inside the yadm pre-commit hook
(~50ms)

push-time
GitHub push protection,
server-side, at the moment
of push

CI-time
TruffleHog sweeps the FULL
git history, blocking

plus: repo-level secret
scanning enabled

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
fi

The 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=verified

The --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-files compromise 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.
  • zizmor and actionlint lint the workflows themselves. Every checkout sets persist-credentials: false.
  • GITHUB_TOKEN gets least privilege, contents: read.
permissions:
  contents: read

steps:
  - name: Checkout repository
    uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
    with:
      persist-credentials: false

Identity: 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>.git

The 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 key

The 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
#

Hardening My Dotfiles - This article is part of a series.
Part 3: This Article

Related

My dotfiles had a no-exceptions test gate. It had never run once.

··1331 words·7 mins
My dotfiles repo has a CLAUDE.md, and the CLAUDE.md has a rule in bold: every commit must pass the test suite, no exceptions. Within the first hour of an audit this July, I learned that this rule had been enforced exactly zero times since the day it was written. The hook file existed, its contents were correct, it even had its executable bit. It was just sitting at a path that yadm stopped reading a major version ago. No error message. No warning. To yadm, a hook in the wrong place and no hook at all are the same thing.

My blog publishes one post a day. I haven't touched the deploy in weeks.

··1104 words·6 mins
In July I went three weeks without opening my blog repo. During those weeks it published two posts, on schedule, each one confirmed live by an automated check, and the only reason I know all this is a green history in the Actions tab. The system's single notification channel is a failure email, and it has never fired. This post is the full pipeline, including the parts that only exist because something went wrong on the way here. 🧪 Tested with Hugo 0.164.0 · Cloudflare Pages The one Hugo fact everything hangs on # Hugo skips content dated in the future unless you pass --buildFuture (docs). That single default turns the date field into a release valve. Merge a post dated next Tuesday and production simply does not contain it: not in the sitemap, not in RSS, not at its URL. It sits in main, invisible, until a build happens after its date.

A typo fix shouldn't boot a browser in CI. Mine did for months.

··1184 words·6 mins
I changed one sentence in a blog post, opened a pull request, and watched CI spend about 85 seconds installing a headless Chromium to confirm my prose still turned into HTML. The obvious fix, telling the workflow to ignore content paths, would have quietly broken every merge instead. Symptom: a browser for a one-line edit # Every pull request on this blog runs two jobs: a lint job, and a build-and-test job. The second one builds the site with Hugo, link-checks the output with htmltest, then installs a headless Chromium and runs a Playwright suite against a live hugo server. End to end, roughly 85 seconds, and almost all of it is Playwright.