From e2dcb6944dfed69609bd019c8b8e74946b0c5141 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 05:14:35 +0000 Subject: [PATCH 1/2] fix(scan): let the history scan see every path, and make the SOPS check read the values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two checks that claimed more than they proved. gitleaks path allowlists apply to every commit a history scan walks, not only to the working tree. .gitleaks.toml allowlisted certificates/, .rendered/, .env and the installed Ansible collections so a deployed host's `make validate` stays green, and the same file fed the history scan — so the purged CA key under certificates/ was exactly the path the scan docs/security.md calls the evidence of the purge could never report. The gitignored paths move to .gitleaks-worktree.toml, which extends .gitleaks.toml and is what the working-tree scans read; the history scan keeps .gitleaks.toml, now with no allowlist for anything gitignored. scripts/check-tracked-artefacts.sh is still what keeps those paths out of the tree, and in history a tracked copy is now a finding as well. scripts/check-sops-encrypted.sh passed any file carrying a `sops:` block. A value pasted back in the clear leaves the block in place, passes gitleaks (the ciphertext is path-allowlisted), and `make render` would copy it through. It now also requires every value above the block to be ENC[...], in POSIX awk because the hosts run mawk, with a failed read counted as a failure and eleven fixtures under --self-test, which scripts/self-tests.sh discovers. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Dchg9vmfKeQd7uzRzPcLR --- .github/workflows/ci.yml | 8 +- .gitleaks-worktree.toml | 57 +++++++++++++++ .gitleaks.toml | 51 +++---------- Makefile | 2 +- docs/security.md | 8 +- scripts/check-sops-encrypted.sh | 113 ++++++++++++++++++++++++++--- scripts/check-tracked-artefacts.sh | 12 +-- scripts/validate.sh | 4 +- 8 files changed, 197 insertions(+), 58 deletions(-) create mode 100644 .gitleaks-worktree.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8811643a..6a6b00dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.gitleaks-worktree.toml b/.gitleaks-worktree.toml new file mode 100644 index 00000000..a88946bb --- /dev/null +++ b/.gitleaks-worktree.toml @@ -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/''', +] diff --git a/.gitleaks.toml b/.gitleaks.toml index 5cf85eab..1b794121 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -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 = [ diff --git a/Makefile b/Makefile index 476e78ba..96c99f4d 100644 --- a/Makefile +++ b/Makefile @@ -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" # --------------------------------------------------------------------------- diff --git a/docs/security.md b/docs/security.md index 255d15da..ddc12037 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 diff --git a/scripts/check-sops-encrypted.sh b/scripts/check-sops-encrypted.sh index 79fef939..4eba8b0e 100755 --- a/scripts/check-sops-encrypted.sh +++ b/scripts/check-sops-encrypted.sh @@ -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() { # 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" ]]; 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_() { # + 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}" @@ -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 diff --git a/scripts/check-tracked-artefacts.sh b/scripts/check-tracked-artefacts.sh index 95fd8924..016709a9 100755 --- a/scripts/check-tracked-artefacts.sh +++ b/scripts/check-tracked-artefacts.sh @@ -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 diff --git a/scripts/validate.sh b/scripts/validate.sh index a003970f..26928fc1 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -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 From 9033becf304cc342342beba1920c945fabf39d61 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 05:20:45 +0000 Subject: [PATCH 2/2] fix(check-sops-encrypted): --self-test takes no trailing argument either `--self-test bogus` ran the fixtures and exited 0; the usage guard below only saw an argument that was not --self-test. Review finding on #1187. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Dchg9vmfKeQd7uzRzPcLR --- scripts/check-sops-encrypted.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-sops-encrypted.sh b/scripts/check-sops-encrypted.sh index 4eba8b0e..ceb0be27 100755 --- a/scripts/check-sops-encrypted.sh +++ b/scripts/check-sops-encrypted.sh @@ -80,7 +80,7 @@ plaintext_reason() { # prints a reason and returns 1, or returns 0 return 0 } -if [[ "${1:-}" == "--self-test" ]]; then +if [[ "${1:-}" == "--self-test" ]] && (($# == 1)); then T="$(mktemp -d)" trap 'rm -rf "${T}"' EXIT fail=0