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
9 changes: 9 additions & 0 deletions gitops_backup/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion gitops_backup/config.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
74 changes: 50 additions & 24 deletions gitops_backup/rootfs/app/gitops_backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<</d' \
"$exclude"
sed -i \
'/# >>> gitops-backup managed >>>/,/# <<< gitops-backup managed <<</d' \
"$exclude"
fi
cat >> "$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
}

Expand Down Expand Up @@ -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)
Expand All @@ -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 ""
Expand Down
16 changes: 12 additions & 4 deletions tests/self_check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<timestamp> 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 \
Expand All @@ -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"
Loading