Learn nginx log analysis step by step — start with grep and awk one-liners for quick answers, then …
Linux Access Control: From sudo to a Go Security Scanner Linux Access Control: From sudo to a Go Security Scanner

Summary
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.
Deepen your understanding in How to Replace Text in Multiple Files with Sed: A Step-by-Step Guide
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:
| Position | Meaning |
|---|---|
- | 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:
| Permission | Value |
|---|---|
| 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:
| Octal | Symbolic | Used for |
|---|---|---|
0755 | -rwxr-xr-x | Directories, 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.
Explore this further in How to Replace Text in Multiple Files with Sed: A Step-by-Step Guide
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.
Discover related concepts in How to Replace Text in Multiple Files with Sed: A Step-by-Step Guide
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:
- When
filepath.Walkhits a permission denied error on a directory, we returnfilepath.SkipDirinstead of stopping the entire scan. - We added a depth limit of 5 levels to keep the scan fast.
- We only scan directories that matter:
/usr/bin,/home,/etc, and similar. No need to scan/procor/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.
Uncover more details in How to Replace Text in Multiple Files with Sed: A Step-by-Step Guide
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.
Journey deeper into this topic with Docker Log Management: From docker logs to a Go Log Collector
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:
Enrich your learning with AWS Security Audit: From AWS CLI to a Go Security Scanner
# 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:
Gain comprehensive insights from Deploy Jenkins on Amazon EKS: Complete Tutorial for Pods and Deployments
id/groups— Go user info withos/user. Trap: nil pointer from unchecked error onuser.Lookup.ls -la/stat— Go permission decoder. Trap: generic error handling instead of detecting permission denied withos.IsPermission.sudo -l/ sudoers — Go sudoers parser. Trap:#includedirlooks like a comment but is a directive. Missing include dirs means missing most rules.find -perm— Go permission scanner withfilepath.Walk. Trap: scanning all of/is too slow, and a single permission denied error kills the walk. Fix withfilepath.SkipDirand path limits.auth.log/journalctl— Go log parser. Trap: log format varies between distros. Fix with multiple regex patterns.- 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/userfor user and group infoos.Stat+Mode()for file permissionsos.IsPermission(err)for access denied checkserrors.Is(err, os.ErrNotExist)for missing file checksfilepath.Walkwithfilepath.SkipDirfor scanning directories- Always check errors before using results — the nil pointer dereference from an unchecked
user.Lookupis the most common Go bug
Key rules:
Master this concept through Sed Cheat Sheet: 30 Essential One-Liners
- Never give
NOPASSWDfor 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.logfor failed sudo attempts — three in a minute from the same user means someone is trying - Use
visudoto edit sudoers — a syntax error locks everyone out of sudo #includedirin sudoers is NOT a comment — your audit must scan those files too
References and Further Reading
- sudo.ws Official Documentation — The canonical reference for sudo configuration and the sudoers file format.
- Linux man page for sudoers(5) — Complete specification of the sudoers file syntax including include directives.
- Go os/user Package — Standard library documentation for user and group lookup in Go.
- Go filepath.Walk Documentation — How file tree walking works in Go, including error handling with SkipDir.
- CIS Benchmarks for Linux — Industry-standard security configuration baselines that include file permission auditing.
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
Related Content
More from devops
Learn AWS automation step by step — start with AWS CLI commands for S3, EC2, and IAM, then build the …
Learn config templating step by step — start with envsubst for simple variable substitution, then …
You Might Also Like
No related topic suggestions found.
