Docs › milestone-01b-redaction-and-scan

Milestone 01b — Redaction convention and secrets scan

Goal: make it mechanically hard to commit an identifier that shouldn’t be public. Do this before the first real record or design doc lands in the repo. Estimated time: 30–45 minutes.

Why now: once something is in git history, removing it is a history rewrite, not a delete. Cloudflare Pages also builds from history, and a private repo going public later exposes every past commit at once.


Part 1 — The convention

1.1 Three categories

Every value in the repo falls into one:

Category Examples Rule
Public Model refs, quant types, llama.cpp commits, flags, chat template SHAs, t/s, watts, context sizes, HF repo names Commit freely — these are the point of the site
Redact Hostnames, local IPs, tailnet IPs, MAC addresses, HA entity ids, internal URLs, port numbers on non-standard services Replace with a placeholder, keep the shape
Never Tokens, API keys, webhook URLs, OAuth client ids/secrets, session ids, prompt content, calendar/email/entity names Must not exist in the repo in any form

The middle category is the one people get wrong. A tailnet IP isn’t a secret exactly, but published alongside a service description it’s an invitation.

1.2 Placeholder style

Angle-bracket, lowercase, hyphenated, descriptive of the role not the value:

host: <host-a>                 # not evo-x2.local
endpoint: <ha-base-url>/api/...
webhook: <ha-webhook-url>
token: <ha-long-lived-token>
tailnet: <tailnet-ip>

Consistency matters more than the exact style — it makes the placeholders greppable, and it makes an un-redacted value visually obvious in a diff.

Node ids stay real. evo-x2 as a node id is fine — it’s a label you chose, not a resolvable address. evo-x2.<tailnet>.ts.net is not.

1.3 Documents are the first leak risk

lab-site-design.md and the two research docs contain live values today. If you commit them to docs/ as-is, that is the leak — before any record exists.

Run the scan against them first, redact, then commit.


Part 2 — The scan

2.1 Install

brew install gitleaks

2.2 Custom rules

Generic gitleaks catches AWS keys and GitHub tokens. It will not catch your infrastructure. Create .gitleaks.toml in the repo root:

[extend]
useDefault = true

[[rules]]
id = "nabu-casa-url"
description = "Nabu Casa remote URL"
regex = '''[a-z0-9]{32}\.ui\.nabu\.casa'''
tags = ["infra"]

[[rules]]
id = "ha-webhook"
description = "Home Assistant webhook path"
regex = '''api/webhook/[a-f0-9]{32}'''
tags = ["infra"]

[[rules]]
id = "ha-long-lived-token"
description = "Home Assistant long-lived access token (JWT)"
regex = '''eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}'''
tags = ["token"]

[[rules]]
id = "tailscale-ip"
description = "Tailscale CGNAT address"
regex = '''\b100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\.\d{1,3}\.\d{1,3}\b'''
tags = ["infra"]

[[rules]]
id = "tailnet-hostname"
description = "Tailscale MagicDNS hostname"
regex = '''[a-z0-9-]+\.tail[a-z0-9]+\.ts\.net'''
tags = ["infra"]

[[rules]]
id = "private-ip"
description = "RFC1918 address"
regex = '''\b(10\.\d{1,3}|192\.168|172\.(1[6-9]|2[0-9]|3[01]))\.\d{1,3}\.\d{1,3}\b'''
tags = ["infra"]

[[rules]]
id = "hf-token"
description = "Hugging Face token"
regex = '''hf_[A-Za-z0-9]{34,}'''
tags = ["token"]

[[rules]]
id = "anthropic-key"
description = "Anthropic API key"
regex = '''sk-ant-[A-Za-z0-9_-]{20,}'''
tags = ["token"]

[[rules]]
id = "oauth-client-id"
description = "Google OAuth client id"
regex = '''[0-9]{10,}-[a-z0-9]{20,}\.apps\.googleusercontent\.com'''
tags = ["infra"]

[allowlist]
description = "Expected hex that is not a secret"
regexes = [
  '''chat_template_sha256:\s*[a-f0-9]{8,64}''',
  '''commit:\s*[a-f0-9]{7,40}''',
  '''revision:\s*[a-f0-9]{7,40}''',
]
paths = ['''\.gitleaks\.toml''']

The [allowlist] block matters more than it looks. Your config records are full of hex — template hashes, HF revisions, llama.cpp commits — and without it every legitimate fingerprint trips the scanner. A scanner that cries wolf gets disabled within a week.

2.3 Scan what already exists

gitleaks detect --source . --config .gitleaks.toml --verbose

Run this before adding the design docs, then again after, so you know which findings are new.

For history rather than working tree (relevant if anything already got committed):

gitleaks detect --source . --log-opts="--all"

2.4 Pre-commit hook

Create .githooks/pre-commit:

#!/usr/bin/env bash
set -e
gitleaks protect --staged --config .gitleaks.toml --redact --verbose

Then:

chmod +x .githooks/pre-commit
git config core.hooksPath .githooks

protect --staged scans only what you’re about to commit, so it’s fast enough to run every time. --redact means the secret isn’t echoed into your terminal scrollback when it fires.

Note: core.hooksPath is local config, not committed. If Warden ever commits from a different checkout, that checkout needs the same setting — which is why step 2.5 exists.

2.5 CI backstop

The hook is bypassable with --no-verify and doesn’t exist on a fresh clone. Add .github/workflows/gitleaks.yml:

name: gitleaks
on: [push, pull_request]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: {fetch-depth: 0}
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITLEAKS_CONFIG: .gitleaks.toml

This is the one that matters once Warden has write access — an unattended process committing records needs a check it cannot skip.


Part 3 — Verify it works

Same principle as the schema validation test: prove the mechanism bites before trusting it.

echo "webhook: https://abcdef0123456789abcdef0123456789.ui.nabu.casa/api/webhook/00000000000000000000000000000000" > /tmp/leaktest.md  # gitleaks:allow
cp /tmp/leaktest.md ./leaktest.md
git add leaktest.md
git commit -m "should fail"     # MUST be rejected

If that commit succeeds, the hook isn’t wired up. Fix before continuing.

git reset HEAD leaktest.md && rm leaktest.md

Part 4 — Structural habits

Two things that prevent the problem rather than catching it:

Never let real config reach the repo. Warden’s collectors read HA and the proxy using credentials from environment variables or a keychain — never a file in the working tree, even gitignored. A gitignored secrets file is one git add -f or one wrong .gitignore edit from being committed.

Redact at generation, not at review. When Warden writes a telemetry or energy record, the collector substitutes placeholders as it serialises. If redaction is a review step, an unattended commit at 3am has no reviewer.

The schema helps here: telemetry is counters and histograms by design, with no free-text field for content to hide in. Constrain the schema and the leak has nowhere to live.


Checklist

  • .gitleaks.toml committed, with the allowlist for config hex
  • Existing docs scanned and redacted before commit
  • Pre-commit hook installed and core.hooksPath set
  • CI workflow added
  • Deliberate-leak test rejected the commit
  • Collectors read credentials from env, not files
  • Redaction happens at generation time in Warden’s write path

Status — 2026-08-03 (Fable)

Implemented and verified in headbouyJB/lab-site (commit 3951c64). gitleaks 8.30.1 via Homebrew.

Checklist

  • .gitleaks.toml committed, allowlist included
  • Pre-commit hook installed.githooks/pre-commit, core.hooksPath=.githooks
  • CI backstop added and PASSINGgitleaks-action@v2 ran in 10 s on push (run 30825070609). NB no licence key needed: gitleaks-action is free for personal accounts, licensed only for orgs
  • Deliberate-leak test REJECTED the commit — both ha-webhook and nabu-casa-url fired; --redact kept the value out of scrollback; git log confirmed nothing landed
  • Baseline clean — working tree and full history (3 commits, 153 KB) scanned, no leaks
  • Docs scanned and redactedN/A yet. lab-site-design.md and the research docs are not in this repo (they live in the Claude project). Scan them at the moment they are copied in, not before
  • Collectors read credentials from env — future; no collectors exist yet
  • Redaction at generation time in Warden’s write path — future

Full ruleset validated (not just the doc’s single test)

Tested every custom rule against realistic values. 5/5 fire: tailscale-ip (a real 100.x tailnet address), private-ip (a real 192.168.x LAN address), tailnet-hostname (*.tailXXXX.ts.net), oauth-client-id, hf-token. Allowlist correctly suppresses commit: hashes and chat_template_sha256: — the config hex that would otherwise make the scanner cry wolf.

⚠️ Gotcha worth keeping — obviously-fake test values are silently suppressed

hf-token appeared broken: valid regex, correct length, matched in Python, but gitleaks reported nothing. Cause was the test value, not the rule — the test value was hf_ followed by a sequential alphabet plus digits, and gitleaks 8.30.1 discards that as an obvious dummy via entropy/stopword filtering. With a random 36-char token the rule fires immediately.

⚠️ And the suppression is VERSION-SPECIFIC. The pre-commit hook (local Homebrew gitleaks 8.30.1) passed this document, while CI failed it — gitleaks-action@v2 pins 8.24.3, which flags the same dummy token (entropy 5.29). Local and CI can therefore disagree, which is worse than either being wrong alone: it teaches people to distrust the scanner. Keep literal token-shaped strings out of prose entirely.

⚑ Rule corrected 2026-08-03 (see §Version pinning below): keep both halves on the same pinned version. The earlier rule — “treat CI as authoritative” — was wrong, because the asymmetry is not reliably in the safe direction. Here the stricter half happened to be CI, so deferring to it was safe by luck. Nothing guarantees that: a future release could relax a rule, leaving the hook stricter and CI waving through what the hook caught. Agreement matters more than which half wins.

Lesson: when verifying a secret scanner, use realistic random values. A patterned placeholder can make a working rule look dead — or worse, make a dead rule look tested.

Known limitation (the doc flags it; restating because it matters)

core.hooksPath is local git config and is not committed. A fresh clone — or Warden committing from a different checkout — has no hook. The CI workflow is the only control that survives that, and the only one --no-verify cannot bypass. Treat the hook as convenience and CI as the actual guarantee.


Version pinning — 2026-08-03

Problem: gitleaks-action@v2 pinned its own binary (8.24.3) while the hook used whatever Homebrew supplied (8.30.1). Suppression heuristics differ between releases, so the two halves of the control disagreed — silently, and in a direction nothing guarantees.

Fix: .gitleaks-version is now the single source of truth, containing 8.30.1 and nothing else.

  • CI (.github/workflows/gitleaks.yml) — the action is gone. The workflow reads .gitleaks-version, downloads that exact release, installs it, and asserts the installed version matches before scanning. A mismatch fails the job with an explicit error rather than scanning with the wrong binary.
  • Hook (.githooks/pre-commit) — refuses to run if the local binary differs from the pinned version, naming both. A local brew upgrade gitleaks therefore surfaces immediately as a blocked commit, not as a silent behavioural fork.
  • Bumping is a deliberate one-line diff to .gitleaks-version, reviewable in the same commit as whatever else the upgrade changes.

CI also now scans full history (--log-opts="--all") rather than the action’s default of the latest commit only.

Parity verified in both directions

Test file with two cases, run through the local hook and CI on the same commit (temporary branch test/gitleaks-parity, since deleted — main history stays clean, confirmed by a full-history scan of all 8 commits):

Case Expected Local (8.30.1) CI (8.30.1)
A — Nabu Casa URL + HA webhook path detected nabu-casa-url, ha-webhook nabu-casa-url, ha-webhook
B — dummy hf_ token that previously diverged suppressed by both not flagged not flagged

2 findings local, 2 findings CI, identical rule ids. The case that previously split the two halves now agrees. The hook also correctly blocked the commit, and reaching CI required an explicit --no-verify — which is exactly the bypass CI exists to cover.