Repository navigation
refactor(security): harden backup_critical_configs.sh (owner-only archives, verification, retention) - #4
Conversation
…ication, retention)
krishsharma-code
left a comment
There was a problem hiding this comment.
Review: hardening of backup_critical_configs.sh
Reviewed the full diff of #4 (4 files, +651/−21) against the four axes of the audit: security boundaries, memory efficiency, edge cases and failure handling, and documentation clarity. Self-review only — GitHub does not let an author approve their own PR, so this is submitted as COMMENT.
What was found in the original script
- Secret exposure (high).
BACKUP_DIR="/tmp/security_backups"was created with baremkdir -p, so the directory mode followed the caller's umask — commonly 0755. The archive contains/etc/shadow. Any local user could read the password hashes of every account on the box. This is the defect that motivated the PR. - Unverified output (medium). The
tarexit status was checked but nothing else: no gzip integrity test, no check that the expected members were present. A tar that exits 0 with a truncated stream would still be reported asSUCCESS. - Swallowed diagnostics (medium).
tar ... 2>/dev/nulldiscarded the only explanation of a failure, and the script then printed "Check permissions." unconditionally. - Brittle file list (low-medium).
tar -czf ... $FILES_TO_BACKUPrelied on unquoted word splitting, and a single missing path (e.g./etc/network/interfaceson a systemd-networkd host) made tar exit non-zero, so a healthy backup reported failure and left a partial archive on disk. - No interface (low). No
--help, no destination override, no retention —/tmpfilled up over time.TIMESTAMPalso collided for two runs in the same second, silently overwriting.
What changed and why
umask 077at the top plus an explicitchmod 0700on the directory aftermkdir -p, becausemkdir -pleaves an existing directory's mode untouched — the chmod is the part that actually fixes a pre-existing 0755 dir.- Archive written with
mktempinside the destination directory (mode 0600 by umask, and same filesystem so the publish is a single atomic rename), verified withgzip -tand an entry-count comparison, thenmv -ninto place.trap cleanup EXITremoves the temporary file on any early exit. - Sources are collected into a bash array: missing files are warned about and skipped, present-but-unreadable files are fatal (exit 2) with a sudo hint, because a backup missing
/etc/shadowthat reports success is the failure mode that matters.--allow-partialopts into skipping. set -euo pipefail,--before paths so a filename cannot be parsed as an option, and tar's stderr left intact.--keep Nretention (default 5) using the fixed-width timestamp in the filename; a_1,_2suffix prevents same-second overwrites.- 16 pytest cases driving the real CLI with throwaway directories, plus a pytest job in CI so the suite gates future PRs.
Verification
bash -n on all 83 shell scripts, py_compile on all Python scripts, flake8 --select=E9,F63,F7,F82,F821,F823, and pytest tests -q → 91 passed. Manual end-to-end runs covered success, missing source, unreadable source, --dry-run and default-file-list preflight.
Not fixed here
The default destination is still /tmp, which is usually tmpfs — those backups do not survive a reboot and are not a real recovery point. Changing the default is a behaviour change for anyone with a cron entry, so it is left as a documented limitation and a follow-up.
| return 0 | ||
| fi | ||
|
|
||
| chmod 0700 -- "$BACKUP_DIR" || die 4 "cannot restrict permissions on '$BACKUP_DIR'" |
There was a problem hiding this comment.
The chmod after mkdir -p is deliberate, not redundant: mkdir -p on an existing directory leaves its mode alone, so a backup directory that some earlier version created at 0755 would stay world-readable and keep exposing the /etc/shadow copy inside it. Worth keeping this comment so nobody "simplifies" it away later.
| return 0 | ||
| fi | ||
|
|
||
| TMP_ARCHIVE="$(mktemp "$BACKUP_DIR/.${ARCHIVE_PREFIX}XXXXXX")" \ |
There was a problem hiding this comment.
Good: the temp file is created inside $BACKUP_DIR rather than in /tmp. That keeps the later mv on the same filesystem, which is what makes the publish atomic — a cross-device mv would degrade to copy-then-delete and reintroduce the partial-file window.
| die 4 "tar failed while creating the archive" | ||
| fi | ||
|
|
||
| if ! gzip -t "$TMP_ARCHIVE"; then |
There was a problem hiding this comment.
This is the check that was missing before. tar exiting 0 only means it did not report an error; it does not prove the gzip stream is intact or that every intended member is present. Pairing gzip -t with the entry-count comparison below is what makes the SUCCESS message trustworthy.
| log_warn "skipping unreadable '$path' (--allow-partial)" | ||
| continue | ||
| fi | ||
| die 2 "cannot read '$path' - re-run with sudo, or pass --allow-partial to skip it" |
There was a problem hiding this comment.
Making an unreadable source fatal is the right call. The alternative — warn and continue — produces an archive that looks complete, carries a valid checksum and is missing /etc/shadow, and nobody discovers that until a restore is needed. --allow-partial keeps the old lenient behaviour available for people who genuinely want it.
|
|
||
| # Sort oldest-first. mapfile keeps paths containing spaces intact, unlike a | ||
| # plain $(...) word-split. | ||
| mapfile -t archives < <(printf '%s\n' "${archives[@]}" | sort) |
There was a problem hiding this comment.
mapfile rather than archives=($(...)) so a backup directory path containing a space survives the sort. Since the timestamp is fixed width, plain lexical sort is chronological and no stat/date parsing is needed.
| log_ok "backup created: $final" | ||
| log_info "backup integrity (SHA256):" | ||
| sha256sum -- "$final" | ||
| if ! sha256sum -- "$final" >"$final.sha256"; then |
There was a problem hiding this comment.
Two notes for reviewers: the sidecar is written with a redirect, so its mode comes from the surrounding umask 077 plus the explicit chmod on the next line — please keep both if this is refactored. Also, the SHA-256 is computed from the archive after it has been published; since the archive was verified and renamed atomically, that ordering is safe here.
Summary
Hardens
bash_scripts/backup_critical_configs.shand adds the first pytest coverage for a shell script in this repo. The old script tarred/etc/shadowinto/tmp/security_backupswith whatever modemkdir -phappened to produce, discarded tar's stderr, and never checked what it had actually written. The rewrite makes the archive owner-only, verifies it before publishing, fails loudly when a source is unreadable, and adds--dry-run,--keep,--filesand--backup-dir.Linked Issue
N/A - raised directly by the recurring code-quality audit (no tracking issue opened).
Type of Change
What Changed
umask 077,mkdir -pfollowed by an explicitchmod 0700on the backup directory (the chmod is not redundant - a pre-existing 0755 directory is tightened), archive written viamktempat mode 0600, and a.sha256sidecar at 0600. A backup of/etc/shadowin a world-readable directory is a local privilege-escalation gift.gzip -t, and its entry count compared to the source count. Only then is it renamed into place, so a truncated file is never mistaken for a good backup. Atrap ... EXITremoves the temporary file on any early exit./etc/network/interfaceson a systemd-networkd host) are skipped with a warning; files that exist but cannot be read fail with exit 2 and a "re-run with sudo" hint, because a backup silently missing the password database is worse than no backup.--allow-partialopts into skipping.2>/dev/nullon thetarcall is gone; tar's own diagnosis is the only explanation a failure gets.set -euo pipefailthroughout, and--before paths so a filename cannot be read as an option.if [ $? -eq 0 ]after a command that always ran in a subshell-free context still left a partial archive on disk. Now nothing is published unless verification passes.--keep N(default 5) prunes oldest-first using the fixed-width timestamp in the filename; two runs in the same second no longer overwrite each other.-h/--help,--dry-run,-d/--backup-dir,-f/--files,-k/--keep,-p/--allow-partial, documented exit codes (0/1/2/3/4). Dry run prints the plan and writes nothing, including no pruning.tests/test_backup_critical_configs.py(16 cases) drives the script through its real CLI with throwaway directories - no root, no/etc..github/workflows/ci.ymlgains a pytest job so the suite actually gates PRs.New or Modified Scripts
bash_scripts/backup_critical_configs.shtests/test_backup_critical_configs.py.github/workflows/ci.ymlREADME.mdSafety Review
--dry-run(pruning is bounded by--keep;--dry-runsuppresses it)Testing Checklist
python3 -m py_compile python_scripts/*.pypassesflake8 python_scripts --select=E9,F63,F7,F82,F821,F823 --max-line-length=120passesfor f in bash_scripts/*.sh; do bash -n "$f"; donepasses (83 scripts)shellcheck bash_scripts/*.shreports no errors (advisory) - shellcheck unavailable in the audit sandbox--helpoutputpython3 -m pytest tests -q-> 91 passedDocumentation
README.mdin the correct numeric positionCode Quality Checklist
Additional Notes
Known limitation, unchanged by this PR: the archive is still written under
/tmpby default./tmpis oftentmpfs, so backups vanish on reboot and are not a durable recovery point ---backup-dir /var/backups/configsis the sensible production choice, and a follow-up could change the default. Members are stored with the leading slash stripped, so restoring issudo tar -xzf <archive> -C /after checking the sidecar.