Linux hosts

Scoped sudo rules, the secure way

Someone asked for "just the right to read the app logs", you wrote one friendly line with a star in it, and now that person can read /etc/shadow. Nobody did anything clever. The star did exactly what stars do.

The short answer

Give each role exact commands with exact arguments in a file under /etc/sudoers.d/, checked with visudo -c. Use "" to forbid arguments, sudoedit instead of an editor or cat as root, and NOEXEC for programs that can start other programs. Never allow find, vi, less, bash or a wildcard path as root.

Updated Houssam Hammoudi, CTOTested with sudo 1.9.13p3 (Debian 12)

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

A sudo rule is a promise: "this user may run this command as root". Most leaks come from rules that promise far more than the writer meant.

Two patterns cause most of them. The first is a wildcard in an argument, such as /usr/bin/cat /var/log/app/*. In sudoers, * matches any characters, including ../ and spaces, so the path can climb out of the directory.

The second is a program that can start other programs: find -exec, vi with :!sh, less with !sh, awk, tar, git, env, python. If the program runs as root, the program it starts runs as root too. The rule said "find"; the user got a root shell.

ALL=(ALL) ALL for a whole team is the third, simpler case. It is full root with an extra password prompt.

What the docs say

Wildcards in command line arguments should be used with care. Wildcards can match any character, including white space.

Source: sudoers manual, Wildcards

the NOEXEC tag can be used to prevent a dynamically-linked executable from running further commands itself.

Source: sudoers manual, EXEC and NOEXEC

If the empty string "" is the only command line argument in the sudoers file entry it means that command is not allowed to be run with any arguments.

Source: sudoers manual, Exceptions to wildcard rules

The manual warns about wildcards, but it does not show the ../ climb that turns a log reader into a shadow-file reader. NOEXEC only works for dynamically linked programs, so treat it as a backstop, not as the scope.

The secure configuration

One file per role, under /etc/sudoers.d/, edited with visudo -f so a syntax error never locks out sudo for everyone.

conf
# /etc/sudoers.d/deploy   (mode 0440, owner root; edit with: visudo -f /etc/sudoers.d/deploy)
# deploy may restart the app, read its logs, and edit its config. Nothing else.

# Record what the user typed and saw, so a review is possible later.
Defaults:deploy log_output

# "" means: no arguments allowed at all.
Cmnd_Alias APP_RESTART = /usr/local/sbin/app-restart ""
# Exact arguments, no wildcards; the path cannot be changed.
Cmnd_Alias APP_LOGS    = /usr/bin/tail -n 200 /var/log/app/app.log
# sudoedit copies the file, runs the editor as the user, then writes it back.
# The editor never runs as root, so :!sh gives the user their own shell.
Cmnd_Alias APP_CONFIG  = sudoedit /etc/app/app.conf

deploy ALL=(root) NOPASSWD: APP_RESTART, APP_LOGS, APP_CONFIG

For a program that must be allowed and could start other programs, add NOEXEC: as a second layer:

conf
# Backstop only: NOEXEC stops exec() from dynamically linked programs.
auditor ALL=(root) NOEXEC: /usr/bin/less /var/log/app/app.log
bash
chmod 0440 /etc/sudoers.d/deploy
visudo -c                 # parse every sudoers file before you log out
sudo -l -U deploy         # show exactly what the user can run

Keep Defaults use_pty (Debian 12 sets it by default) so a command cannot push keystrokes into your terminal after sudo exits.

Prove it

Broad rule 1, a wildcard in the path:

bash
echo "deploy ALL=(root) NOPASSWD: /usr/bin/cat /var/log/app/*" > /etc/sudoers.d/deploy
su deploy -c "sudo cat /var/log/app/../../../etc/shadow | head -1"
text
root:*:20714:0:99999:7:::

Broad rule 2, a program that runs programs:

bash
echo "deploy ALL=(root) NOPASSWD: /usr/bin/find" > /etc/sudoers.d/deploy
su deploy -c "sudo find /etc/hostname -exec id \;"
text
uid=0(root) gid=0(root) groups=0(root)

The scoped file from above, checked and listed:

bash
visudo -c
sudo -l -U deploy
text
/etc/sudoers: parsed OK
/etc/sudoers.d/README: parsed OK
/etc/sudoers.d/deploy: parsed OK
Matching Defaults entries for deploy on app-1:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty, log_output

User deploy may run the following commands on app-1:
    (root) NOPASSWD: /usr/local/sbin/app-restart "", /usr/bin/tail -n 200 /var/log/app/app.log, sudoedit /etc/app/app.conf

The allowed commands work. Anything else, including the same program with other arguments, finds no rule; sudo falls back to asking for a password, which deploy does not have:

bash
su deploy -c "sudo /usr/local/sbin/app-restart"
su deploy -c "sudo /usr/bin/tail -n 200 /var/log/app/app.log"
su deploy -c "sudo -n /usr/local/sbin/app-restart --now"
su deploy -c "sudo -n tail -n 200 /var/log/app/../../../etc/shadow"
su deploy -c "sudo -n find /etc/hostname -exec id \;"
text
restarting app
app started
sudo: a password is required
sudo: a password is required
sudo: a password is required

NOEXEC on its own, with find allowed:

bash
echo "deploy ALL=(root) NOPASSWD: NOEXEC: /usr/bin/find" > /etc/sudoers.d/zz-noexec-demo
su deploy -c "sudo -n find /etc/hostname -exec id \;"
text
find: 'id': Permission denied

The test script is secure-tests/scoped-sudo-rules/run.sh.

Mistakes people make

The friendly star

/var/log/app/* reads as "files in that folder". sudo reads it as "any characters", and ../ is characters. Name the exact file, or use a small root-owned script that takes no arguments.

Allowing an editor or a pager as root

sudo vi file, sudo less file and sudo nano file all offer a way to run a command from inside. Use sudoedit for editing. For reading, allow tail or cat with a fixed path.

Editing /etc/sudoers directly

One typo and sudo refuses to run for anyone. Use visudo or visudo -f, which checks the syntax before saving, and run visudo -c after any change.

Wrapper scripts that trust their input

A root-owned script is only as safe as its arguments. If a script takes a service name, check it against a fixed list. Keep it root-owned and not writable by the user, or the user edits the script and runs anything.

NOPASSWD: ALL for automation

A deploy bot with NOPASSWD: ALL is a root account whose key sits in CI. Give it the exact commands it runs, the same as a person.

Checklist

  • No rule grants ALL commands except to a small admin group.
  • No rule has a wildcard in a command argument.
  • No rule allows an editor, pager, shell, interpreter, find, tar, env or git as root.
  • File edits go through sudoedit with an exact path.
  • Commands that must take no arguments end with "".
  • Every file in /etc/sudoers.d/ is mode 0440 and owned by root.
  • visudo -c reports parsed OK for every file.
  • sudo -l -U <user> matches what the role needs, and nothing more.
  • Scripts named in sudo rules are owned by root and not writable by others.

A good sudo rule reads like a receipt, not a wish. If you cannot say in one line what it lets someone do, it lets them do too much.

FND

Learn it on a live range

Linux 1: the command line to a working, secure host, in Foundation: a real host in your browser, and every objective checked on the machine.

Start free

The Secure Way

More on linux hosts

SSH, sudo, firewalls, mount options, updates and the host basics every engineer should get right.

All linux hosts guides