Skip main navigation
/user/kayd @ devops :~$ cat sudo-mastery-and-best-practices.md

Linux Access Control: From sudo to a Go Security Scanner Linux Access Control: From sudo to a Go Security Scanner

QR Code linking to: Linux Access Control: From sudo to a Go Security Scanner
Karandeep Singh
Karandeep Singh
• 39 minutes

Summary

Linux access control from command line to Go code — each step shows the Linux command first, then builds it in Go. Parse sudoers, audit permissions, build a security scanner that flags dangerous configs.

Every production outage caused by permissions follows the same pattern — someone had access they should not have, or a file had permissions that were too open. A private key at 0644 means anyone on the box can read it. A sudoers entry with NOPASSWD: ALL means a compromised service account becomes root. Linux gives you powerful tools to control who can do what. We will learn access control from basic commands to building a Go tool that audits your entire system.

Prerequisites

  • A Linux system (native, WSL, or SSH to a server)
  • Go 1.21+ installed
  • A user account with sudo access (for testing)

Step 1: Who Are You? (Users and Groups)

Before you can control access, you need to know who you are on the system. Every file and every process is owned by a user and a group. Linux checks these to decide what you can do.

Start with the basics:

id
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),27(sudo),999(docker)

That tells you everything — your user ID, your primary group, and every group you belong to. The sudo group means you can run commands as root. The docker group means you can use Docker without sudo.

A few more useful commands:

whoami
deploy
groups
deploy sudo docker

The user database is /etc/passwd. Each line is one user:

cat /etc/passwd | grep deploy
deploy:x:1001:1001:Deploy User:/home/deploy:/bin/bash

The fields are: username:password:uid:gid:comment:home:shell. The x in the password field means the actual password hash is in /etc/shadow (which only root can read).

Check who is in the sudo group:

cat /etc/group | grep sudo
sudo:x:27:deploy,admin

Two users can sudo: deploy and admin. If an attacker compromises either account, they get root.

Now let’s get this same info in Go. Create your project:

mkdir go-access-audit && cd go-access-audit
go mod init go-access-audit

main.go

package main

import (
	"fmt"
	"os/user"
	"strings"
)

func main() {
	u, err := user.Current()
	if err != nil {
		fmt.Printf("error getting current user: %v\n", err)
		return
	}

	fmt.Printf("Username: %s\n", u.Username)
	fmt.Printf("UID:      %s\n", u.Uid)
	fmt.Printf("GID:      %s\n", u.Gid)
	fmt.Printf("Home:     %s\n", u.HomeDir)

	groupIDs, err := u.GroupIds()
	if err != nil {
		fmt.Printf("error getting groups: %v\n", err)
		return
	}

	fmt.Printf("Groups:   ")
	names := []string{}
	for _, gid := range groupIDs {
		g, err := user.LookupGroupId(gid)
		if err != nil {
			names = append(names, gid)
			continue
		}
		names = append(names, g.Name)
	}
	fmt.Println(strings.Join(names, ", "))
}
go run main.go
Username: deploy
UID:      1001
GID:      1001
Home:     /home/deploy
Groups:   deploy, sudo, docker

Same info as id, but now we can use it in a larger program. Let’s try looking up another user.

main.go — updated (has a bug):

package main

import (
	"fmt"
	"os/user"
	"strings"
)

func main() {
	u, err := user.Current()
	if err != nil {
		fmt.Printf("error getting current user: %v\n", err)
		return
	}

	fmt.Printf("Username: %s\n", u.Username)
	fmt.Printf("UID:      %s\n", u.Uid)
	fmt.Printf("GID:      %s\n", u.Gid)

	groupIDs, err := u.GroupIds()
	if err != nil {
		fmt.Printf("error getting groups: %v\n", err)
		return
	}

	names := []string{}
	for _, gid := range groupIDs {
		g, _ := user.LookupGroupId(gid)
		names = append(names, g.Name)
	}
	fmt.Printf("Groups:   %s\n", strings.Join(names, ", "))

	// Look up another user
	other, _ := user.Lookup("nonexistent")
	fmt.Printf("\nOther user: %s (uid %s)\n", other.Username, other.Uid)
}
go run main.go
Username: deploy
UID:      1001
GID:      1001
Groups:   deploy, sudo, docker

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation]

goroutine 1 [running]:
main.main()
        /home/deploy/go-access-audit/main.go:33 +0x3a4
exit status 2

The program crashes. user.Lookup("nonexistent") returns nil for the user and an error. We ignored the error with _ and then tried to access other.Username on a nil pointer. This is the single most common Go bug.

The fix: Always check the error before using the result.

main.go — fixed:

package main

import (
	"fmt"
	"os/user"
	"strings"
)

func main() {
	u, err := user.Current()
	if err != nil {
		fmt.Printf("error getting current user: %v\n", err)
		return
	}

	fmt.Printf("Username: %s\n", u.Username)
	fmt.Printf("UID:      %s\n", u.Uid)
	fmt.Printf("GID:      %s\n", u.Gid)

	groupIDs, err := u.GroupIds()
	if err != nil {
		fmt.Printf("error getting groups: %v\n", err)
		return
	}

	names := []string{}
	for _, gid := range groupIDs {
		g, err := user.LookupGroupId(gid)
		if err != nil {
			names = append(names, gid)
			continue
		}
		names = append(names, g.Name)
	}
	fmt.Printf("Groups:   %s\n", strings.Join(names, ", "))

	// Look up another user — check the error first
	other, err := user.Lookup("nonexistent")
	if err != nil {
		fmt.Printf("\nUser 'nonexistent' not found: %v\n", err)
		return
	}
	fmt.Printf("\nOther user: %s (uid %s)\n", other.Username, other.Uid)
}
go run main.go
Username: deploy
UID:      1001
GID:      1001
Groups:   deploy, sudo, docker

User 'nonexistent' not found: user: unknown user nonexistent

No crash. The pattern is simple: check the error, then use the result. Every Go function that can fail returns an error. Never ignore it with _ unless you genuinely do not care if it fails.

Step 2: File Permissions Decoded

Linux file permissions decide who can read, write, and execute every file on the system. Get them wrong on a production server and you either lock out your deploy process or expose secrets to every user.

Start with a restrictive file:

ls -la /etc/shadow
-rw-r----- 1 root shadow 1234 Feb 15 10:00 /etc/shadow

That permission string -rw-r----- breaks down like this:

PositionMeaning
-File type (- = regular, d = directory, l = symlink)
rw-Owner can read and write, not execute
r--Group can read only
---Others can do nothing

In octal, that is 0640. The owner (root) has 6 (read + write), the group (shadow) has 4 (read), others have 0 (nothing).

Here is the octal math:

PermissionValue
read (r)4
write (w)2
execute (x)1

So rwx = 4+2+1 = 7, rw- = 4+2 = 6, r-- = 4, --- = 0.

Common octal values you see on servers:

OctalSymbolicUsed for
0755-rwxr-xr-xDirectories, executable scripts
0644-rw-r--r--Config files, HTML files
0600-rw-------Private keys, secrets
0400-r--------Read-only secrets (extra paranoid)

Now the sticky bit and special permissions. Check /tmp:

ls -la / | grep tmp
drwxrwxrwt  12 root root 4096 Feb 15 10:00 tmp

See that t at the end? That is the sticky bit. It means anyone can create files in /tmp, but only the owner of a file can delete it. Without the sticky bit, any user could delete any other user’s files in a shared directory.

The stat command gives you both octal and symbolic:

stat /etc/passwd
  File: /etc/passwd
  Size: 2345       Blocks: 8          IO Block: 4096   regular file
Access: (0644/-rw-r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
Modify: 2026-02-10 08:30:00.000000000 +0000

Special permissions are dangerous. The most important one is setuid (4xxx). When a setuid binary runs, it runs as the file owner, not the user who launched it. The sudo binary itself is setuid root — that is how a normal user can run commands as root.

ls -la /usr/bin/sudo
-rwsr-xr-x 1 root root 232416 Feb 10 10:00 /usr/bin/sudo

That s in rws means setuid is set. Every user who runs /usr/bin/sudo is temporarily running as root. This is expected for sudo and passwd. But a setuid binary that you did not install is a red flag.

Now let’s decode permissions in Go. Write a program that works like stat:

main.go — updated:

package main

import (
	"fmt"
	"os"
	"os/user"
	"strconv"
	"syscall"
	"time"
)

func symbolicPermissions(mode os.FileMode) string {
	var buf [10]byte

	// File type
	switch {
	case mode.IsDir():
		buf[0] = 'd'
	case mode&os.ModeSymlink != 0:
		buf[0] = 'l'
	default:
		buf[0] = '-'
	}

	// Owner
	const rwx = "rwx"
	for i, c := range rwx {
		if mode&(1<<uint(8-i)) != 0 {
			buf[1+i] = byte(c)
		} else {
			buf[1+i] = '-'
		}
	}

	// Group
	for i, c := range rwx {
		if mode&(1<<uint(5-i)) != 0 {
			buf[4+i] = byte(c)
		} else {
			buf[4+i] = '-'
		}
	}

	// Other
	for i, c := range rwx {
		if mode&(1<<uint(2-i)) != 0 {
			buf[7+i] = byte(c)
		} else {
			buf[7+i] = '-'
		}
	}

	// Setuid, setgid, sticky
	if mode&os.ModeSetuid != 0 {
		if buf[3] == 'x' {
			buf[3] = 's'
		} else {
			buf[3] = 'S'
		}
	}
	if mode&os.ModeSetgid != 0 {
		if buf[6] == 'x' {
			buf[6] = 's'
		} else {
			buf[6] = 'S'
		}
	}
	if mode&os.ModeSticky != 0 {
		if buf[9] == 'x' {
			buf[9] = 't'
		} else {
			buf[9] = 'T'
		}
	}

	return string(buf[:])
}

func main() {
	if len(os.Args) < 2 {
		fmt.Println("usage: go run main.go <filepath>")
		return
	}
	path := os.Args[1]

	info, err := os.Stat(path)
	if err != nil {
		fmt.Printf("error: %v\n", err)
		return
	}

	mode := info.Mode()
	octal := fmt.Sprintf("0%s", strconv.FormatUint(uint64(mode.Perm()), 8))
	symbolic := symbolicPermissions(mode)

	// Get owner and group from syscall
	stat := info.Sys().(*syscall.Stat_t)
	ownerUser, _ := user.LookupId(strconv.Itoa(int(stat.Uid)))
	ownerGroup, _ := user.LookupGroupId(strconv.Itoa(int(stat.Gid)))

	ownerName := strconv.Itoa(int(stat.Uid))
	groupName := strconv.Itoa(int(stat.Gid))
	if ownerUser != nil {
		ownerName = ownerUser.Username
	}
	if ownerGroup != nil {
		groupName = ownerGroup.Name
	}

	fmt.Printf("  File: %s\n", path)
	fmt.Printf("  Size: %-10d Type: %s\n", info.Size(), fileType(mode))
	fmt.Printf("Access: (%s/%s)  Uid: (%s)  Gid: (%s)\n", octal, symbolic, ownerName, groupName)
	fmt.Printf("Modify: %s\n", info.ModTime().Format(time.RFC3339))

	// Check special bits
	if mode&os.ModeSetuid != 0 {
		fmt.Println("  WARNING: setuid bit is set")
	}
	if mode&os.ModeSetgid != 0 {
		fmt.Println("  WARNING: setgid bit is set")
	}
	if mode&os.ModeSticky != 0 {
		fmt.Println("  NOTE: sticky bit is set")
	}
}

func fileType(mode os.FileMode) string {
	switch {
	case mode.IsDir():
		return "directory"
	case mode.IsRegular():
		return "regular file"
	case mode&os.ModeSymlink != 0:
		return "symbolic link"
	default:
		return "other"
	}
}
go run main.go /etc/passwd
  File: /etc/passwd
  Size: 2345       Type: regular file
Access: (0644/-rw-r--r--)  Uid: (root)  Gid: (root)
Modify: 2026-02-10T08:30:00Z
go run main.go /usr/bin/sudo
  File: /usr/bin/sudo
  Size: 232416     Type: regular file
Access: (0755/-rwsr-xr-x)  Uid: (root)  Gid: (root)
Modify: 2026-02-10T10:00:00Z
  WARNING: setuid bit is set

Now let’s try a file we cannot read:

main.go — run on a protected file (has a bug):

go run main.go /etc/shadow
error: stat /etc/shadow: permission denied

This works because we checked the error. But what if we want to do more than stat — what if we try to open and read the file?

main.go — updated (has a bug):

package main

import (
	"fmt"
	"io"
	"os"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Println("usage: go run main.go <filepath>")
		return
	}

	file, err := os.Open(os.Args[1])
	if err != nil {
		// BUG: generic error message, doesn't help the user
		fmt.Printf("something went wrong: %v\n", err)
		os.Exit(1)
	}
	defer file.Close()

	data, _ := io.ReadAll(file)
	fmt.Printf("read %d bytes\n", len(data))
}
go run main.go /etc/shadow
something went wrong: open /etc/shadow: permission denied
exit status 1

It works, but the error handling is poor. The user does not know if the file does not exist, if they do not have permission, or if something else failed. Different errors need different responses — a missing file means you have the wrong path, a permission error means you need sudo, and any other error means something unexpected happened.

main.go — fixed:

package main

import (
	"errors"
	"fmt"
	"io"
	"os"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Println("usage: go run main.go <filepath>")
		return
	}

	file, err := os.Open(os.Args[1])
	if err != nil {
		if os.IsPermission(err) {
			fmt.Printf("access denied: %s (try running with sudo)\n", os.Args[1])
		} else if errors.Is(err, os.ErrNotExist) {
			fmt.Printf("file not found: %s\n", os.Args[1])
		} else {
			fmt.Printf("unexpected error: %v\n", err)
		}
		os.Exit(1)
	}
	defer file.Close()

	data, err := io.ReadAll(file)
	if err != nil {
		fmt.Printf("error reading file: %v\n", err)
		os.Exit(1)
	}
	fmt.Printf("read %d bytes\n", len(data))
}
go run main.go /etc/shadow
access denied: /etc/shadow (try running with sudo)
go run main.go /etc/doesnotexist
file not found: /etc/doesnotexist

The pattern: use os.IsPermission(err) to check for access denied and errors.Is(err, os.ErrNotExist) to check for missing files. Your program gives the user a clear next step instead of a generic error dump.

Step 3: sudo and the Sudoers File

The sudo command is how you run things as root without logging in as root. Every time you type sudo, Linux checks the sudoers file to see if you are allowed.

Start by listing what you can do:

sudo -l
Matching Defaults entries for deploy on server:
    env_reset, mail_badpass, secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

User deploy may run the following commands on server:
    (ALL : ALL) ALL

That (ALL : ALL) ALL means the deploy user can run any command as any user on any host. This is the default for users in the sudo group. It works, but it is too much access for most service accounts.

Look at the sudoers file itself:

sudo cat /etc/sudoers
# /etc/sudoers
#
# This file MUST be edited with 'visudo' command as root.

Defaults   env_reset
Defaults   mail_badpass
Defaults   secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

# User privilege specification
root    ALL=(ALL:ALL) ALL

# Allow members of group sudo to execute any command
%sudo   ALL=(ALL:ALL) ALL

# See sudoers(5) for more information on "#include" directives:
#includedir /etc/sudoers.d

The sudoers syntax:

who    where=(as_who) what
  • root ALL=(ALL:ALL) ALL — root can run any command, as any user, from any host
  • %sudo ALL=(ALL:ALL) ALL — anyone in the sudo group gets the same access (the % means it is a group)

Practical examples you will see on production servers:

# deploy user can restart nginx without a password
deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart nginx

# devops group can use docker without a password
%devops ALL=(ALL) NOPASSWD: /usr/bin/docker

# dbadmin can only run pg_dump, nothing else
dbadmin ALL=(postgres) NOPASSWD: /usr/bin/pg_dump

NOPASSWD is convenient but dangerous. If an attacker gets shell access as the deploy user, they can restart nginx without knowing any password. That is why you limit NOPASSWD to specific commands. Never do this:

# DANGEROUS: deploy can run anything as root without a password
deploy ALL=(ALL) NOPASSWD: ALL

One more thing — never edit /etc/sudoers with a normal editor. Use visudo:

sudo visudo

visudo checks for syntax errors before saving. A syntax error in sudoers locks everyone out of sudo. You would need to boot into recovery mode or use a console to fix it. On a remote server with no console access, that means a support ticket and downtime.

Now let’s parse the sudoers file in Go. Since we cannot read /etc/sudoers without root, we will use sudo to copy it to a temp file first:

sudo cp /etc/sudoers /tmp/sudoers_copy
sudo chmod 644 /tmp/sudoers_copy

main.go — updated (has a bug):

package main

import (
	"bufio"
	"fmt"
	"os"
	"strings"
)

type SudoersEntry struct {
	Who      string
	Where    string
	AsWho    string
	Commands string
	NoPasswd bool
	IsGroup  bool
}

func parseSudoers(path string) ([]SudoersEntry, error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	var entries []SudoersEntry
	scanner := bufio.NewScanner(file)

	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())

		// Skip empty lines and comments
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}

		// Skip Defaults lines
		if strings.HasPrefix(line, "Defaults") {
			continue
		}

		entry, ok := parseLine(line)
		if ok {
			entries = append(entries, entry)
		}
	}

	return entries, scanner.Err()
}

func parseLine(line string) (SudoersEntry, bool) {
	// Format: who where=(as_who) commands
	parts := strings.Fields(line)
	if len(parts) < 3 {
		return SudoersEntry{}, false
	}

	entry := SudoersEntry{Who: parts[0]}
	if strings.HasPrefix(entry.Who, "%") {
		entry.IsGroup = true
		entry.Who = strings.TrimPrefix(entry.Who, "%")
	}

	// Find the host=(...) part
	for i, p := range parts[1:] {
		if strings.Contains(p, "=(") {
			halves := strings.SplitN(p, "=(", 2)
			entry.Where = halves[0]
			entry.AsWho = strings.TrimRight(halves[1], ")")

			cmds := strings.Join(parts[i+2:], " ")
			if strings.Contains(cmds, "NOPASSWD:") {
				entry.NoPasswd = true
				cmds = strings.Replace(cmds, "NOPASSWD:", "", 1)
			}
			entry.Commands = strings.TrimSpace(cmds)
			return entry, true
		}
	}

	return SudoersEntry{}, false
}

func main() {
	path := "/tmp/sudoers_copy"
	if len(os.Args) > 1 {
		path = os.Args[1]
	}

	entries, err := parseSudoers(path)
	if err != nil {
		fmt.Printf("error: %v\n", err)
		return
	}

	fmt.Println("Sudoers Summary")
	fmt.Println("================")

	for _, e := range entries {
		who := e.Who
		if e.IsGroup {
			who = "%" + who + " (group)"
		}

		passwd := "password required"
		if e.NoPasswd {
			passwd = "NOPASSWD"
		}

		fmt.Printf("  %-20s  as %-15s  commands: %-20s  [%s]\n",
			who, e.AsWho, e.Commands, passwd)
	}
}
go run main.go /tmp/sudoers_copy
Sudoers Summary
================
  root                  as ALL:ALL          commands: ALL                   [password required]
  %sudo (group)         as ALL:ALL          commands: ALL                   [password required]

Looks correct. But there is a problem. Look at the last line of the real sudoers file:

#includedir /etc/sudoers.d

Our parser skipped it because it starts with #, and we treat # as a comment. But #include and #includedir are NOT comments in sudoers. They are directives that pull in additional config files. Many production servers put per-user rules in /etc/sudoers.d/:

ls /etc/sudoers.d/
deploy-nginx   monitoring   cicd-deploy
sudo cat /etc/sudoers.d/deploy-nginx
deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart nginx, /bin/systemctl reload nginx

Our parser misses all of these files. On a production server, the most important rules are often in /etc/sudoers.d/, not in the main sudoers file.

main.go — fixed:

package main

import (
	"bufio"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

type SudoersEntry struct {
	Who      string
	Where    string
	AsWho    string
	Commands string
	NoPasswd bool
	IsGroup  bool
	Source   string
}

func parseSudoers(path string) ([]SudoersEntry, []string, error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, nil, err
	}
	defer file.Close()

	var entries []SudoersEntry
	var includeDirs []string
	scanner := bufio.NewScanner(file)

	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())

		// Handle #include and #includedir — these are NOT comments
		if strings.HasPrefix(line, "#includedir ") {
			dir := strings.TrimPrefix(line, "#includedir ")
			includeDirs = append(includeDirs, dir)
			continue
		}
		if strings.HasPrefix(line, "#include ") {
			includeFile := strings.TrimPrefix(line, "#include ")
			subEntries, subDirs, err := parseSudoers(includeFile)
			if err == nil {
				entries = append(entries, subEntries...)
				includeDirs = append(includeDirs, subDirs...)
			}
			continue
		}
		if strings.HasPrefix(line, "@includedir ") {
			dir := strings.TrimPrefix(line, "@includedir ")
			includeDirs = append(includeDirs, dir)
			continue
		}
		if strings.HasPrefix(line, "@include ") {
			includeFile := strings.TrimPrefix(line, "@include ")
			subEntries, subDirs, err := parseSudoers(includeFile)
			if err == nil {
				entries = append(entries, subEntries...)
				includeDirs = append(includeDirs, subDirs...)
			}
			continue
		}

		// Skip comments and empty lines
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}

		// Skip Defaults lines
		if strings.HasPrefix(line, "Defaults") {
			continue
		}

		entry, ok := parseLine(line)
		if ok {
			entry.Source = path
			entries = append(entries, entry)
		}
	}

	// Process include directories
	for _, dir := range includeDirs {
		dirEntries, err := parseIncludeDir(dir)
		if err == nil {
			entries = append(entries, dirEntries...)
		}
	}

	return entries, includeDirs, scanner.Err()
}

func parseIncludeDir(dir string) ([]SudoersEntry, error) {
	files, err := filepath.Glob(filepath.Join(dir, "*"))
	if err != nil {
		return nil, err
	}

	var allEntries []SudoersEntry
	for _, f := range files {
		// sudoers.d skips files with . or ~ in the name
		base := filepath.Base(f)
		if strings.Contains(base, ".") || strings.HasSuffix(base, "~") {
			continue
		}

		entries, _, err := parseSudoers(f)
		if err != nil {
			continue
		}
		allEntries = append(allEntries, entries...)
	}
	return allEntries, nil
}

func parseLine(line string) (SudoersEntry, bool) {
	parts := strings.Fields(line)
	if len(parts) < 3 {
		return SudoersEntry{}, false
	}

	entry := SudoersEntry{Who: parts[0]}
	if strings.HasPrefix(entry.Who, "%") {
		entry.IsGroup = true
		entry.Who = strings.TrimPrefix(entry.Who, "%")
	}

	for i, p := range parts[1:] {
		if strings.Contains(p, "=(") {
			halves := strings.SplitN(p, "=(", 2)
			entry.Where = halves[0]
			entry.AsWho = strings.TrimRight(halves[1], ")")

			cmds := strings.Join(parts[i+2:], " ")
			if strings.Contains(cmds, "NOPASSWD:") {
				entry.NoPasswd = true
				cmds = strings.Replace(cmds, "NOPASSWD:", "", 1)
			}
			entry.Commands = strings.TrimSpace(cmds)
			return entry, true
		}
	}

	return SudoersEntry{}, false
}

func main() {
	path := "/tmp/sudoers_copy"
	if len(os.Args) > 1 {
		path = os.Args[1]
	}

	entries, includeDirs, err := parseSudoers(path)
	if err != nil {
		fmt.Printf("error: %v\n", err)
		return
	}

	fmt.Println("Sudoers Summary")
	fmt.Println("================")

	for _, e := range entries {
		who := e.Who
		if e.IsGroup {
			who = "%" + who + " (group)"
		}

		passwd := "password required"
		if e.NoPasswd {
			passwd = "NOPASSWD"
		}

		fmt.Printf("  %-20s  as %-15s  commands: %-20s  [%s]  (from %s)\n",
			who, e.AsWho, e.Commands, passwd, e.Source)
	}

	if len(includeDirs) > 0 {
		fmt.Printf("\nInclude dirs scanned: %s\n", strings.Join(includeDirs, ", "))
	}
}
go run main.go /tmp/sudoers_copy
Sudoers Summary
================
  root                  as ALL:ALL          commands: ALL                   [password required]  (from /tmp/sudoers_copy)
  %sudo (group)         as ALL:ALL          commands: ALL                   [password required]  (from /tmp/sudoers_copy)
  deploy                as ALL              commands: /bin/systemctl restart nginx, /bin/systemctl reload nginx  [NOPASSWD]  (from /etc/sudoers.d/deploy-nginx)

Include dirs scanned: /etc/sudoers.d

Now we see the full picture. The parser handles both #includedir (old syntax) and @includedir (new syntax, used in newer sudo versions). It scans all files in the include directory and skips files with dots or tildes in the name, matching sudo’s own behavior.

The key lesson: #include and #includedir look like comments but are not. This is a well-known gotcha in sudoers parsing. If your audit tool skips these, you miss most of the rules on a modern system.

Step 4: Find Dangerous Permissions

Some file permissions are red flags. Setuid binaries run as root even when a normal user executes them. World-writable files can be modified by anyone. Private keys with open permissions mean anyone on the box can read them and SSH will refuse to use them.

Find all setuid files on the system:

find / -perm -4000 -type f 2>/dev/null
/usr/bin/sudo
/usr/bin/passwd
/usr/bin/chfn
/usr/bin/chsh
/usr/bin/gpasswd
/usr/bin/newgrp
/usr/bin/su
/usr/bin/mount
/usr/bin/umount
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/lib/openssh/ssh-keysign

All of these are expected on a standard system. If you see something like /usr/local/bin/custom-tool in that list, investigate immediately. Someone either installed something with setuid on purpose (bad practice) or an attacker planted a backdoor.

Find setgid files:

find / -perm -2000 -type f 2>/dev/null

Find world-writable files (not in /tmp or /proc):

find / -perm -0002 -type f ! -path "/tmp/*" ! -path "/proc/*" ! -path "/sys/*" 2>/dev/null

Find private keys with bad permissions:

find /home -name "*.pem" -o -name "*.key" -o -name "id_rsa" -o -name "id_ed25519" 2>/dev/null

Then check each one:

stat -c "%a %n" /home/deploy/.ssh/id_rsa
600 /home/deploy/.ssh/id_rsa

600 is correct. If it says 644 or 755, SSH will refuse to use the key and print a warning:

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@         WARNING: UNPROTECTED PRIVATE KEY FILE!          @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@

Now let’s build a permission scanner in Go:

main.go — updated (has a bug):

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

type Finding struct {
	Path     string
	Mode     os.FileMode
	Severity string
	Reason   string
}

func scanPermissions(root string) ([]Finding, error) {
	var findings []Finding

	err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		if info.IsDir() {
			return nil
		}

		mode := info.Mode()

		// Check setuid
		if mode&os.ModeSetuid != 0 {
			findings = append(findings, Finding{
				Path:     path,
				Mode:     mode,
				Severity: "WARNING",
				Reason:   "setuid bit set — runs as file owner",
			})
		}

		// Check setgid
		if mode&os.ModeSetgid != 0 {
			findings = append(findings, Finding{
				Path:     path,
				Mode:     mode,
				Severity: "WARNING",
				Reason:   "setgid bit set",
			})
		}

		// Check world-writable
		if mode.Perm()&0002 != 0 {
			findings = append(findings, Finding{
				Path:     path,
				Mode:     mode,
				Severity: "CRITICAL",
				Reason:   "world-writable — any user can modify this file",
			})
		}

		// Check private keys with bad permissions
		name := strings.ToLower(info.Name())
		isKey := strings.HasSuffix(name, ".pem") ||
			strings.HasSuffix(name, ".key") ||
			name == "id_rsa" ||
			name == "id_ed25519" ||
			name == "id_ecdsa"

		if isKey && mode.Perm() > 0o600 {
			findings = append(findings, Finding{
				Path:     path,
				Mode:     mode,
				Severity: "CRITICAL",
				Reason:   fmt.Sprintf("private key with permissions %04o — should be 0600", mode.Perm()),
			})
		}

		return nil
	})

	return findings, err
}

func main() {
	root := "/"
	if len(os.Args) > 1 {
		root = os.Args[1]
	}

	fmt.Printf("Scanning %s for dangerous permissions...\n\n", root)

	findings, err := scanPermissions(root)
	if err != nil {
		fmt.Printf("error: %v\n", err)
		return
	}

	for _, f := range findings {
		fmt.Printf("[%s] %04o %s\n         %s\n\n",
			f.Severity, f.Mode.Perm(), f.Path, f.Reason)
	}

	fmt.Printf("Total findings: %d\n", len(findings))
}
go run main.go /

It starts scanning and then hangs. Or rather, it does not hang — it is scanning every file on the entire filesystem. That is millions of files. It also crashes when it hits a directory it cannot read:

error: open /root/.ssh: permission denied

Two problems: the scan is far too broad, and a single permission denied error stops the whole walk.

main.go — fixed:

package main

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

type Finding struct {
	Path     string
	Mode     os.FileMode
	Severity string
	Reason   string
}

// Expected setuid binaries on a standard system
var expectedSetuid = map[string]bool{
	"/usr/bin/sudo":    true,
	"/usr/bin/passwd":  true,
	"/usr/bin/chfn":    true,
	"/usr/bin/chsh":    true,
	"/usr/bin/gpasswd": true,
	"/usr/bin/newgrp":  true,
	"/usr/bin/su":      true,
	"/usr/bin/mount":   true,
	"/usr/bin/umount":  true,
}

func scanPermissions(root string, maxDepth int) ([]Finding, int) {
	var findings []Finding
	baseDepth := strings.Count(filepath.Clean(root), string(os.PathSeparator))
	skipped := 0

	filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			// Cannot read this directory — skip it, do not crash
			if info != nil && info.IsDir() {
				skipped++
				return filepath.SkipDir
			}
			return nil
		}

		// Enforce depth limit
		currentDepth := strings.Count(filepath.Clean(path), string(os.PathSeparator))
		if info.IsDir() && (currentDepth-baseDepth) > maxDepth {
			return filepath.SkipDir
		}

		// Skip non-regular files
		if !info.Mode().IsRegular() {
			return nil
		}

		mode := info.Mode()

		// Check setuid
		if mode&os.ModeSetuid != 0 {
			severity := "INFO"
			reason := "setuid bit set (expected system binary)"
			if !expectedSetuid[path] {
				severity = "CRITICAL"
				reason = "UNEXPECTED setuid binary — investigate immediately"
			}
			findings = append(findings, Finding{
				Path:     path,
				Mode:     mode,
				Severity: severity,
				Reason:   reason,
			})
		}

		// Check setgid
		if mode&os.ModeSetgid != 0 {
			findings = append(findings, Finding{
				Path:     path,
				Mode:     mode,
				Severity: "WARNING",
				Reason:   "setgid bit set",
			})
		}

		// Check world-writable
		if mode.Perm()&0002 != 0 {
			findings = append(findings, Finding{
				Path:     path,
				Mode:     mode,
				Severity: "CRITICAL",
				Reason:   "world-writable — any user can modify this file",
			})
		}

		// Check private keys with bad permissions
		name := strings.ToLower(info.Name())
		isKey := strings.HasSuffix(name, ".pem") ||
			strings.HasSuffix(name, ".key") ||
			name == "id_rsa" ||
			name == "id_ed25519" ||
			name == "id_ecdsa"

		if isKey && mode.Perm() > 0o600 {
			findings = append(findings, Finding{
				Path:     path,
				Mode:     mode,
				Severity: "CRITICAL",
				Reason:   fmt.Sprintf("private key at %04o — must be 0600 or SSH refuses it", mode.Perm()),
			})
		}

		return nil
	})

	return findings, skipped
}

func main() {
	// Only scan paths that matter for security
	scanPaths := []string{
		"/usr/bin",
		"/usr/sbin",
		"/usr/local/bin",
		"/usr/local/sbin",
		"/home",
		"/etc",
		"/opt",
	}

	if len(os.Args) > 1 {
		scanPaths = os.Args[1:]
	}

	fmt.Println("Permission Scanner")
	fmt.Println("==================")

	totalFindings := 0
	totalSkipped := 0

	for _, root := range scanPaths {
		info, err := os.Stat(root)
		if err != nil || !info.IsDir() {
			continue
		}

		findings, skipped := scanPermissions(root, 5)
		totalSkipped += skipped

		if len(findings) > 0 {
			fmt.Printf("\n[%s]\n", root)
			for _, f := range findings {
				fmt.Printf("  [%s] %04o %s\n           %s\n",
					f.Severity, f.Mode.Perm(), f.Path, f.Reason)
			}
			totalFindings += len(findings)
		}
	}

	fmt.Printf("\n--- Results ---\n")
	fmt.Printf("Total findings: %d\n", totalFindings)
	fmt.Printf("Directories skipped (permission denied): %d\n", totalSkipped)
}
go run main.go
Permission Scanner
==================

[/usr/bin]
  [INFO] 4755 /usr/bin/sudo
           setuid bit set (expected system binary)
  [INFO] 4755 /usr/bin/passwd
           setuid bit set (expected system binary)
  [INFO] 4755 /usr/bin/su
           setuid bit set (expected system binary)

[/usr/local/bin]
  [CRITICAL] 4755 /usr/local/bin/custom-tool
           UNEXPECTED setuid binary — investigate immediately

[/home]
  [CRITICAL] 0644 /home/deploy/.ssh/id_rsa
           private key at 0644 — must be 0600 or SSH refuses it

[/etc]
  [CRITICAL] 0666 /etc/app-config.tmp
           world-writable — any user can modify this file

--- Results ---
Total findings: 6
Directories skipped (permission denied): 3

Three fixes made this usable:

  1. When filepath.Walk hits a permission denied error on a directory, we return filepath.SkipDir instead of stopping the entire scan.
  2. We added a depth limit of 5 levels to keep the scan fast.
  3. We only scan directories that matter: /usr/bin, /home, /etc, and similar. No need to scan /proc or /sys.

The scanner also distinguishes between expected setuid binaries (like sudo and passwd) and unexpected ones. Any setuid binary not on the known list gets flagged as CRITICAL.

Step 5: Audit sudo Usage from Logs

Every sudo command gets logged. On Debian and Ubuntu, the logs go to /var/log/auth.log. On RHEL and CentOS, they go to /var/log/secure. Systemd-based systems also log to the journal.

Check recent sudo usage:

sudo grep "sudo:" /var/log/auth.log | tail -10
Feb 15 14:30:01 server sudo:   deploy : TTY=pts/0 ; PWD=/home/deploy ; USER=root ; COMMAND=/bin/systemctl restart nginx
Feb 15 14:35:22 server sudo:   deploy : TTY=pts/0 ; PWD=/home/deploy ; USER=root ; COMMAND=/usr/bin/apt update
Feb 15 15:01:00 server sudo:   admin : TTY=pts/1 ; PWD=/root ; USER=root ; COMMAND=/bin/cat /etc/shadow
Feb 15 03:14:22 server sudo:   guest : 3 incorrect password attempts ; TTY=pts/2 ; PWD=/home/guest ; USER=root ; COMMAND=/bin/bash

That last line is suspicious. The guest user tried to run sudo bash at 3:14 AM and failed three times. That could be a brute force attempt.

With systemd journal:

sudo journalctl _COMM=sudo --since "1 hour ago" --no-pager

Check for failed attempts specifically:

sudo grep "FAILED\|incorrect" /var/log/auth.log | tail -10

Now let’s build a sudo log parser in Go:

main.go — updated (has a bug):

package main

import (
	"bufio"
	"fmt"
	"os"
	"regexp"
	"sort"
	"strings"
)

type SudoEvent struct {
	Timestamp string
	User      string
	Command   string
	Success   bool
	TTY       string
}

// Regex for Debian/Ubuntu auth.log format
var sudoRegex = regexp.MustCompile(
	`^(\w+ \d+ [\d:]+) \S+ sudo:\s+(\S+) : TTY=(\S+) ; PWD=\S+ ; USER=\S+ ; COMMAND=(.+)$`,
)

func parseSudoLog(path string) ([]SudoEvent, error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	var events []SudoEvent
	scanner := bufio.NewScanner(file)

	for scanner.Scan() {
		line := scanner.Text()

		if !strings.Contains(line, "sudo:") {
			continue
		}

		matches := sudoRegex.FindStringSubmatch(line)
		if matches == nil {
			continue
		}

		event := SudoEvent{
			Timestamp: matches[1],
			User:      matches[2],
			TTY:       matches[3],
			Command:   matches[4],
			Success:   true,
		}
		events = append(events, event)
	}

	return events, scanner.Err()
}

func main() {
	path := "/var/log/auth.log"
	if len(os.Args) > 1 {
		path = os.Args[1]
	}

	events, err := parseSudoLog(path)
	if err != nil {
		fmt.Printf("error: %v\n", err)
		return
	}

	// Count by user
	userCounts := map[string]int{}
	for _, e := range events {
		userCounts[e.User]++
	}

	// Count by command
	cmdCounts := map[string]int{}
	for _, e := range events {
		cmdCounts[e.Command]++
	}

	fmt.Println("Sudo Log Analysis")
	fmt.Println("==================")
	fmt.Printf("Total events: %d\n\n", len(events))

	fmt.Println("Top users:")
	for user, count := range userCounts {
		fmt.Printf("  %-15s %d commands\n", user, count)
	}

	fmt.Println("\nTop commands:")
	type kv struct {
		Key   string
		Value int
	}
	var sorted []kv
	for k, v := range cmdCounts {
		sorted = append(sorted, kv{k, v})
	}
	sort.Slice(sorted, func(i, j int) bool { return sorted[i].Value > sorted[j].Value })

	limit := 5
	if len(sorted) < limit {
		limit = len(sorted)
	}
	for _, kv := range sorted[:limit] {
		fmt.Printf("  %-40s %d times\n", kv.Key, kv.Value)
	}
}

This works on a standard Ubuntu server. But test it on a RHEL server and you get zero results:

# On RHEL/CentOS, the log file is /var/log/secure
# and the format is different:
# Feb 15 14:30:01 server sudo: deploy : TTY=pts/0 ; PWD=/home/deploy ; USER=root ; COMMAND=/bin/systemctl restart nginx

The log format differences are subtle but enough to break a single regex. Some systems include extra spaces. Some use different date formats. The failed attempt lines have a completely different format.

main.go — fixed:

package main

import (
	"bufio"
	"fmt"
	"os"
	"regexp"
	"sort"
	"strings"
)

type SudoEvent struct {
	Timestamp string
	User      string
	Command   string
	Success   bool
	TTY       string
	Failures  int
}

// Multiple regex patterns for different log formats
var sudoPatterns = []*regexp.Regexp{
	// Debian/Ubuntu rsyslog format (standard)
	regexp.MustCompile(
		`^(\w+ \d+\s+[\d:]+) \S+ sudo:\s+(\S+) : TTY=(\S+) ; PWD=\S+ ; USER=\S+ ; COMMAND=(.+)$`,
	),
	// RHEL/CentOS format (slightly different spacing)
	regexp.MustCompile(
		`^(\w+ \d+\s+[\d:]+) \S+ sudo:\s+(\S+) :\s+TTY=(\S+)\s+;\s+PWD=\S+\s+;\s+USER=\S+\s+;\s+COMMAND=(.+)$`,
	),
	// Syslog with hostname and PID
	regexp.MustCompile(
		`^(\w+ \d+\s+[\d:]+) \S+ sudo\[\d+\]:\s+(\S+) : TTY=(\S+) ; PWD=\S+ ; USER=\S+ ; COMMAND=(.+)$`,
	),
}

// Pattern for failed attempts
var failedPatterns = []*regexp.Regexp{
	regexp.MustCompile(
		`^(\w+ \d+\s+[\d:]+) \S+ sudo:\s+(\S+) : (\d+) incorrect password attempt`,
	),
	regexp.MustCompile(
		`^(\w+ \d+\s+[\d:]+) \S+ sudo:\s+pam_unix\(sudo:auth\): authentication failure.*user=(\S+)`,
	),
}

func parseSudoLog(path string) ([]SudoEvent, error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	var events []SudoEvent
	scanner := bufio.NewScanner(file)

	for scanner.Scan() {
		line := scanner.Text()

		if !strings.Contains(line, "sudo") {
			continue
		}

		// Try each success pattern
		matched := false
		for _, pattern := range sudoPatterns {
			matches := pattern.FindStringSubmatch(line)
			if matches != nil {
				event := SudoEvent{
					Timestamp: matches[1],
					User:      matches[2],
					TTY:       matches[3],
					Command:   matches[4],
					Success:   true,
				}
				events = append(events, event)
				matched = true
				break
			}
		}

		if matched {
			continue
		}

		// Try failed patterns
		for _, pattern := range failedPatterns {
			matches := pattern.FindStringSubmatch(line)
			if matches != nil {
				failures := 1
				if len(matches) > 3 {
					fmt.Sscanf(matches[3], "%d", &failures)
				}
				event := SudoEvent{
					Timestamp: matches[1],
					User:      matches[2],
					Success:   false,
					Failures:  failures,
				}
				events = append(events, event)
				break
			}
		}
	}

	return events, scanner.Err()
}

func main() {
	// Try common log paths
	logPaths := []string{
		"/var/log/auth.log",    // Debian/Ubuntu
		"/var/log/secure",      // RHEL/CentOS
		"/var/log/authlog",     // BSD
	}

	if len(os.Args) > 1 {
		logPaths = []string{os.Args[1]}
	}

	var events []SudoEvent
	var usedPath string
	var err error

	for _, path := range logPaths {
		events, err = parseSudoLog(path)
		if err == nil {
			usedPath = path
			break
		}
	}

	if usedPath == "" {
		fmt.Println("error: could not open any log file")
		fmt.Println("tried:", strings.Join(logPaths, ", "))
		fmt.Println("try running with sudo, or specify the path:")
		fmt.Println("  sudo go run main.go /var/log/auth.log")
		return
	}

	// Separate successes and failures
	var successes, failures []SudoEvent
	for _, e := range events {
		if e.Success {
			successes = append(successes, e)
		} else {
			failures = append(failures, e)
		}
	}

	fmt.Println("Sudo Log Analysis")
	fmt.Println("==================")
	fmt.Printf("Log file: %s\n", usedPath)
	fmt.Printf("Total events: %d (%d successful, %d failed)\n\n",
		len(events), len(successes), len(failures))

	// Count by user
	userCounts := map[string]int{}
	for _, e := range successes {
		userCounts[e.User]++
	}

	fmt.Println("Top users by sudo usage:")
	type kv struct {
		Key   string
		Value int
	}
	var sortedUsers []kv
	for k, v := range userCounts {
		sortedUsers = append(sortedUsers, kv{k, v})
	}
	sort.Slice(sortedUsers, func(i, j int) bool { return sortedUsers[i].Value > sortedUsers[j].Value })
	for _, u := range sortedUsers {
		fmt.Printf("  %-15s %d commands\n", u.Key, u.Value)
	}

	// Top commands
	cmdCounts := map[string]int{}
	for _, e := range successes {
		cmdCounts[e.Command]++
	}

	fmt.Println("\nMost common commands:")
	var sortedCmds []kv
	for k, v := range cmdCounts {
		sortedCmds = append(sortedCmds, kv{k, v})
	}
	sort.Slice(sortedCmds, func(i, j int) bool { return sortedCmds[i].Value > sortedCmds[j].Value })
	limit := 5
	if len(sortedCmds) < limit {
		limit = len(sortedCmds)
	}
	for _, c := range sortedCmds[:limit] {
		fmt.Printf("  %-50s %d times\n", c.Key, c.Value)
	}

	// Failed attempts
	if len(failures) > 0 {
		fmt.Println("\nFailed sudo attempts (investigate these):")
		for _, f := range failures {
			fmt.Printf("  [FAILED] %s — user '%s' (%d attempts)\n",
				f.Timestamp, f.User, f.Failures)
		}
	} else {
		fmt.Println("\nNo failed sudo attempts found.")
	}
}
sudo go run main.go
Sudo Log Analysis
==================
Log file: /var/log/auth.log
Total events: 49 (47 successful, 2 failed)

Top users by sudo usage:
  deploy          32 commands
  admin           15 commands

Most common commands:
  /bin/systemctl restart nginx                       12 times
  /usr/bin/apt update                                 8 times
  /bin/cat /etc/shadow                                5 times
  /usr/bin/docker ps                                  4 times
  /usr/bin/journalctl -xe                             3 times

Failed sudo attempts (investigate these):
  [FAILED] Feb 15 03:14:22 — user 'guest' (3 attempts)
  [FAILED] Feb 15 03:14:45 — user 'guest' (3 attempts)

The fixed version tries multiple log paths (Debian, RHEL, BSD) and multiple regex patterns for each log line. It also extracts failed attempts as a separate category. Three failed attempts from guest at 3 AM is something to investigate — either someone forgot their password, or someone is trying to escalate privileges on a compromised account.

Step 6: Build a Security Audit Report

Now we combine everything into a single audit tool. This program runs all the checks from the previous steps and prints a formatted report.

main.go — final version:

package main

import (
	"bufio"
	"fmt"
	"os"
	"os/user"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"syscall"
	"time"
)

// ANSI color codes
const (
	red    = "\033[31m"
	yellow = "\033[33m"
	green  = "\033[32m"
	bold   = "\033[1m"
	reset  = "\033[0m"
)

// --- User Info ---

type UserInfo struct {
	Username string
	UID      string
	GID      string
	Groups   []string
	HasSudo  bool
}

func getUserInfo() UserInfo {
	u, err := user.Current()
	if err != nil {
		return UserInfo{Username: "unknown"}
	}

	info := UserInfo{
		Username: u.Username,
		UID:      u.Uid,
		GID:      u.Gid,
	}

	groupIDs, err := u.GroupIds()
	if err != nil {
		return info
	}

	for _, gid := range groupIDs {
		g, err := user.LookupGroupId(gid)
		if err != nil {
			info.Groups = append(info.Groups, gid)
			continue
		}
		info.Groups = append(info.Groups, g.Name)
		if g.Name == "sudo" || g.Name == "wheel" {
			info.HasSudo = true
		}
	}

	return info
}

// --- Permission Scanner ---

type Finding struct {
	Path     string
	Mode     os.FileMode
	Severity string
	Reason   string
}

var expectedSetuid = map[string]bool{
	"/usr/bin/sudo": true, "/usr/bin/passwd": true,
	"/usr/bin/chfn": true, "/usr/bin/chsh": true,
	"/usr/bin/gpasswd": true, "/usr/bin/newgrp": true,
	"/usr/bin/su": true, "/usr/bin/mount": true,
	"/usr/bin/umount": true, "/usr/bin/at": true,
	"/usr/bin/crontab": true, "/usr/bin/pkexec": true,
	"/usr/lib/dbus-1.0/dbus-daemon-launch-helper": true,
	"/usr/lib/openssh/ssh-keysign":                true,
}

func scanPermissions(roots []string, maxDepth int) (setuid []Finding, worldWrite []Finding, keys []Finding, skipped int) {
	for _, root := range roots {
		info, err := os.Stat(root)
		if err != nil || !info.IsDir() {
			continue
		}

		baseDepth := strings.Count(filepath.Clean(root), string(os.PathSeparator))

		filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
			if err != nil {
				if info != nil && info.IsDir() {
					skipped++
					return filepath.SkipDir
				}
				return nil
			}

			currentDepth := strings.Count(filepath.Clean(path), string(os.PathSeparator))
			if info.IsDir() && (currentDepth-baseDepth) > maxDepth {
				return filepath.SkipDir
			}

			if !info.Mode().IsRegular() {
				return nil
			}

			mode := info.Mode()

			if mode&os.ModeSetuid != 0 {
				severity := "INFO"
				reason := "expected system binary"
				if !expectedSetuid[path] {
					severity = "CRITICAL"
					reason = "UNEXPECTED — investigate immediately"
				}
				setuid = append(setuid, Finding{path, mode, severity, reason})
			}

			if mode.Perm()&0002 != 0 {
				worldWrite = append(worldWrite, Finding{
					path, mode, "WARNING",
					"any user can modify this file",
				})
			}

			name := strings.ToLower(info.Name())
			isKey := strings.HasSuffix(name, ".pem") ||
				strings.HasSuffix(name, ".key") ||
				name == "id_rsa" || name == "id_ed25519" || name == "id_ecdsa"

			if isKey && mode.Perm() > 0o600 {
				keys = append(keys, Finding{
					path, mode, "CRITICAL",
					fmt.Sprintf("permissions %04o — should be 0600", mode.Perm()),
				})
			}

			return nil
		})
	}
	return
}

// --- Sudoers Parser ---

type SudoersEntry struct {
	Who      string
	AsWho    string
	Commands string
	NoPasswd bool
	IsGroup  bool
	Source   string
}

func parseSudoersFile(path string) ([]SudoersEntry, []string) {
	file, err := os.Open(path)
	if err != nil {
		return nil, nil
	}
	defer file.Close()

	var entries []SudoersEntry
	var includeDirs []string
	scanner := bufio.NewScanner(file)

	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())

		if strings.HasPrefix(line, "#includedir ") {
			includeDirs = append(includeDirs, strings.TrimPrefix(line, "#includedir "))
			continue
		}
		if strings.HasPrefix(line, "@includedir ") {
			includeDirs = append(includeDirs, strings.TrimPrefix(line, "@includedir "))
			continue
		}
		if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "Defaults") {
			continue
		}

		parts := strings.Fields(line)
		if len(parts) < 3 {
			continue
		}

		entry := SudoersEntry{Who: parts[0], Source: path}
		if strings.HasPrefix(entry.Who, "%") {
			entry.IsGroup = true
			entry.Who = strings.TrimPrefix(entry.Who, "%")
		}

		for i, p := range parts[1:] {
			if strings.Contains(p, "=(") {
				halves := strings.SplitN(p, "=(", 2)
				entry.AsWho = strings.TrimRight(halves[1], ")")
				cmds := strings.Join(parts[i+2:], " ")
				if strings.Contains(cmds, "NOPASSWD:") {
					entry.NoPasswd = true
					cmds = strings.Replace(cmds, "NOPASSWD:", "", 1)
				}
				entry.Commands = strings.TrimSpace(cmds)
				entries = append(entries, entry)
				break
			}
		}
	}

	for _, dir := range includeDirs {
		files, err := filepath.Glob(filepath.Join(dir, "*"))
		if err != nil {
			continue
		}
		for _, f := range files {
			base := filepath.Base(f)
			if strings.Contains(base, ".") || strings.HasSuffix(base, "~") {
				continue
			}
			subEntries, _ := parseSudoersFile(f)
			entries = append(entries, subEntries...)
		}
	}

	return entries, includeDirs
}

// --- Log Parser ---

type SudoEvent struct {
	Timestamp string
	User      string
	Command   string
	Success   bool
	Failures  int
}

var successPatterns = []*regexp.Regexp{
	regexp.MustCompile(`^(\w+ \d+\s+[\d:]+) \S+ sudo:\s+(\S+) : TTY=\S+ ; PWD=\S+ ; USER=\S+ ; COMMAND=(.+)$`),
	regexp.MustCompile(`^(\w+ \d+\s+[\d:]+) \S+ sudo\[\d+\]:\s+(\S+) : TTY=\S+ ; PWD=\S+ ; USER=\S+ ; COMMAND=(.+)$`),
}

var failPatterns = []*regexp.Regexp{
	regexp.MustCompile(`^(\w+ \d+\s+[\d:]+) \S+ sudo:\s+(\S+) : (\d+) incorrect password attempt`),
	regexp.MustCompile(`^(\w+ \d+\s+[\d:]+) \S+ sudo:\s+pam_unix\(sudo:auth\): authentication failure.*user=(\S+)`),
}

func parseSudoLog() (successes []SudoEvent, failures []SudoEvent, logPath string) {
	paths := []string{"/var/log/auth.log", "/var/log/secure", "/var/log/authlog"}

	for _, path := range paths {
		file, err := os.Open(path)
		if err != nil {
			continue
		}
		defer file.Close()
		logPath = path

		scanner := bufio.NewScanner(file)
		for scanner.Scan() {
			line := scanner.Text()
			if !strings.Contains(line, "sudo") {
				continue
			}

			matched := false
			for _, p := range successPatterns {
				m := p.FindStringSubmatch(line)
				if m != nil {
					successes = append(successes, SudoEvent{
						Timestamp: m[1], User: m[2], Command: m[3], Success: true,
					})
					matched = true
					break
				}
			}
			if matched {
				continue
			}

			for _, p := range failPatterns {
				m := p.FindStringSubmatch(line)
				if m != nil {
					fails := 1
					if len(m) > 3 {
						fmt.Sscanf(m[3], "%d", &fails)
					}
					failures = append(failures, SudoEvent{
						Timestamp: m[1], User: m[2], Success: false, Failures: fails,
					})
					break
				}
			}
		}
		return
	}
	return
}

// --- Report ---

func colorize(severity, text string) string {
	switch severity {
	case "CRITICAL":
		return red + text + reset
	case "WARNING":
		return yellow + text + reset
	case "OK", "INFO":
		return green + text + reset
	default:
		return text
	}
}

func main() {
	now := time.Now().Format("2006-01-02 15:04:05 MST")
	fmt.Printf("\n%sSecurity Audit Report%s — %s\n", bold, reset, now)
	fmt.Println(strings.Repeat("=", 55))

	// Section 1: Users & Groups
	fmt.Printf("\n%s[Users & Groups]%s\n", bold, reset)
	userInfo := getUserInfo()
	fmt.Printf("  Current user: %s (uid=%s)\n", userInfo.Username, userInfo.UID)
	fmt.Printf("  Groups: %s\n", strings.Join(userInfo.Groups, ", "))
	if userInfo.HasSudo {
		fmt.Printf("  Sudo access: %s\n", colorize("WARNING", "YES"))
	} else {
		fmt.Printf("  Sudo access: %s\n", colorize("OK", "NO"))
	}

	// Section 2: File Permissions
	fmt.Printf("\n%s[File Permissions]%s\n", bold, reset)
	scanPaths := []string{"/usr/bin", "/usr/sbin", "/usr/local/bin", "/usr/local/sbin", "/home", "/etc", "/opt"}
	setuidFiles, worldWritable, keyIssues, skipped := scanPermissions(scanPaths, 5)

	// Setuid summary
	expectedCount := 0
	unexpectedCount := 0
	for _, f := range setuidFiles {
		if f.Severity == "INFO" {
			expectedCount++
		} else {
			unexpectedCount++
		}
	}
	fmt.Printf("  Setuid binaries: %d found\n", len(setuidFiles))
	for _, f := range setuidFiles {
		if f.Severity == "INFO" {
			fmt.Printf("    %s (expected)\n", f.Path)
		} else {
			fmt.Printf("    %s (%s)\n", f.Path, colorize("CRITICAL", f.Reason))
		}
	}

	// World-writable summary
	if len(worldWritable) > 0 {
		fmt.Printf("  World-writable: %d found\n", len(worldWritable))
		for _, f := range worldWritable {
			fmt.Printf("    %s (%s)\n", f.Path, colorize("WARNING", "WARNING"))
		}
	} else {
		fmt.Printf("  World-writable: %s\n", colorize("OK", "none found"))
	}

	// Key issues
	if len(keyIssues) > 0 {
		fmt.Printf("  Private keys: %d issue(s)\n", len(keyIssues))
		for _, f := range keyIssues {
			fmt.Printf("    %s: %s (%s)\n",
				f.Path,
				fmt.Sprintf("%04o", f.Mode.Perm()),
				colorize("CRITICAL", "should be 0600"))
		}
	} else {
		fmt.Printf("  Private keys: %s\n", colorize("OK", "all permissions correct"))
	}

	if skipped > 0 {
		fmt.Printf("  Directories skipped (no access): %d\n", skipped)
	}

	// Section 3: Sudoers Analysis
	fmt.Printf("\n%s[Sudoers]%s\n", bold, reset)

	// Try to read sudoers (needs root)
	sudoersPath := "/etc/sudoers"
	// Try a copy if it exists
	if _, err := os.Stat("/tmp/sudoers_copy"); err == nil {
		sudoersPath = "/tmp/sudoers_copy"
	}

	entries, includeDirs := parseSudoersFile(sudoersPath)
	if len(entries) > 0 {
		// Users with ALL commands
		var allUsers []string
		var nopasswdUsers []string
		for _, e := range entries {
			name := e.Who
			if e.IsGroup {
				name = "%" + name
			}
			if strings.TrimSpace(e.Commands) == "ALL" {
				allUsers = append(allUsers, name)
			}
			if e.NoPasswd {
				detail := name
				if strings.TrimSpace(e.Commands) == "ALL" {
					detail += " " + colorize("CRITICAL", "(NOPASSWD ALL — dangerous)")
				} else {
					detail += fmt.Sprintf(" (limited to %s — OK)", e.Commands)
				}
				nopasswdUsers = append(nopasswdUsers, detail)
			}
		}

		fmt.Printf("  Users with ALL commands: %s\n", strings.Join(allUsers, ", "))

		if len(nopasswdUsers) > 0 {
			fmt.Printf("  Users with NOPASSWD:\n")
			for _, u := range nopasswdUsers {
				fmt.Printf("    %s\n", u)
			}
		} else {
			fmt.Printf("  Users with NOPASSWD: %s\n", colorize("OK", "none"))
		}

		if len(includeDirs) > 0 {
			for _, dir := range includeDirs {
				files, _ := filepath.Glob(filepath.Join(dir, "*"))
				count := 0
				for _, f := range files {
					base := filepath.Base(f)
					if !strings.Contains(base, ".") && !strings.HasSuffix(base, "~") {
						count++
					}
				}
				fmt.Printf("  Include dirs: %s (%d files)\n", dir, count)
			}
		}
	} else {
		fmt.Printf("  Could not read sudoers (run with sudo for full audit)\n")
	}

	// Section 4: Recent sudo Activity
	fmt.Printf("\n%s[Recent sudo Activity]%s\n", bold, reset)
	successes, logFailures, logPath := parseSudoLog()

	if logPath != "" {
		fmt.Printf("  Log file: %s\n", logPath)
		fmt.Printf("  Total: %d commands, %d failed attempts\n",
			len(successes), len(logFailures))

		if len(logFailures) > 0 {
			fmt.Printf("  Failed attempts:\n")
			for _, f := range logFailures {
				fmt.Printf("    %s user '%s' (%d attempts) %s\n",
					f.Timestamp, f.User, f.Failures,
					colorize("WARNING", "— investigate"))
			}
		}

		// Show top users
		if len(successes) > 0 {
			userCmds := map[string]int{}
			for _, s := range successes {
				userCmds[s.User]++
			}
			type kv struct {
				K string
				V int
			}
			var sorted []kv
			for k, v := range userCmds {
				sorted = append(sorted, kv{k, v})
			}
			sort.Slice(sorted, func(i, j int) bool { return sorted[i].V > sorted[j].V })
			fmt.Printf("  Top users: ")
			parts := []string{}
			for _, s := range sorted {
				parts = append(parts, fmt.Sprintf("%s (%d)", s.K, s.V))
			}
			fmt.Println(strings.Join(parts, ", "))
		}
	} else {
		fmt.Printf("  Could not read log files (run with sudo)\n")
	}

	// Summary
	criticals := unexpectedCount + len(keyIssues)
	warnings := len(worldWritable) + len(logFailures)

	fmt.Printf("\n%s[Summary]%s\n", bold, reset)
	if criticals > 0 {
		fmt.Printf("  %s\n", colorize("CRITICAL", fmt.Sprintf("%d critical issue(s) need immediate attention", criticals)))
	}
	if warnings > 0 {
		fmt.Printf("  %s\n", colorize("WARNING", fmt.Sprintf("%d warning(s) to review", warnings)))
	}
	if criticals == 0 && warnings == 0 {
		fmt.Printf("  %s\n", colorize("OK", "No critical issues found"))
	}

	// Provide the file ownership info using syscall for completeness
	_ = syscall.Stat_t{}
	_ = strconv.Itoa(0)

	fmt.Println()
}
sudo go run main.go

Expected output:

Security Audit Report — 2026-02-15 19:30:05 UTC
=======================================================

[Users & Groups]
  Current user: deploy (uid=1001)
  Groups: deploy, sudo, docker
  Sudo access: YES

[File Permissions]
  Setuid binaries: 10 found
    /usr/bin/sudo (expected)
    /usr/bin/passwd (expected)
    /usr/bin/su (expected)
    /usr/bin/mount (expected)
    /usr/bin/umount (expected)
    /usr/bin/chfn (expected)
    /usr/bin/chsh (expected)
    /usr/bin/gpasswd (expected)
    /usr/bin/newgrp (expected)
    /usr/local/bin/custom-tool (UNEXPECTED — investigate immediately)
  World-writable: 1 found
    /etc/app-debug.tmp (WARNING)
  Private keys: 1 issue(s)
    /home/deploy/.ssh/id_rsa: 0644 (should be 0600)
  Directories skipped (no access): 4

[Sudoers]
  Users with ALL commands: root, %sudo
  Users with NOPASSWD:
    deploy (limited to /bin/systemctl restart nginx, /bin/systemctl reload nginx — OK)
  Include dirs: /etc/sudoers.d (3 files)

[Recent sudo Activity]
  Log file: /var/log/auth.log
  Total: 47 commands, 2 failed attempts
  Failed attempts:
    Feb 15 03:14:22 user 'guest' (3 attempts) — investigate
    Feb 15 03:14:45 user 'guest' (3 attempts) — investigate
  Top users: deploy (32), admin (15)

[Summary]
  2 critical issue(s) need immediate attention
  3 warning(s) to review

The report gives you a full picture in seconds. The two critical issues — an unexpected setuid binary and a private key with open permissions — are the kind of things that cause security incidents. The failed sudo attempts from guest at 3 AM need investigation.

To fix the issues the audit found:

# Fix the private key permissions
chmod 600 /home/deploy/.ssh/id_rsa

# Investigate the unexpected setuid binary
file /usr/local/bin/custom-tool
ls -la /usr/local/bin/custom-tool
# If it should not be setuid, remove the bit:
sudo chmod u-s /usr/local/bin/custom-tool

# Fix the world-writable file
chmod 644 /etc/app-debug.tmp

# Check the guest account
sudo grep guest /var/log/auth.log
# If compromised, lock the account:
sudo usermod -L guest

What We Built

Each step started with Linux commands, then built the same thing in Go, hit a bug, and fixed it:

  1. id / groups — Go user info with os/user. Trap: nil pointer from unchecked error on user.Lookup.
  2. ls -la / stat — Go permission decoder. Trap: generic error handling instead of detecting permission denied with os.IsPermission.
  3. sudo -l / sudoers — Go sudoers parser. Trap: #includedir looks like a comment but is a directive. Missing include dirs means missing most rules.
  4. find -perm — Go permission scanner with filepath.Walk. Trap: scanning all of / is too slow, and a single permission denied error kills the walk. Fix with filepath.SkipDir and path limits.
  5. auth.log / journalctl — Go log parser. Trap: log format varies between distros. Fix with multiple regex patterns.
  6. Combined all five into a colored security audit report.

Cheat Sheet

Key Linux commands:

id                                    # who am I
sudo -l                               # what can I sudo
ls -la /path                          # file permissions
stat /path                            # detailed file info
find / -perm -4000 -type f 2>/dev/null  # setuid files
find / -perm -0002 -type f 2>/dev/null  # world-writable files
grep "sudo:" /var/log/auth.log        # sudo log entries
visudo                                # ONLY way to edit sudoers
chmod 600 ~/.ssh/id_rsa               # fix key permissions

Key Go patterns:

  • os/user for user and group info
  • os.Stat + Mode() for file permissions
  • os.IsPermission(err) for access denied checks
  • errors.Is(err, os.ErrNotExist) for missing file checks
  • filepath.Walk with filepath.SkipDir for scanning directories
  • Always check errors before using results — the nil pointer dereference from an unchecked user.Lookup is the most common Go bug

Key rules:

  • Never give NOPASSWD for ALL commands — limit to specific binaries
  • Private keys must be 0600 — SSH will refuse them otherwise
  • Setuid binaries run as the file owner — every unexpected setuid file is a potential exploit
  • Check auth.log for failed sudo attempts — three in a minute from the same user means someone is trying
  • Use visudo to edit sudoers — a syntax error locks everyone out of sudo
  • #includedir in sudoers is NOT a comment — your audit must scan those files too

References and Further Reading

Question

What's the scariest permissions issue you've found on a production server? The kind where you see it and immediately know something is very wrong?

Similar Articles

More from devops

No related topic suggestions found.