
Audit Mail User Permissions and Find Over-Privileged Accounts
Before you run this
This script is a read-only audit. It walks your virtual mailbox tree and a couple of mail config directories and reports three things: mailbox files or directories that are readable or writable by group/other, mailbox files not owned by the expected mail user, config files that contain secrets but aren't locked down, and the service account's login shell. It changes nothing — no chmod, no chown, no account edits. Remediation is a separate, manual step at the end that you run deliberately, one finding at a time.
Privileges: to read every mailbox you almost certainly need sudo/root, because a well-configured mail store is mode 0700 and owned by the mail service account (vmail on iRedMail). Run it as root, or as the vmail user itself, or you'll get "Permission denied" on the very directories you most want to inspect — and a clean-looking report that's clean only because you couldn't see anything.
Test first: read the whole script before you run it, and run it on a staging or test mailbox tree first, or point MAIL_BASE at a copy. It's read-only, so the audit itself is safe, but the remediation commands at the bottom (chmod, usermod -s, gpasswd -d) are not — a wrong chmod on the mail store can break delivery or, worse, expose mail. Do remediation in a maintenance window and back up permissions first (see the last section).
Assumptions I'm holding to: Debian 12 / Ubuntu 22.04, GNU findutils (the -perm /mode "any of these bits" syntax is GNU-specific), Postfix + Dovecot with virtual mailboxes under a single base directory owned by one service account — the iRedMail layout (/var/vmail, user vmail). If instead you run Dovecot with system-user mailboxes, each mailbox is a real Unix account and "over-privileged" means those accounts having shells or sudo they don't need; the tree-permission checks still apply, but adapt the ownership check.
What "over-privileged" means here
For a virtual mail server there's no per-user Unix account to audit, so the risky states are:
- Mailbox files/dirs that group or others can read (someone else on the box can read customers' mail) or write.
- Mailbox files owned by the wrong user — a sign of a botched restore or a script that ran as root.
- Config files holding database or LDAP passwords that aren't mode
0600/0640. - The mail service account carrying a real login shell when it should have
nologin. - Humans sitting in privileged groups (
sudo,wheel,adm) who only ever touch mail.
The script
Save as mail-perm-audit.sh, set the three variables at the top for your environment, then chmod +x mail-perm-audit.sh.
#!/usr/bin/env bash
# Read-only audit of a virtual mailbox tree and mail config for
# over-privileged / over-exposed permissions. Changes nothing.
# ---- Set these for your environment ----
MAIL_BASE="/var/vmail" # base dir of virtual mailboxes
VMAIL_USER="vmail" # account that should own the mailboxes
VMAIL_GROUP="vmail" # group that should own the mailboxes
CONFIG_DIRS=(/etc/dovecot /etc/postfix) # dirs to scan for secret-bearing files
PRIV_GROUPS=(sudo wheel adm) # groups whose membership you want listed
# ----------------------------------------
findings=0
note() { printf ' [!] %s\n' "$1"; findings=$((findings+1)); }
hdr() { printf '\n=== %s ===\n' "$1"; }
hdr "Service account shell: $VMAIL_USER"
shell=$(getent passwd "$VMAIL_USER" | awk -F: '{print $7}')
if [ -z "$shell" ]; then
echo " account '$VMAIL_USER' not found in passwd (LDAP-only? skip)"
elif echo "$shell" | grep -Eq 'nologin|/false$'; then
echo " OK: shell is $shell"
else
note "$VMAIL_USER has a login shell: $shell (expected nologin/false)"
fi
hdr "Mailbox tree permissions under $MAIL_BASE"
if [ ! -d "$MAIL_BASE" ]; then
echo " $MAIL_BASE does not exist — check MAIL_BASE"
else
# Group- or world-readable/writable directories (-perm /077 = any of those bits)
while IFS= read -r d; do
note "dir group/other-accessible: $d ($(stat -c '%A %U:%G' "$d"))"
done < <(find "$MAIL_BASE" -type d -perm /077 2>/dev/null)
# Group- or world-readable/writable files (-perm /066)
while IFS= read -r f; do
note "file group/other-accessible: $f ($(stat -c '%A %U:%G' "$f"))"
done < <(find "$MAIL_BASE" -type f -perm /066 2>/dev/null)
# Wrong ownership
while IFS= read -r p; do
note "wrong owner/group: $p ($(stat -c '%U:%G' "$p"))"
done < <(find "$MAIL_BASE" \( -not -user "$VMAIL_USER" -o -not -group "$VMAIL_GROUP" \) 2>/dev/null)
fi
hdr "Config files containing 'password' that are group/other-readable"
# Find files that hold secrets, then flag any readable beyond the owner (-perm /044)
while IFS= read -r cf; do
if find "$cf" -perm /044 >/dev/null 2>&1; then
note "secret file readable by group/other: $cf ($(stat -c '%A %U:%G' "$cf"))"
fi
done < <(grep -rilE 'password' "${CONFIG_DIRS[@]}" 2>/dev/null)
hdr "Members of privileged groups (secondary members only)"
for g in "${PRIV_GROUPS[@]}"; do
members=$(getent group "$g" | awk -F: '{print $4}')
printf ' %-8s %s\n' "$g:" "${members:-<none>}"
done
hdr "Summary"
echo " $findings finding(s). Review each before changing anything."
A few notes on the non-obvious bits. find … -perm /077 matches any path where any of the group/other permission bits are set — that's the GNU /mode form, and it's why I named GNU findutils up top; on a non-GNU find this syntax differs. I use -perm /066 for files (read or write for group/other) rather than /077, because the execute bit on a Maildir file isn't the exposure you care about. The config check greps for the literal string password to locate secret-bearing files like Dovecot's SQL/LDAP config or Postfix's map files, instead of hardcoding paths that vary between installs — so it adapts to your layout. getent group lists only secondary members; a user whose primary group is sudo won't appear, so treat that section as a prompt to eyeball, not gospel.
Running it
sudo ./mail-perm-audit.sh
Redirect to a dated file if you want to diff audits over time:
sudo ./mail-perm-audit.sh | tee "audit-$(date +%F).txt"
Expected clean output has OK lines and 0 finding(s). Every [!] line names the exact path and its current mode/owner so you can judge it.
Verifying a finding by hand
Don't trust the script blindly — confirm anything it flags with stat, which is the canonical tool here:
stat -c '%A %U:%G %n' /var/vmail/example.com/user
%A gives you the human-readable mode string (e.g. drwx------), %U:%G the owner and group. That should agree with what the report said.
Remediation (deliberate, reversible-ish)
The audit changes nothing; fixing does. Back up the current state first so you can restore it if a fix breaks delivery:
# Snapshot every path + its numeric mode + owner, so you can undo
find /var/vmail -printf '%m %u %g %p\n' > /root/vmail-perms.$(date +%F).bak
Then fix findings one class at a time, verifying delivery after each. Typical corrections:
# Tighten a mailbox tree back to owner-only (verify layout matches yours first)
sudo chown -R vmail:vmail /var/vmail
sudo chmod -R go-rwx /var/vmail
# Lock a secret-bearing config file
sudo chmod 640 /etc/dovecot/dovecot-sql.conf.ext
# Give the service account a non-login shell
sudo usermod -s /usr/sbin/nologin vmail
# Remove a human from a privileged group they don't need
sudo gpasswd -d alice sudo
Check the exact mailbox permission scheme your platform expects before running a broad chmod — iRedMail and stock Dovecot document their required modes, and Dovecot in particular will refuse mail if a mailbox is group/world-writable. See the Dovecot documentation for mailbox permissions and the iRedMail docs for its file-ownership layout.
To undo a mistaken chmod/chown, replay from the snapshot you took. Each line is mode user group path; feed it back with a small loop:
while read -r m u g p; do chmod "$m" "$p"; chown "$u:$g" "$p"; done < /root/vmail-perms.YYYY-MM-DD.bak
Finally, re-run the audit — a clean 0 finding(s) and successful test delivery to a mailbox are your confirmation the fixes held.
Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.
More about the author →Was this article helpful?
Tap a star — no sign-in needed.
Be the first to rate this article.
Related guides
Audit World-Writable Files and SUID Binaries with Bash
This script reads your filesystem and reports two classes of risky files: world-writable files and directories (anyone on the box can modify them) and SUID/SGID binaries (they run with the owner's or group's privileges, often root). It writes a timestamped report and, optionally, a baseline you can diff against later. It does not change any permissions, delete anything, or modify a single file — it only runs find and writes a text report to a directory you choose.
Automate a Read-Only Security Audit Report for a Linux Server
This guide builds a single bash script that takes a read-only snapshot of a server's security posture — logged-in users, listening sockets, running services, UID 0 accounts, the effective SSH config, pending updates, SUID/SGID binaries, and world-writable files — and writes it to a timestamped text file. It inspects and records; it never edits config, kills processes, changes permissions, or installs anything.
Automate Dovecot Mailbox Quota Reports with a Shell Script
This guide builds a small shell script that runs doveadm quota get for every mailbox on a Dovecot server, writes a plain-text usage report to a file, and emails you a summary that flags anyone at or above a threshold (90% by default). It is a read-only reporting script. It queries quota figures that Dovecot already tracks; it does not create, resize, recalculate, or delete anything in a mailbox.
Correlate Fail2ban, Postfix and Dovecot Logs Into One Report
This is a read-only reporting script . It reads your Postfix/Dovecot mail log and your Fail2ban log, extracts the source IPs behind SMTP SASL failures, Postfix rejects and Dovecot auth failures, cross-references them against the IPs Fail2ban actually banned, and prints one ranked summary. It does not touch your firewall, your jails, your mail queue, or any config. There is nothing to undo except deleting the report file and removing the cron entry you add at the end.




