Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,12 @@ jobs:
# removed what that file used to acknowledge, and deleting it is how a
# future finding stays visible instead of being masked by a stale entry.
#
# Two configurations, because a path allowlist applies to every commit a
# history scan walks. The working tree reads .gitleaks-worktree.toml,
# which allowlists the decrypted and generated paths a deployed host
# holds; the history scan reads .gitleaks.toml, which allowlists none of
# them, so a purged key that reappears under certificates/ is a finding.
#
# Both run with --network none, as lint.sh's linters do: gitleaks reads
# the checkout and a local `git log`, and on a fork PR that checkout is
# the author's. For the same reason the checkout is mounted read-only and
Expand All @@ -677,7 +683,7 @@ jobs:
docker run --rm --network none --user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "$PWD:/repo:ro" -w /repo \
"$GITLEAKS_IMAGE" \
detect --no-git --no-banner --redact -c .gitleaks.toml -v
detect --no-git --no-banner --redact -c .gitleaks-worktree.toml -v

# Through the script validate.sh also calls. gitleaks exits 0 when git
# cannot read the repository, so the script also fails a scan of zero
Expand Down
57 changes: 57 additions & 0 deletions .gitleaks-worktree.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
title = "HomeLab gitleaks configuration — working-tree scans"

# The same rules as .gitleaks.toml, with the paths that hold real secrets on a
# deployed host allowlisted. This file is read by the working-tree scans only:
# `make scan`'s first line, scripts/validate.sh and the workflow's
# "gitleaks — working tree" step. The full-history scan reads .gitleaks.toml
# directly, where none of these paths is allowlisted, because a path allowlist
# applies to every commit a history scan walks: listed there, a purged key that
# reappeared under certificates/ would never be reported, and the history scan
# would stop being the evidence docs/security.md says it is.
#
# Everything below is gitignored and exists only on a host that has actually
# deployed. CI scans a fresh checkout and never sees them; the filesystem scan
# on the monitoring host does, and reported eight findings that are all
# working as designed.
#
# Allowlisting them here is not a weakening. gitleaks was never the control on
# them: the control is that none may ever become a tracked file, which CI and
# scripts/validate.sh both assert directly through
# scripts/check-tracked-artefacts.sh. A `make validate` that is permanently red
# for a known reason is a `make validate` nobody reads.

[extend]
path = ".gitleaks.toml"

[allowlist]
description = "Decrypted and generated files that exist only on a deployed host"

paths = [
# Decrypted at deploy time by scripts/render-config.sh.
'''\.rendered/''',
# The lab's own CA and leaf keys, created by scripts/gen-certs.sh.
'''certificates/''',
# The stack's .env, holding the real GRAFANA_ADMIN_PASSWORD. It was missed
# for a while because nothing ever ran the filesystem scan on a deployed host:
# validate.sh asked for a `gitleaks` binary that was not installed and
# skipped, and CI's copy of this file is the throwaway from
# scripts/seed-validation-env.sh, whose values match the `validation-only`
# allowlist regex in .gitleaks.toml. So the one machine that had a real .env
# was the one machine not scanning it (#68).
#
# `\.env$` does not match .env.example, which is committed and carries only
# change-me values.
'''(^|/)\.env$''',
# Third-party Ansible collections, downloaded and gitignored (ansible/.gitignore).
# `ansible-lint --project-dir ansible` in scripts/lint.sh installs
# ansible/requirements.yml into ansible/.ansible/ on every run, and
# ansible.cfg's collections_path is ansible/.collections/. ansible.windows
# ships test fixtures — PEM keys, a .pfx, passwords in task files — so
# `make validate` failed its working-tree scan on nine of them, on a clean
# main, every time lint had run first (2026-10-07). None of it is ours or
# secret. What keeps it out of the repository is
# scripts/check-tracked-artefacts.sh, which names both paths, `git add -f`
# included, and runs in CI and in validate.sh.
'''(^|/)ansible/\.ansible/''',
'''(^|/)ansible/\.collections/''',
]
51 changes: 12 additions & 39 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,45 +65,18 @@ paths = [
'''secrets/.*\.sops\.yaml$''',
# Documents required key names and deliberately carries change-me values.
'''secrets/.*\.example\.yaml$''',
# The next three hold real secrets on purpose, and are gitignored. They exist
# only on a host that has actually deployed, so CI — which scans a fresh
# checkout — never sees them; the filesystem scan on the monitoring host does,
# and reported eight findings that are all working as designed.
#
# Allowlisting them is not a weakening. gitleaks was never the control here:
# the control is that none may ever become a tracked file, which CI and
# scripts/validate.sh both assert directly. A `make validate` that is
# permanently red for a known reason is a `make validate` nobody reads.
#
# Decrypted at deploy time by scripts/render-config.sh.
'''\.rendered/''',
# The lab's own CA and leaf keys, created by scripts/gen-certs.sh.
'''certificates/''',
# The stack's .env, holding the real GRAFANA_ADMIN_PASSWORD. Same argument as
# the two above, and it belonged here all along — it was missed because
# nothing ever ran the filesystem scan on a deployed host. validate.sh asked
# for a `gitleaks` binary that was not installed and skipped, and CI's copy of
# this file is the throwaway from scripts/seed-validation-env.sh, whose
# values match the `validation-only` allowlist regex below. So the one machine
# that had a real .env was the one machine not scanning it (#68).
#
# `\.env$` does not match .env.example, which is committed and carries only
# change-me values.
'''(^|/)\.env$''',
# Third-party Ansible collections, downloaded and gitignored (ansible/.gitignore).
# `ansible-lint --project-dir ansible` in scripts/lint.sh installs
# ansible/requirements.yml into ansible/.ansible/ on every run, and
# ansible.cfg's collections_path is ansible/.collections/. ansible.windows
# ships test fixtures — PEM keys, a .pfx, passwords in task files — so
# `make validate` failed its working-tree scan on nine of them, on a clean
# main, every time lint had run first (2026-10-07). None of it is ours or
# secret. What keeps it out of the repository is
# scripts/check-tracked-artefacts.sh, which names both paths, `git add -f`
# included, and runs in CI and in validate.sh. An allowlisted path is
# invisible to the history scan as well, so that check is the only control
# on it, not a second one.
'''(^|/)ansible/\.ansible/''',
'''(^|/)ansible/\.collections/''',
# Nothing gitignored is allowlisted HERE. This file is what the full-history
# scan reads (scripts/gitleaks-history.sh), and a path allowlist applies to
# every commit a history scan walks, not only to the working tree: with
# `certificates/` listed here, the purged CA key of SECURITY.md would never
# be reported if it reappeared, and docs/security.md's "a history scan that
# passes is the evidence the purge worked" would be true of a scan that
# could not see it. The paths that hold real secrets on a deployed host —
# .rendered/, certificates/, .env, the installed Ansible collections — are
# allowlisted in .gitleaks-worktree.toml, which extends this file and is
# what the working-tree scans read. None of them may ever be a tracked
# file, which scripts/check-tracked-artefacts.sh asserts; so in history they
# are findings, and in the working tree they are the deploy.
]

regexes = [
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,7 @@ scan-images: ## Scan every pinned image for fixable HIGH/CRITICAL CVEs (reports

.PHONY: scan
scan: ## Scan the working tree and history for secrets
gitleaks detect --no-banner --redact -c .gitleaks.toml
gitleaks detect --no-banner --redact -c .gitleaks-worktree.toml
gitleaks detect --no-banner --redact -c .gitleaks.toml --log-opts="--all"

# ---------------------------------------------------------------------------
Expand Down
8 changes: 7 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,13 @@ with what it was and why it was still there. It was an acknowledgement, not a
fix, and it existed because a CI job that is permanently red for a known reason
gets ignored — and then a genuinely new leak goes unnoticed alongside it. The
purge removed what it acknowledged, so the file was deleted. A history scan
that passes with no exceptions is the evidence the purge worked.
that passes with no exceptions is the evidence the purge worked — and since
2026-10-14 the history scan reads a configuration with no path allowlist for
anything gitignored. The working-tree scans read `.gitleaks-worktree.toml`,
which extends it with `.rendered/`, `certificates/` and the rest; those were
allowlisted in the one shared file before, and a path allowlist applies to
every commit a history scan walks, so the purged key under `certificates/`
was exactly the path that scan could not see.

### Everything above sits on an unencrypted disk

Expand Down
113 changes: 104 additions & 9 deletions scripts/check-sops-encrypted.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,115 @@
# has to be purged from history rather than reverted, so the check is worth
# far more before the push than after it — see docs/runbooks/purge-git-history.md.
#
# The test is the `sops:` metadata block, which sops appends to every file it
# encrypts and which cannot survive the file being written back in the clear.
# Not "does it contain something that looks like ciphertext": a partially
# decrypted file is still a leak, and the block is absent for that too.
# Two tests, both required. The `sops:` metadata block, which sops appends to
# every file it encrypts and which `sops --decrypt` writing over the source
# removes; and, above that block, every value being ciphertext — `ENC[...]`,
# which is what sops writes for a scalar and a list item alike. The block alone
# was the test until 2026-10-14, and it is necessary rather than sufficient: a
# value pasted back in the clear leaves the block in place, so the file passed
# here, passed gitleaks (secrets/*.sops.yaml is path-allowlisted there, because
# ciphertext trips the entropy rules), and `make render` would have decrypted
# the rest and copied the plaintext through. sops itself refuses such a file
# on its MAC, but only on a host that holds the key, which the push does not.
#
# Usage:
# scripts/check-sops-encrypted.sh one line per file, exit 1 on any failure
# scripts/check-sops-encrypted.sh --self-test fixtures, in a temporary directory
#
# No arguments and no options. Both callers — scripts/validate.sh and the
# workflow — want exactly this, and a --quiet nobody asked for is a flag that
# can be passed by accident in the one place the output matters.
# No other arguments and no options. Both callers — scripts/validate.sh and
# the workflow — want exactly this, and a --quiet nobody asked for is a flag
# that can be passed by accident in the one place the output matters.

set -euo pipefail

# Why one file fails, or nothing. Lines before the `sops:` block that carry
# a value — `key: value`, `- item` or `- key: value` — must carry `ENC[`. A
# key that opens a mapping has no value and is not a line this reads, nor is a
# bare `-`; comments and blank lines are skipped. A quoted ENC[ is tolerated:
# sops does not quote ciphertext, so that is a hand edit holding no plaintext.
# POSIX awk only — the hosts run mawk, and a gawk-only form would fail the read
# rather than the file, which is why a failed read is itself a failure below.
plaintext_reason() { # <file> prints a reason and returns 1, or returns 0
local f="$1" line
if ! grep -q '^sops:' "${f}"; then
printf 'no sops: metadata block'
return 1
fi
# The quote characters arrive in a variable: a single quote cannot appear
# inside a single-quoted awk program, and \x27 is not POSIX.
if ! line="$(awk -v q="'\"" '
BEGIN { key = "^[^[:space:]" q "][^:]*:([[:space:]]|$)"; quote = "^[" q "]" }
/^sops:/ { exit }
/^[[:space:]]*(#|$)/ { next }
{
v = $0
sub(/^[[:space:]]+/, "", v)
if (v ~ /^-([[:space:]]+|$)/) sub(/^-[[:space:]]*/, "", v)
if (v == "") next
if (v ~ key) {
if (v ~ /^[^:]*:[[:space:]]*$/) next
sub(/^[^:]*:[[:space:]]+/, "", v)
}
sub(quote, "", v)
if (v !~ /^ENC\[/) { print NR ": " $0; exit }
}' "${f}")"; then
printf 'the awk read failed'
return 1
fi
if [[ -n "${line}" ]]; then
printf 'plaintext value at line %s' "${line}"
return 1
fi
return 0
}

if [[ "${1:-}" == "--self-test" ]] && (($# == 1)); then
T="$(mktemp -d)"
trap 'rm -rf "${T}"' EXIT
fail=0
ok() { printf '\033[0;32m PASS\033[0m %s\n' "$*"; }
bad() { printf '\033[0;31m FAIL\033[0m %s\n' "$*"; fail=1; }
SOPS_BLOCK=$'sops:\n age:\n - recipient: age1example\n lastmodified: "2026-10-14T00:00:00Z"\n version: 3.13.3\n'
case_() { # <want rc> <what> <file body, in one or more parts>
local f="${T}/case.sops.yaml" rc=0
printf '%s' "${@:3}" > "${f}"
plaintext_reason "${f}" >/dev/null || rc=$?
if ((rc == $1)); then ok "$2"; else bad "$2 (got ${rc}, wanted $1)"; fi
}
case_ 0 "scalars, a nested map, a list item and a comment, all ENC[, pass" \
$'# a comment\nGRAFANA_ADMIN_PASSWORD: ENC[AES256_GCM,data:abc,iv:def,tag:ghi,type:str]\nsnmp:\n community: ENC[AES256_GCM,data:abc,iv:def,tag:ghi,type:str]\n hosts:\n - ENC[AES256_GCM,data:abc,iv:def,tag:ghi,type:str]\n'"${SOPS_BLOCK}"
case_ 1 "no sops: block at all fails" \
$'GRAFANA_ADMIN_PASSWORD: ENC[AES256_GCM,data:abc,iv:def,tag:ghi,type:str]\n'
case_ 1 "a scalar pasted back in the clear, block intact, fails" \
$'GRAFANA_ADMIN_PASSWORD: hunter2\nOTHER: ENC[AES256_GCM,data:abc,iv:def,tag:ghi,type:str]\n'"${SOPS_BLOCK}"
case_ 1 "a nested scalar in the clear fails" \
$'snmp:\n community: public\n'"${SOPS_BLOCK}"
case_ 1 "a list item in the clear fails" \
$'hosts:\n - 10.0.99.1\n'"${SOPS_BLOCK}"
case_ 0 "a quoted ENC[ value is not plaintext" \
$'KEY: "ENC[AES256_GCM,data:abc,iv:def,tag:ghi,type:str]"\n'"${SOPS_BLOCK}"
case_ 0 "values inside the sops: block are not read" \
$'KEY: ENC[AES256_GCM,data:abc,iv:def,tag:ghi,type:str]\n'"${SOPS_BLOCK}"
case_ 0 "a list of maps with ENC[ values, and a bare - opening one, pass" \
$'peers:\n - name: ENC[AES256_GCM,data:abc,iv:def,tag:ghi,type:str]\n' \
$' key: ENC[AES256_GCM,data:abc,iv:def,tag:ghi,type:str]\n -\n' \
$' name: ENC[AES256_GCM,data:abc,iv:def,tag:ghi,type:str]\n'"${SOPS_BLOCK}"
case_ 1 "a map inside a list with a value in the clear fails" \
$'peers:\n - name: ENC[AES256_GCM,data:abc,iv:def,tag:ghi,type:str]\n' \
$' key: wg-private-key-in-the-clear\n'"${SOPS_BLOCK}"
# The fixture is not shaped like a key on purpose: the working-tree scan
# reads this file too, and found the first draft's PEM header (#1187).
case_ 1 "a block scalar, which sops never writes, fails" \
$'KEY: |\n several lines of something\n pasted in the clear\n'"${SOPS_BLOCK}"
case_ 1 "an empty file fails" ""
exit "${fail}"
fi

if (($# > 0)); then
printf 'usage: %s [--self-test]\n' "$0" >&2
exit 2
fi

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${REPO_ROOT}"

Expand All @@ -50,10 +145,10 @@ fi

plaintext=()
for f in "${FILES[@]}"; do
if grep -q '^sops:' "$f"; then
if reason="$(plaintext_reason "$f")"; then
printf ' encrypted %s\n' "$f"
else
printf ' PLAINTEXT %s\n' "$f" >&2
printf ' PLAINTEXT %s — %s\n' "$f" "${reason}" >&2
plaintext+=("$f")
fi
done
Expand Down
12 changes: 7 additions & 5 deletions scripts/check-tracked-artefacts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@
# because `\.terraform/` needs the slash.
#
# ansible/.ansible/ and ansible/.collections/ are on the list because
# .gitleaks.toml allowlists them. They hold third-party collections that
# ansible-lint and ansible-galaxy download, and ansible.windows ships PEM keys
# and passwords as test fixtures. An allowlisted path is invisible to BOTH
# gitleaks scans, working tree and history, so being untracked is the only
# control left. This is what asserts it, against `git add -f` too (#968).
# .gitleaks-worktree.toml allowlists them. They hold third-party collections
# that ansible-lint and ansible-galaxy download, and ansible.windows ships PEM
# keys and passwords as test fixtures. An allowlisted path is invisible to the
# working-tree scan, so being untracked is what keeps them out of the history
# scan's reach too — and that scan reads .gitleaks.toml, which allowlists none
# of them, so a tracked copy IS a finding there. This is what asserts the
# untracked half, against `git add -f` too (#968).
#
# Usage:
# scripts/check-tracked-artefacts.sh names every offender, exit 1 if any
Expand Down
4 changes: 2 additions & 2 deletions scripts/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -777,10 +777,10 @@ else
fi

if ((${#GITLEAKS[@]})); then
if "${GITLEAKS[@]}" detect --no-git --no-banner --redact -c .gitleaks.toml >/dev/null 2>&1; then
if "${GITLEAKS[@]}" detect --no-git --no-banner --redact -c .gitleaks-worktree.toml >/dev/null 2>&1; then
pass "gitleaks (working tree)"
else
"${GITLEAKS[@]}" detect --no-git --no-banner --redact -c .gitleaks.toml
"${GITLEAKS[@]}" detect --no-git --no-banner --redact -c .gitleaks-worktree.toml
fail "gitleaks (working tree)"
fi

Expand Down
Loading