What this check reads
The check first inventories dump files with find /var/backups /backup /opt/backup /srv/backup /root/backups -maxdepth 3 -type f \( -name '*.sql' -o -name '*.sql.gz' -o -name '*.dump' -o -name '*.bak' \), keeping the first 50 matches as the count NB. It then re-runs the identical search with -perm -004 added, which isolates the files carrying the world-read bit. An empty inventory is the SKIP; a non-empty inventory with no -perm -004 match is the PASS; and a single match is enough for the FAIL, with the first three paths printed in the message. Unprivileged runs drop /root/backups from both searches and say so in the message.
When it applies
Runs on any host, but its scope depends on privilege: with root it scans /var/backups, /backup, /opt/backup, /srv/backup and /root/backups; without root it drops /root/backups and says so in the message. Depth is capped at 3 and the inventory at the first 50 files; only four extensions count (*.sql, *.sql.gz, *.dump, *.bak). Backups anywhere else - /home/*/backups, /mnt/backup, an object store - are never seen, so SKIP means "nothing found in those five paths", not "no exposed dumps". The test is -perm -004, i.e. the world-read bit on the file; a restrictive parent directory mitigates in practice but does not change the result.
What each result means
| Result | When | What it means |
|---|---|---|
| SKIP | No dump files in those paths | Nothing was verified: no dump matched in the scanned paths, which is not the same as having no exposed dumps elsewhere on the host. |
| FAIL | Any dump is world-readable | At least one dump can be read by every local account on the server. The message lists the first three matches only; there may be more. |
| PASS | Dumps exist, none world-readable | Every dump found in those paths has the world-read bit clear. Nothing was checked about the directory above them, the file owner, or any offsite copy. |
Why it matters
A dump is the entire database in one file. World-readable dumps bypass every database-level control. OWASP: protect backups, ideally encrypted. PCI DSS 3.x requires protecting stored cardholder data wherever it sits, including backups.
Why it fails, and when it is wrong
mysqldump > fileinheritsumask 022and creates644files.pg_dump -Fcthe same.- Dumps stored elsewhere (
/home/user/backups,/mnt/backup, S3) are not scanned. .bakalso matches unrelated files (editor backups, config backups). Those are still worth protecting.- Directory permissions (
700on the parent) mitigate but the check looks at file modes only.
How to fix it
Find the dumps and their modes
Inventory what is actually on disk before you change anything, so you know how many files the fix has to cover:
sudo find /var/backups /backup /opt/backup /srv/backup /root/backups -maxdepth 3 -type f \
\( -name '*.sql' -o -name '*.sql.gz' -o -name '*.dump' -o -name '*.bak' \) \
-exec stat -c '%a %U:%G %n' {} + 2>/dev/nullRemove world read
A dump is the entire database in a single file, so the owner is the only account that should be able to read it. Take the world-read bit off every file the inventory found, and close the directory above them as well:
sudo chmod 600 /var/backups/*.sql.gz
sudo chmod 700 /var/backups/dbSet umask in the backup script
A one-off chmod only fixes the files that already exist. Add umask 077 at the top of the script that creates the dumps, so future files are created at 600 without a separate chmod step:
# in the backup script:
umask 077Encrypt during creation, not after
Permissions only stop other accounts on this host. Pipe the dump straight through gpg, age or openssl enc so a plaintext copy never touches disk, and store the key somewhere the server itself cannot read. See the Backup Encryption check for the encryption side of this.
Verify the fix
# Reproduce the scan, showing modes:
sudo find /var/backups /backup /opt/backup /srv/backup /root/backups -maxdepth 3 -type f \
\( -name '*.sql' -o -name '*.sql.gz' -o -name '*.dump' -o -name '*.bak' \) \
-exec stat -c '%a %U:%G %n' {} + 2>/dev/null
# expected: every mode is 600 (or 640 with a trusted group) - none ending in 4,5,6,7
# The check's exact world-readable test must return nothing:
sudo find /var/backups /backup /opt/backup /srv/backup /root/backups -maxdepth 3 -type f \
\( -name '*.sql' -o -name '*.sql.gz' -o -name '*.dump' -o -name '*.bak' \) -perm -004 2>/dev/null
# Confirm from an unprivileged account:
sudo -u nobody cat /var/backups/db/latest.sql.gz # expected: Permission deniedDebugging
Not a pass. Either there are no backups on this host, or they live outside the five scanned paths or deeper than three levels. Find them with sudo find / -xdev -name '*.sql.gz' -o -name '*.dump' 2>/dev/null | head.
The audit ran unprivileged. Re-run as root or with sudo before treating the result as clean.
The backup job recreates files under umask 022. Add umask 077 at the top of the backup script (or UMask=0077 in the systemd unit); a one-off chmod only fixes the files that already exist.
The FAIL message shows head -3. Use the -perm -004 command above for the complete list.
Editor and config backups match the pattern too. They are still worth protecting, but if the finding is noise, confirm what was matched with the first command.
Only from local accounts. Anyone who reaches the storage location or an offsite copy still gets the whole database; see the Backup Encryption check.
Sources
- find(1)
-permsemantics - MySQL 8.4: Backup and Recovery Types / security
- PostgreSQL: Backup and Restore
- OWASP Database Security Cheat Sheet (Backups)
- PCI DSS v4.0 Requirement 3 (Protect stored account data)
How the script reads this
Next
Re-run the Configuration & Hardening audit after applying the fix and confirm this check moves to PASS. CtrlOps runs all 7 checks over your existing SSH connection and scores the result, so the change is visible without reading another config file.
All 7 Configuration & Hardening fixes