Least-Privilege Execution and sudo Discipline
Drop privileges, scope sudo rules tightly, and validate effective UID before risky operations.
Least-Privilege Execution and sudo Discipline is a free DevOps Bootcamp lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the DevOps Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why Least-Privilege Matters in Shell Scripts
Most security breaches in automation happen not because of exotic exploits, but because scripts run with more privileges than they need. A cron job running as root that only needs to rotate a log file is an accident waiting to happen.
The principle of least privilege states: every process should operate using only the permissions required to do its job — and no more. In Bash scripting this means:
- Running as an unprivileged user whenever possible
- Elevating to root only for the specific commands that require it
- Dropping privileges as soon as elevated work is done
- Never storing or inheriting credentials beyond their scope
This lesson walks through the concrete techniques: sudo scoping, su drops, UID validation guards, and sudoers hardening — building a disciplined privilege model for production scripts.
Checking Effective UID Before Risky Operations
Before any block of code that truly requires root, your script should verify it is running with the expected effective UID. Never assume; always assert.
$EUID is a Bash special variable holding the effective user ID of the current process. Root always has EUID 0. Checking it at the top of a script — or around a privileged block — prevents accidental execution under the wrong identity.
Use this guard pattern:
#!/usr/bin/env bash
set -euo pipefail
# Guard: this script must NOT run as root.
if [[ "$EUID" -eq 0 ]]; then
echo "ERROR: Do not run this script as root. Use a normal user account." >&2
exit 1
fi
echo "Running as UID $EUID — proceeding safely."Asserting Root Only When Required
Some scripts legitimately need root. In that case, the guard flips: fail early if root is absent, rather than letting the script reach a privileged syscall and produce a confusing permission error mid-run.
Combining the early exit with a helpful usage message makes scripts self-documenting:
#!/usr/bin/env bash
set -euo pipefail
require_root() {
if [[ "$EUID" -ne 0 ]]; then
echo "ERROR: $(basename "$0") must be run as root." >&2
echo " Try: sudo $(basename "$0") $*" >&2
exit 1
fi
}
require_root "$@"
echo "Root confirmed (EUID=0). Starting privileged work..."Scoping sudo to Single Commands
The most common mistake is putting sudo at the top of a script and then running everything as root. Instead, apply sudo only to the exact command that needs it — everything else runs as your normal user.
This limits the blast radius: if an attacker injects code into your script, they can only execute the portions that don't have sudo in front of them with root permissions.
Compare the two patterns below. The second is dramatically safer:
#!/usr/bin/env bash
set -euo pipefail
# BAD: escalate early, do everything as root (avoid this)
# sudo bash -c '
# cp config.conf /etc/app/config.conf
# chown app:app /etc/app/config.conf
# systemctl restart app
# '
# GOOD: escalate only for the commands that require it
LOCAL_CONF="./config.conf"
DEST="/etc/app/config.conf"
# Unprivileged: validate the config before touching anything as root
if ! grep -q '^[[:space:]]*\[main\]' "$LOCAL_CONF"; then
echo "ERROR: config.conf is missing [main] section" >&2
exit 1
fi
# Privileged: only these three commands run under sudo
sudo cp "$LOCAL_CONF" "$DEST"
sudo chown app:app "$DEST"
sudo systemctl restart app
echo "Config deployed and service restarted."Writing Tight sudoers Rules
Calling sudo somecommand in a script only works safely if the sudoers file is configured to permit exactly that command — and nothing more. Avoid rules like ALL=(ALL) NOPASSWD: ALL for service accounts.
Instead, lock rules down to specific commands with specific arguments using the visudo-safe syntax. Key fields in a sudoers rule:
- User — who may invoke sudo
- Host — on which machine (use
ALLfor portability) - RunAs — which identity to assume (almost always
root) - Command — full absolute path, optionally with literal arguments
Example tight rules for a deploy service account (deployer):
# /etc/sudoers.d/deployer (edit with: sudo visudo -f /etc/sudoers.d/deployer)
#
# Allow 'deployer' to restart exactly one service — nothing else
deployer ALL=(root) NOPASSWD: /usr/bin/systemctl restart app
# Allow copying a config file to a fixed destination only
deployer ALL=(root) NOPASSWD: /usr/bin/cp /home/deployer/staging/config.conf /etc/app/config.conf
# Allow chown of that specific file only
deployer ALL=(root) NOPASSWD: /usr/bin/chown app\:app /etc/app/config.conf
# NEVER do this — gives full root shell:
# deployer ALL=(ALL) NOPASSWD: ALLDropping Privileges with su and runuser
When a script starts as root (e.g., launched by a system init or cron running as root) but most work should happen as an unprivileged user, drop privileges explicitly rather than running the entire script as root.
Two tools for this:
su -s /bin/bash -c 'command' username— spawns a shell as username and runs commandrunuser -u username -- command args— preferred on Linux for switching within root-owned scripts; cleaner thansu
The pattern below shows a root-owned deploy wrapper that drops to the app user for the actual application logic:
#!/usr/bin/env bash
# This script is called by systemd as root during pre-deployment
set -euo pipefail
APP_USER="app"
DEPLOY_DIR="/opt/myapp"
# Step 1: privileged — fix ownership of deploy directory
chown -R "${APP_USER}:${APP_USER}" "$DEPLOY_DIR"
# Step 2: drop to app user for the actual migration/startup logic
# runuser is available on most modern Linux systems
runuser -u "$APP_USER" -- bash -c "
cd $DEPLOY_DIR
./bin/migrate.sh
./bin/start.sh
"
echo "Deploy complete. Privileged wrapper exiting."Using sudo -u to Run a Single Command as Another User
You don't always need to drop to a full shell session. sudo -u username command runs a single command as the specified user, then returns to the calling identity. This is useful for touching files owned by a service account without granting that account any interactive access.
Pair this with a sudoers rule that allows exactly that user/command combination:
#!/usr/bin/env bash
set -euo pipefail
# Scenario: deploy script runs as 'deployer'; DB migrations must run as 'postgres'
# sudoers entry needed:
# deployer ALL=(postgres) NOPASSWD: /opt/app/bin/run_migrations.sh
DB_MIGRATION_SCRIPT="/opt/app/bin/run_migrations.sh"
if [[ ! -x "$DB_MIGRATION_SCRIPT" ]]; then
echo "ERROR: migration script not found or not executable: $DB_MIGRATION_SCRIPT" >&2
exit 1
fi
echo "Running DB migrations as postgres user..."
sudo -u postgres "$DB_MIGRATION_SCRIPT"
echo "Migrations done. Returning to deployer context (EUID=$EUID)."Avoiding Privilege Escalation via Environment Variables
One subtle attack surface: environment variables inherited by a sudo session. By default, sudo resets the environment, but misconfigured env_keep or env_reset overrides can pass attacker-controlled variables like LD_PRELOAD, PATH, or PYTHONPATH into privileged commands.
Best practices:
- Always use absolute paths in scripts that run under
sudo— never rely on$PATH - Pass only the variables you explicitly need:
sudo env VAR=value /path/to/cmd - In sudoers, avoid
env_keep += PATHorenv_keep += LD_* - Use
sudo -Eonly when you fully control and trust the calling environment
#!/usr/bin/env bash
set -euo pipefail
# BAD: relies on $PATH — attacker who controls PATH can hijack 'cp'
# sudo cp config.conf /etc/app/
# GOOD: absolute paths for every command called under elevated context
SUDO_BIN="/usr/bin/sudo"
CP_BIN="/usr/bin/cp"
CHOWN_BIN="/usr/bin/chown"
SYSTEMCTL_BIN="/usr/bin/systemctl"
"$SUDO_BIN" "$CP_BIN" ./config.conf /etc/app/config.conf
"$SUDO_BIN" "$CHOWN_BIN" app:app /etc/app/config.conf
"$SUDO_BIN" "$SYSTEMCTL_BIN" restart app
echo "Deployed with hardened absolute-path invocations."Locking Down sudo with Command Argument Validation
Even when a sudoers rule permits a specific script, that script can be passed arbitrary arguments unless the rule locks them down too. A common pitfall:
deployer ALL=(root) NOPASSWD: /opt/scripts/manage.shThis allows sudo /opt/scripts/manage.sh restart — but also sudo /opt/scripts/manage.sh --arbitrary-flag. If manage.sh passes arguments blindly to privileged sub-commands, you have a problem.
Defend in depth: validate arguments inside the privileged script as well as in sudoers:
#!/usr/bin/env bash
# /opt/scripts/manage.sh — called via sudo; must validate its own args
set -euo pipefail
# Allowlist of valid actions
declare -A ALLOWED_ACTIONS=(
[restart]=1
[status]=1
[reload]=1
)
ACTION="${1:-}"
if [[ -z "$ACTION" ]]; then
echo "Usage: $(basename "$0") <restart|status|reload>" >&2
exit 1
fi
if [[ -z "${ALLOWED_ACTIONS[$ACTION]:-}" ]]; then
echo "ERROR: Unknown action '${ACTION}'. Allowed: ${!ALLOWED_ACTIONS[*]}" >&2
exit 2
fi
/usr/bin/systemctl "$ACTION" app
echo "Action '$ACTION' executed successfully."Temporary Privilege Elevation with a Cleanup Trap
When a script must briefly hold a privileged file, credential, or resource, use Bash's trap to ensure cleanup happens even on error or signal. This prevents privilege leakage — for example, a temporary setuid binary or a root-owned socket left behind if the script crashes.
The pattern below creates a temp file as root, uses it, then removes it — guaranteed by a trap on EXIT:
#!/usr/bin/env bash
set -euo pipefail
# Must run as root for this demo
if [[ "$EUID" -ne 0 ]]; then
echo "Run as root" >&2; exit 1
fi
TMP_SECRET=""
cleanup() {
local exit_code=$?
if [[ -n "$TMP_SECRET" && -f "$TMP_SECRET" ]]; then
# Overwrite before deletion to reduce forensic recovery risk
shred -u "$TMP_SECRET" 2>/dev/null || rm -f "$TMP_SECRET"
echo "[cleanup] Removed privileged temp file." >&2
fi
exit "$exit_code"
}
trap cleanup EXIT INT TERM
# Create a root-owned temp file for a short-lived secret
TMP_SECRET="$(mktemp /tmp/deploy_secret.XXXXXXXX)"
chmod 600 "$TMP_SECRET"
# Simulate fetching a secret into the temp file
echo "super-secret-token" > "$TMP_SECRET"
# Use the secret (e.g., pass to a sub-command via file descriptor)
/usr/bin/some-privileged-tool --key-file "$TMP_SECRET"
echo "Privileged operation complete."Auditing and Logging Privileged Actions
Least privilege is easier to enforce when every elevation event is logged with context: who ran what, when, and why. Combine two layers:
- sudo itself —
/var/log/auth.log(Debian/Ubuntu) or/var/log/secure(RHEL) captures every sudo invocation automatically - Script-level audit log — write a structured entry at the start of every privileged function so intent is recorded alongside the system log
Using a simple structured log function keeps audit trails consistent and grep-friendly:
#!/usr/bin/env bash
set -euo pipefail
AUDIT_LOG="/var/log/app_deploy_audit.log"
log_privileged_action() {
local action="$1"
local reason="${2:-unspecified}"
local ts
ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf '{"ts":"%s","user":"%s","euid":%d,"action":"%s","reason":"%s"}\n' \
"$ts" "${SUDO_USER:-$USER}" "$EUID" "$action" "$reason" \
| sudo tee -a "$AUDIT_LOG" > /dev/null
}
# Each privileged step is logged before execution
log_privileged_action "cp_config" "deploy release v2.4.1"
sudo /usr/bin/cp ./config.conf /etc/app/config.conf
log_privileged_action "chown_config" "ensure app user owns config"
sudo /usr/bin/chown app:app /etc/app/config.conf
log_privileged_action "restart_service" "activate new config"
sudo /usr/bin/systemctl restart app
echo "Deployment complete. Audit entries written to $AUDIT_LOG"Knowledge Check: sudo Scoping
Test your understanding of least-privilege execution in Bash scripts.
Recap: Least-Privilege Execution and sudo Discipline
In this lesson you mastered the techniques for running Bash scripts with the minimum privilege required at every step. Here is a concise summary of the key principles:
- Guard with
$EUID— fail fast if the script is running as the wrong identity, whether that means refusing root or requiring it - Scope
sudoto individual commands — never elevate an entire script; applysudoonly to the lines that genuinely need it - Write tight sudoers rules — specify full absolute paths and literal arguments; avoid wildcards and
ALL - Drop privileges with
runuserorsudo -u— when a root-started script must hand off to an unprivileged user, use the right tool rather than running everything as root - Use absolute paths — never rely on
$PATHinside privileged code; hardcode binary paths to prevent hijacking - Validate arguments inside privileged scripts — sudoers rules are the first line of defence, not the only one; use allowlists
- Trap and clean up — use
trap cleanup EXITto guarantee temporary privileged resources are destroyed even on error - Log every elevation — structured audit entries combined with the built-in sudo syslog give you traceability for every privileged action
Applied consistently, these practices reduce the attack surface of your automation from root-at-all-times to root-only-where-provably-necessary — the hallmark of hardened, production-grade Bash.
Frequently asked questions
Is the “Least-Privilege Execution and sudo Discipline” lesson free?
Yes — the full text of “Least-Privilege Execution and sudo Discipline” is free to read here on the web, and the DevOps Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the DevOps Bootcamp course, upgrade to CoddyKit PRO.
What will I learn in “Least-Privilege Execution and sudo Discipline”?
Drop privileges, scope sudo rules tightly, and validate effective UID before risky operations. You practise DevOps Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start DevOps Bootcamp?
No prior experience is required. DevOps Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Least-Privilege Execution and sudo Discipline” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this DevOps Bootcamp lesson?
Yes. Every DevOps Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Preventing Command and Argument Injection
- Secure Secret Handling and Environment Hygiene
- Least-Privilege Execution and sudo Discipline
- Static Analysis and Auditing with ShellCheck