Running Bash Scripts With Least Privilege: sudo Rules Over SUID Pitfalls
AI generated
$_
#!/
Bash · Security · sudo · Least Privilege
Running Bash Scripts With Least Privilege
Targeted sudo rules instead of blanket root access, and why SUID does not work on scripts

Deployment scripts in practice often run with far more privileges than needed, usually because a blanket sudo grant feels more convenient than a precise rule. Taking least privilege seriously means writing targeted sudoers entries per script and skipping the SUID bit on shell scripts, which modern Linux kernels ignore for good reason.

15 min read sudoers · least privilege · SUID Bash 4.x · 5.x · Linux

1. Least privilege for deployment scripts: the core problem

A deployment script rarely truly needs full root access. Usually it is enough to write a handful of files with certain permissions, restart a service, or bind a single privileged port. Yet such scripts regularly end up with a sudoers line like deploy ALL=(ALL) NOPASSWD: ALL in practice, because that is faster to set up than a precise rule tailored to the script.

The problem is not theoretical: if the account running the script gets compromised, for example through a vulnerability in one of the applications it calls, an attacker immediately inherits full root access to the entire server, instead of being confined to the actually needed, limited set of actions. Least privilege means keeping that blast radius as small as possible from the start, not restricting it after the fact once it is already too late.

2. Precise sudoers rules instead of a blanket ALL=(ALL)

A targeted sudoers rule allows exactly the commands a script actually needs, with exact arguments or argument patterns, instead of a blanket free pass. A file like /etc/sudoers.d/deploy-nginx-reload can specify that the deploy user may run only systemctl reload nginx without a password, but neither systemctl stop nor restart any other arbitrary service.

It matters to specify the full path to the command and avoid wildcards that grant more than intended. An entry like deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx cannot be extended with additional arguments, while /usr/bin/systemctl * effectively grants full access to every systemctl subcommand again, including potentially dangerous ones like systemctl edit.


# /etc/sudoers.d/deploy-nginx-reload -- edit only with visudo -f
# Precise: exact command, exact arguments, no wildcards
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart php8.4-fpm

# Bad: grants full control over every systemctl subcommand
# deploy ALL=(root) NOPASSWD: /usr/bin/systemctl *

3. Calling sudo for specific steps instead of running the whole script as root

Instead of starting a whole deployment script as root, it is more robust to run the script mostly as an unprivileged user and only call the individual steps that genuinely need elevated rights through targeted sudo invocations. This significantly reduces the attack surface, because a bug or vulnerability in the bulk of the script does not automatically translate into full root access.

In practice this means splitting a script into clearly separated function blocks: build steps, file copy operations, and health checks run as the normal deployment user, while only restarting a service or writing into a root-owned directory goes through a precise sudo call that is exactly whitelisted in the sudoers file.


#!/usr/bin/env bash
set -euo pipefail

# Runs as the unprivileged 'deploy' user, no sudo needed here
rsync -a --delete ./build/ /var/www/app/
bin/magento setup:upgrade

# Only this single step needs elevated rights, scoped by sudoers
sudo /usr/bin/systemctl reload nginx

curl -fsS https://shop.example.com/health >/dev/null

4. Why the SUID bit on a Bash script has no effect

A natural but wrong idea is to set the SUID bit on a script like a compiled binary (chmod u+s script.sh), expecting it to automatically run with its owner's privileges. For a real ELF binary that actually works, but for a script with a shebang line, the Linux kernel deliberately ignores the SUID bit and keeps running it with the privileges of the invoking user instead.

The reason is a race condition known for decades: between the moment the kernel reads the shebang line and starts the matching interpreter, and the moment that interpreter actually opens the script, an attacker can swap out the file (time-of-check-to-time-of-use). A SUID interpreter would then run an arbitrary, attacker-controlled script with elevated privileges. The Linux kernel therefore disables the SUID bit categorically for all interpreted scripts.


# This has NO effect on Linux for a script with a #!/bin/bash shebang
chmod u+s /usr/local/bin/deploy.sh
ls -l /usr/local/bin/deploy.sh
# -rwsr-xr-x 1 root root ... deploy.sh   <- bit is set, kernel ignores it

# Verify: still runs as the invoking user, not as root
whoami
/usr/local/bin/deploy.sh  # prints the caller's UID, not root's

5. Alternatives to the SUID bit: small C wrappers and sudo policies

Anyone who genuinely needs a SUID-like privilege elevation for one specific action, such as binding a privileged port or writing to a root-owned file, should write a minimal, compiled C wrapper that performs exactly that one action and then immediately drops its privileges again (setuid/seteuid in code, not through the filesystem bit). Such a wrapper is auditable, small, and unaffected by the shebang race condition, because it is a real binary.

For the vast majority of deployment cases, though, a C wrapper is unnecessary overhead when a precise sudoers rule achieves the same result without extra code to maintain. The rule of thumb: sudo for actions another person can review in plain text and version in a text file, a wrapper only for the rare cases where even sudo is too coarse-grained.

6. Linux capabilities as a fine-grained alternative to root

Instead of granting a process blanket root rights, Linux capabilities allow unlocking exactly one specific privileged ability, such as CAP_NET_BIND_SERVICE to bind a port below 1024, without receiving every other root privilege. With setcap, this ability can be bound directly to a binary, which represents a noticeably finer alternative to SUID for compiled programs.

For plain Bash scripts, setcap does not apply directly for the same reason as SUID, since capabilities are also bound to an executable binary, not an interpreted script. Anyone needing capabilities for a script-driven action has to move the actual privileged operation into a small compiled helper and assign it the matching capability, while the Bash script itself stays unprivileged.


# Grant only the ability to bind privileged ports, nothing else
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/http-helper

# Verify which capabilities a binary actually carries
getcap /usr/local/bin/http-helper
# /usr/local/bin/http-helper cap_net_bind_service=ep

7. Making sudo usage traceable: logging and auditing

Every action run through sudo lands in the system log by default, usually via syslog or journald, including timestamp, invoking user, and the exact command. For deployment scripts, it pays off to review these logs deliberately, to spot whether a script gets called with unexpected arguments regularly or whether a sudo rule is more generous than what is actually used.

With sudo -l, any user's currently allowed commands can be checked at any time, which makes an excellent recurring audit step, especially right before a new sudoers rule goes live in production. A rule that, after a few months of logs, was never used with its full argument range should be tightened, instead of staying broad just because that is how it was originally set up.

8. Practical example: a least-privilege deployment setup for Magento

In a typical Magento deployment, the bulk of the work, composer install, static content deploy, cache flush, runs entirely as the owning www-data or deployment user with no elevated rights at all. Only two steps genuinely need elevated privileges: restarting PHP-FPM after an opcache-relevant update, and, occasionally, fixing file ownership after a composer install that accidentally left the wrong owner behind.

For exactly these two cases, two precise sudoers lines are entirely sufficient, without the deployment user ever needing full root rights. This pattern transfers to practically any deployment script: sketch out the full flow without sudo first, then identify only the actually privileged individual steps, and write exactly one sudoers line for each of them.


# /etc/sudoers.d/deploy-magento
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart php8.4-fpm
deploy ALL=(root) NOPASSWD: /usr/bin/chown -R www-data\:www-data /var/www/app/pub/static

#!/usr/bin/env bash
set -euo pipefail
composer install --no-dev --optimize-autoloader
bin/magento setup:static-content:deploy -f
sudo /usr/bin/chown -R www-data:www-data /var/www/app/pub/static
sudo /usr/bin/systemctl restart php8.4-fpm

9. sudo, SUID, and capabilities compared

All three mechanisms solve the same basic problem, giving a process more rights than its invoking user has, but they differ significantly in security, auditability, and fitness for shell scripts. sudo is the only practical, directly usable option for scripts, while SUID stays ineffective on scripts and capabilities only apply to compiled helpers.

The table below summarizes which mechanism fits which use case, and makes clear why sudo with precise rules remains the correct, and only directly effective, choice for the vast majority of Bash deployment scripts.

Mechanism Effective on Bash scripts Granularity Typical use
sudo with a precise rule Yes, directly Per command and argument Individual privileged deployment steps
SUID bit No, ignored by the kernel Not applicable to scripts Only relevant for compiled binaries
Linux capabilities No, binaries only Very fine, per capability Compiled helpers for single privileges
sudoers ALL=(ALL) Yes, but unsafe None, full access Should practically never be used
Compiled C wrapper Yes, with setuid in code Exactly one defined action Rare cases where sudo is too coarse

Mironsoft

Shell automation, DevOps tooling and deployment infrastructure

Shell scripts that hold up in production?

We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.

Code Review

ShellCheck analysis and manual review for critical Bash pattern violations.

Refactoring

Retrofitting error handling, logging and safe file operations.

CI Integration

Wiring ShellCheck and BATS into pipelines and building regression tests.

10. Summary

Least Privilege for Bash Scripts: The Essentials at a Glance

Core rule

Every deployment script should run mostly unprivileged and only elevate individual steps through sudo.

sudoers

Register exact commands with full paths and no wildcards, never use ALL=(ALL) for a deployment user.

SUID myth

chmod u+s on a Bash script has no effect on Linux, the kernel ignores the bit because of the shebang race condition.

Alternatives

For genuine privilege elevation outside sudo, use small compiled C wrappers or Linux capabilities via setcap.

11. FAQ: Least Privilege for Bash Scripts: The Essentials at a Glance

1Why does chmod u+s not work on my Bash script?
The Linux kernel categorically ignores the SUID bit on interpreted scripts, because of a well-known race condition between reading the shebang line and the interpreter actually opening the file.
2How do I safely grant a deployment script elevated rights?
Through a precise sudoers rule that allows exactly the needed commands with full paths, combined with a script that runs mostly unprivileged and only calls individual steps through sudo.
3What is wrong with sudoers ALL=(ALL) NOPASSWD: ALL?
A compromised deployment account immediately inherits full root access to the entire server, instead of staying confined to the actions it genuinely needs.
4Can I use wildcards in sudoers?
Possible, but risky. An entry like /usr/bin/systemctl * effectively grants access to every systemctl subcommand, including potentially dangerous ones like edit or mask.
5What are Linux capabilities and do they help with Bash scripts?
Capabilities unlock individual privileged abilities like binding privileged ports. They are bound to binaries via setcap and therefore do not apply directly to interpreted scripts.
6When is a compiled C wrapper worth it over sudo?
When even a precise sudoers rule is too coarse-grained, or the action contains logic that cannot reasonably be expressed as a plain command with fixed arguments.
7How do I check which sudo rights a user actually has?
With sudo -l as that user, or as an administrator with sudo -l -U username. It lists every sudoers rule configured for that user.
8Do sudo calls end up in the log automatically?
Yes, sudo logs every invocation by default via syslog or journald, including timestamp, invoking user, and the exact command with arguments.
9Is NOPASSWD in sudoers inherently unsafe?
Not by itself, as long as the rule is limited to a single, clearly defined command. NOPASSWD on ALL combined with ALL=(ALL) is the actually risky combination.
10How do I safely test a new sudoers rule?
Always edit with visudo or visudo -f, which checks syntax before saving, then verify the rule with sudo -l as the target user before it goes into production use.