From ad3fa6e1060057fd691daad9d13f77e7453a2b8c Mon Sep 17 00:00:00 2001 From: Anil Belur Date: Sun, 12 Jul 2026 18:53:40 +1000 Subject: [PATCH] fix: enforce never-commit secrets on migrated repos, not just fresh ones The exclusion guarantee previously only reached repos the addon bootstrapped: seed_gitignore writes its list only when no .gitignore exists, so a repo migrated in with its own .gitignore lacking secret entries could leak an OAuth token (.google.token), *.key/*.pem, etc. Generalize enforcement independent of .gitignore: - write_secret_excludes(): writes the never-commit patterns (secrets, credentials, .storage/, addon runtime files) to .git/info/exclude on every run, before the stash, so untracked secrets are never swept in. - untrack_secrets(): runs after the upstream sync (a staged deletion before the stash would be lost in stash/pop) and untracks any secret a prior run already committed, so the removal is captured as drift and goes out through a reviewable PR. Extends the migration self-check to assert an untracked secret is kept out AND an already-committed key gets untracked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Anil Belur --- gitops_backup/CHANGELOG.md | 9 +++ gitops_backup/config.json | 2 +- gitops_backup/rootfs/app/gitops_backup.sh | 74 +++++++++++++++-------- tests/self_check.sh | 16 +++-- 4 files changed, 72 insertions(+), 29 deletions(-) diff --git a/gitops_backup/CHANGELOG.md b/gitops_backup/CHANGELOG.md index c647cf5..45912d1 100644 --- a/gitops_backup/CHANGELOG.md +++ b/gitops_backup/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.2.1 + +- Security: the never-commit guarantee (secrets, credentials, `.storage/`, + addon runtime files) now holds for **migrated** repos too, not just fresh + ones. Patterns are enforced via `.git/info/exclude` on every run, and any + secret a prior run already committed is untracked (the removal flows out + through the next backup PR). Previously a repo that arrived with its own + `.gitignore` lacking these entries could leak an OAuth token or key. + ## 0.2.0 - Docs: make the two-pillar backup model explicit — config repo (this add-on) diff --git a/gitops_backup/config.json b/gitops_backup/config.json index b30e372..fbf898e 100644 --- a/gitops_backup/config.json +++ b/gitops_backup/config.json @@ -1,6 +1,6 @@ { "name": "HA GitOps", - "version": "0.2.0", + "version": "0.2.1", "slug": "gitops_backup", "description": "PR-gated GitHub backup for your Home Assistant config \u2014 nothing force-pushes, every change is reviewable, CI validates before merge", "url": "https://github.com/askb/ha-gitops", diff --git a/gitops_backup/rootfs/app/gitops_backup.sh b/gitops_backup/rootfs/app/gitops_backup.sh index b8419ca..e03a557 100755 --- a/gitops_backup/rootfs/app/gitops_backup.sh +++ b/gitops_backup/rootfs/app/gitops_backup.sh @@ -97,34 +97,56 @@ EOF fi } -ensure_own_excludes() { - # The addon's own runtime files must never be committed — not even when the - # repo already had a .gitignore (seed_gitignore only writes one when none - # exists, so migrating an existing repo would otherwise sweep these into a - # backup PR and risk a status-file PR feedback loop). .git/info/exclude is - # git's local, non-committed ignore — the right home for addon-managed - # metadata. Idempotent: replace our managed block each run. +never_commit_patterns() { + # Secrets, credentials, and the addon's own runtime metadata — must never be + # committed. Shared by the exclude writer and the untracker. + cat <<'EOF' +secrets.yaml +*.key +*.pem +*.token +.google.token +.git-credentials +.cloud/ +.storage/ +.gitops_backup_status +gitops_backup.log +EOF +} + +write_secret_excludes() { + # Write the never-commit patterns to .git/info/exclude (git's local, + # un-shared ignore) so secrets/credentials + addon metadata stay out of + # backups even when migrating a repo that already has its own .gitignore + # (seed_gitignore only writes one for a fresh repo). Called before the stash + # so untracked secrets are never swept in. Idempotent: replace our block. [ -d .git ] || return 0 mkdir -p .git/info local exclude=".git/info/exclude" if [ -f "$exclude" ]; then -sed -i \ - '/# >>> gitops-backup managed >>>/,/# <<< gitops-backup managed <<>> gitops-backup managed >>>/,/# <<< gitops-backup managed <<> "$exclude" <<'EOF' -# >>> gitops-backup managed >>> -.gitops_backup_status -gitops_backup.log -# <<< gitops-backup managed <<< -EOF - # If a prior buggy run already committed these, stop tracking them (the - # removal flows out through the next backup PR). - local f - for f in .gitops_backup_status gitops_backup.log; do -if git ls-files --error-unmatch "$f" >/dev/null 2>&1; then - git rm -q --cached "$f" -fi + { + echo "# >>> gitops-backup managed >>>" + never_commit_patterns + echo "# <<< gitops-backup managed <<<" + } >> "$exclude" +} + +untrack_secrets() { + # If a prior run committed a secret/credential, stop tracking it. Called + # AFTER the upstream sync (not before the stash — a staged deletion would be + # lost in stash/pop) so the removal counts as drift and flows out through the + # next backup PR for review. + [ -d .git ] || return 0 + never_commit_patterns | while IFS= read -r pat; do + [ -n "$pat" ] || continue + if git ls-files --error-unmatch -- "$pat" >/dev/null 2>&1; then + log "⚠ Untracking previously-committed sensitive path: ${pat}" + git rm -q --cached -r -- "$pat" + fi done } @@ -185,7 +207,7 @@ main() { fi git remote set-url origin "$REMOTE_URL" seed_gitignore - ensure_own_excludes + write_secret_excludes trap cleanup_branch EXIT # 1–2. Stash local drift, sync upstream (merged PRs flow back here) @@ -207,6 +229,10 @@ main() { write_status warning stash_conflict "kept live config where conflicting" fi + # If a prior run committed a secret/credential, untrack it now (before the + # change check) so the removal is captured as drift and goes out via a PR. + untrack_secrets + # 4. Anything to back up? if [ -z "$(git status --porcelain)" ]; then write_status success no_changes "" diff --git a/tests/self_check.sh b/tests/self_check.sh index feae558..ccdd68f 100755 --- a/tests/self_check.sh +++ b/tests/self_check.sh @@ -63,16 +63,21 @@ status | grep -q "warning:branch_pushed" || { echo "FAIL: drift status = $(statu git -C "$TMP/config" branch --show-current | grep -q "^main$" || { echo "FAIL: not back on main"; exit 1; } # 4. Migration case: repo already had its own .gitignore lacking the addon's -# entries, and was never excluded. The addon's own status file + log must -# still be kept out of the backup (regression test for the seed_gitignore -# skip-when-.gitignore-exists bug). +# entries, and was never excluded. The addon's own status file + log AND any +# secrets/credentials must be kept out of the backup — even an already- +# committed secret must be untracked (regression test for seed_gitignore +# skip-when-.gitignore-exists + secret-leak on migrated repos). sleep 1 # ensure a distinct auto-backup/ branch name rm -f "$TMP/config/.git/info/exclude" # never-excluded repo -printf 'secrets.yaml\n' > "$TMP/config/.gitignore" # user ignore, no addon entries +printf 'secrets.yaml\n' > "$TMP/config/.gitignore" # user ignore, no addon/token entries HOME="$TMP" git -C "$TMP/config" add .gitignore HOME="$TMP" git -C "$TMP/config" commit -q -m "user gitignore" echo "success:no_changes:" > "$TMP/config/.gitops_backup_status" echo "log line" > "$TMP/config/gitops_backup.log" +echo "ya29.oauth-secret" > "$TMP/config/.google.token" # untracked secret, not in user ignore +echo "PRIVATE KEY" > "$TMP/config/leaked.key" # secret already committed by a prior run +HOME="$TMP" git -C "$TMP/config" add -f leaked.key +HOME="$TMP" git -C "$TMP/config" commit -q -m "oops: committed a key" echo "more drift" >> "$TMP/config/configuration.yaml" run newbranch=$(git -c safe.bareRepository=all -C "$TMP/origin.git" branch \ @@ -81,6 +86,9 @@ tree=$(git -c safe.bareRepository=all -C "$TMP/origin.git" ls-tree -r --name-onl if echo "$tree" | grep -qE '^\.gitops_backup_status$|^gitops_backup\.log$'; then echo "FAIL: addon runtime files committed to backup branch"; exit 1 fi +if echo "$tree" | grep -qE '^\.google\.token$|^leaked\.key$|^secrets\.yaml$'; then + echo "FAIL: a secret/credential was committed to the backup branch"; exit 1 +fi echo "$tree" | grep -q "^configuration.yaml$" || { echo "FAIL: real drift not backed up"; exit 1; } echo "OK: all self-checks passed"