Linux Security Audit with Bash
Write a bash script that audits your Linux server security and scores it.
Introduction
30 Second Summary
Every server connected to the internet faces constant probing from automated scanners. Knowing whether your system is hardened or exposed is the difference between sleeping well and waking up to a breach notification.
In this project, you will build a Bash script that automatically audits your Ubuntu server's security posture across 5 categories and outputs a scored report.
What You'll Build
A command-line security audit tool called security-audit.sh that scans your Ubuntu server and produces a color-coded report showing what's hardened and what needs attention.
By the end of this project, you'll have:
- A modular bash script organized into functions that audits security across SSH configuration, user accounts, file permissions, kernel parameters, and network settings.
- Color-coded terminal output with green PASS, red FAIL, and yellow WARNING indicators.
- A numerical security score (0-100) with a letter grade you can use to track your server's hardening progress.
- Secret Mission: A logging feature that saves audit results with timestamps so you can track your security posture over time.
Are there any prerequisites?
You should be comfortable with basic Linux command line navigation (cd, ls, cat). No prior bash scripting or security experience is needed.
Set Up Your Workspace
Before you can build a security audit tool, you need a clean workspace on the server you will be auditing. Your Ubuntu server is the target environment where security checks actually matter.
In this step, you will connect to your Ubuntu server, create a project directory, and write the first lines of your script file. You will also learn the essential commands for nano, the text editor you will use throughout this project.
In this step, get ready to:
- Connect to your Ubuntu server and create a project directory.
- Create your script file and learn nano basics.
- Verify the script runs without errors.
Connect to your Ubuntu server and create a project directory
You need a terminal session on your Ubuntu server. You can either SSH into it from your Kali VM or open the Ubuntu VM terminal directly in VMware Workstation.
- If using SSH from Kali, connect by running:
- If using the Ubuntu VM directly, open the terminal inside the VM.
ssh [[UBUNTU_IP="your Ubuntu server's IP address"]]
Which option should I choose?
Both approaches give you the same result: a terminal session on your Ubuntu server. If you are unsure of your Ubuntu server's IP address, opening the VM terminal directly is the simpler path.
- Create a project directory and move into it by running these commands:
mkdir ~/security-audit && cd ~/security-audit
What does this command do?
This creates a new folder called security-audit in your home directory, then immediately moves you into it.
The && operator means the second command only runs if the first one succeeds.
- Verify that nano is installed by running:
nano --version
✔️ I see a version number
You should see output starting with GNU nano followed by a version number. nano is ready to use.
ⓧ Command not found
- Install nano by running:
sudo apt update && sudo apt install nano -y
- Run nano --version again to confirm it installed successfully.
Create your script file and learn nano basics
nano is a terminal-based text editor. Unlike vim, its keyboard shortcuts are displayed at the bottom of the screen, making it beginner-friendly.
- Create and open your script file by running:
nano security-audit.sh
What are the essential nano commands?
You only need three commands to work with nano throughout this project:
- Ctrl+S saves the current file.
- Ctrl+X exits nano.
- Ctrl+G opens the help menu if you get stuck.
- Type the following code into the editor:
#!/bin/bash
# ============================================================
# Linux Security Audit Script
# Audits Ubuntu server security across 5 categories
# Usage: sudo ./security-audit.sh
# ============================================================
What does this code do?
The first line #!/bin/bash is called the shebang. It tells the system to use Bash to interpret this script.
The comment block (lines starting with #) documents what the script does and how to use it. These lines are ignored when the script runs but help anyone reading the file understand its purpose.
- Save the file by pressing Ctrl+S.
- Exit nano by pressing Ctrl+X.
- Make the script executable by running:
chmod +x security-audit.sh
Why do you need chmod +x?
Linux files are not executable by default. The chmod +x command adds execute permission, which allows you to run the file as a program instead of just reading it.
- Confirm the file exists and is executable by running:
ls -la security-audit.sh
You should see -rwxr-xr-x (or similar) at the start of the line. The x characters confirm the file is executable.
Don't see the x permission?
- Make sure you typed chmod +x security-audit.sh with no typos.
- Confirm you are in the ~/security-audit directory by running pwd.
Why isn't my script executable after running chmod?
Verify the script runs
The script does not have any logic yet, so it will simply exit. Running it now confirms there are no syntax errors in your shebang or comment header.
- Run your script by executing:
./security-audit.sh
You should see no output and no errors. The terminal returns to your prompt silently, which means the script ran and exited cleanly.
Seeing a permission denied error?
- Run chmod +x security-audit.sh again and retry.
- If you see a "bad interpreter" error, open the file with nano security-audit.sh and make sure the first line is exactly #!/bin/bash with no spaces before the #.
Help me fix an error when running my security-audit.sh script.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file and confirmed it runs without errors.
ⓧ I'd like to double check the full code
Here is the complete security-audit.sh file at this point:
#!/bin/bash
# ============================================================
# Linux Security Audit Script
# Audits Ubuntu server security across 5 categories
# Usage: sudo ./security-audit.sh
# ============================================================
Your workspace is ready and your script skeleton is in place. Next up, you will build the report framework with colored output and a scoring system.
Build the Report Framework
Your script file is ready and executable. Now you need it to actually do something useful when it runs.
Every audit tool needs a way to present its findings clearly. In this step, you will build the reporting framework that displays color-coded results (green for PASS, red for FAIL, yellow for WARNING) and calculates a numerical security score.
In this step, get ready to:
- Define color codes and score-tracking variables.
- Write helper functions for the banner, section headers, results, and scoring.
- Test the framework to confirm colored output works.
Add color variables and score tracking
Terminal colors in Bash work through special escape sequences called ANSI codes. Each code tells the terminal to switch to a different text color or style.
- Open your script in nano by running:
nano security-audit.sh
- Your cursor should be at the end of the comment header. Press Enter twice to add a blank line, then type in the following color definitions and score variables:
# --- Color Definitions ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# --- Score Tracking ---
TOTAL_CHECKS=0
PASSED_CHECKS=0
# --- Helper Functions ---
What does this code do?
Each variable stores an ANSI escape sequence that changes terminal text color. For example, RED='\033[0;31m' switches text to red, and NC='\033[0m' resets back to normal (No Color).
TOTAL_CHECKS and PASSED_CHECKS start at zero. Each security check will increment these counters, and the final score is calculated from their ratio.
Now you need a function that displays each check result in color. This is the workhorse function that every security check will call.
- Below the # --- Helper Functions --- comment, add a blank line and then type the print_result function:
print_result() {
local status="$1"
local message="$2"
TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
if [ "$status" = "PASS" ]; then
PASSED_CHECKS=$((PASSED_CHECKS + 1))
echo -e " ${GREEN}[PASS]${NC} $message"
elif [ "$status" = "FAIL" ]; then
echo -e " ${RED}[FAIL]${NC} $message"
else
echo -e " ${YELLOW}[WARN]${NC} $message"
fi
}
What does this code do?
print_result takes two arguments: a status (PASS, FAIL, or WARN) and a message string. Every time it is called, it increments TOTAL_CHECKS. If the status is PASS, it also increments PASSED_CHECKS.
The echo -e flag enables interpretation of escape sequences, so the color codes actually change the terminal output color.
- Save your progress by pressing Ctrl+S. Stay in nano for the next substep.
Add the report header and banner function
Your audit tool needs a professional banner that shows when it was run, which machine it scanned, and what kernel version is running. You will also add functions for section headers and the final score calculation.
- Below the print_result function's closing }, add a blank line and then type the print_banner function:
print_banner() {
echo ""
echo -e "${BLUE}${BOLD}================================================${NC}"
echo -e "${BLUE}${BOLD} LINUX SECURITY AUDIT TOOL${NC}"
echo -e "${BLUE}${BOLD}================================================${NC}"
echo -e " Date: $(date '+%Y-%m-%d %H:%M:%S')"
echo -e " Hostname: $(hostname)"
echo -e " Kernel: $(uname -r)"
echo -e "${BLUE}${BOLD}================================================${NC}"
echo ""
}
What does this code do?
print_banner creates a blue, bold header bar with the tool name. The $(date ...), $(hostname), and $(uname -r) expressions are command substitution. Bash runs each command and inserts its output directly into the string.
- Below print_banner's closing }, add a blank line and then type the print_section function:
print_section() {
echo ""
echo -e "${BOLD}[ $1 ]${NC}"
echo "------------------------------------------------"
}
What does this code do?
print_section displays a bold category header. The $1 is the first argument passed to the function. Later, you will call it like print_section "SSH CONFIGURATION" to label each audit category.
- Below print_section's closing }, add a blank line and then type the print_score function:
print_score() {
echo ""
echo -e "${BOLD}================================================${NC}"
if [ "$TOTAL_CHECKS" -gt 0 ]; then
local score=$(( (PASSED_CHECKS * 100) / TOTAL_CHECKS ))
local grade=""
if [ "$score" -ge 90 ]; then
grade="A"
elif [ "$score" -ge 75 ]; then
grade="B"
elif [ "$score" -ge 60 ]; then
grade="C"
elif [ "$score" -ge 40 ]; then
grade="D"
else
grade="F"
fi
echo -e " Security Score: ${BOLD}${score}/100${NC} (Grade: ${BOLD}${grade}${NC})"
echo -e " Checks Passed: ${PASSED_CHECKS}/${TOTAL_CHECKS}"
else
echo -e " No checks were run."
fi
echo -e "${BOLD}================================================${NC}"
echo ""
}
What does this code do?
print_score calculates the percentage of passed checks and maps it to a letter grade (A through F). It first checks whether any checks ran at all to avoid dividing by zero.
The $(( )) syntax is bash arithmetic expansion. The expression (PASSED_CHECKS * 100) / TOTAL_CHECKS gives you the percentage as a whole number.
- Save your progress by pressing Ctrl+S. Stay in nano for the next substep.
Test the framework
Your helper functions are defined, but bash only runs them if you explicitly call them. You need a main execution section at the bottom of the script that invokes the banner and score display.
- Below the print_score function's closing }, add a blank line and then type the main execution block:
# --- Main Execution ---
print_banner
print_score
What does this code do?
This is where the script actually starts doing work. When you run the script, bash reads from top to bottom. It loads all the function definitions first, then hits these two calls and executes them in order.
Since no checks have been added yet, TOTAL_CHECKS is still 0. The print_score function will display "No checks were run." for now.
- Save the file by pressing Ctrl+S.
- Exit nano by pressing Ctrl+X.
- Run your script to test the framework:
./security-audit.sh
- You should see a blue banner with your hostname, the current date, and kernel version. Below it, you should see "No checks were run." between two separator lines.
Don't see colored output?
- Make sure you used echo -e (with the -e flag) in your functions. Without it, bash prints the escape codes as literal text instead of interpreting them as colors.
- Check that your color variables use single quotes around the escape sequences. Double quotes can cause bash to interpret the backslashes prematurely.
- If you see a syntax error, open the file again with nano security-audit.sh and check for missing quotes or mismatched fi statements in print_score.
help me debug my security-audit.sh script output
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is what your complete security-audit.sh should look like at this point:
#!/bin/bash
# ============================================================
# Linux Security Audit Script
# Audits Ubuntu server security across 5 categories
# Usage: sudo ./security-audit.sh
# ============================================================
# --- Color Definitions ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# --- Score Tracking ---
TOTAL_CHECKS=0
PASSED_CHECKS=0
# --- Helper Functions ---
print_banner() {
echo ""
echo -e "${BLUE}${BOLD}================================================${NC}"
echo -e "${BLUE}${BOLD} LINUX SECURITY AUDIT TOOL${NC}"
echo -e "${BLUE}${BOLD}================================================${NC}"
echo -e " Date: $(date '+%Y-%m-%d %H:%M:%S')"
echo -e " Hostname: $(hostname)"
echo -e " Kernel: $(uname -r)"
echo -e "${BLUE}${BOLD}================================================${NC}"
echo ""
}
print_section() {
echo ""
echo -e "${BOLD}[ $1 ]${NC}"
echo "------------------------------------------------"
}
print_result() {
local status="$1"
local message="$2"
TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
if [ "$status" = "PASS" ]; then
PASSED_CHECKS=$((PASSED_CHECKS + 1))
echo -e " ${GREEN}[PASS]${NC} $message"
elif [ "$status" = "FAIL" ]; then
echo -e " ${RED}[FAIL]${NC} $message"
else
echo -e " ${YELLOW}[WARN]${NC} $message"
fi
}
print_score() {
echo ""
echo -e "${BOLD}================================================${NC}"
if [ "$TOTAL_CHECKS" -gt 0 ]; then
local score=$(( (PASSED_CHECKS * 100) / TOTAL_CHECKS ))
local grade=""
if [ "$score" -ge 90 ]; then
grade="A"
elif [ "$score" -ge 75 ]; then
grade="B"
elif [ "$score" -ge 60 ]; then
grade="C"
elif [ "$score" -ge 40 ]; then
grade="D"
else
grade="F"
fi
echo -e " Security Score: ${BOLD}${score}/100${NC} (Grade: ${BOLD}${grade}${NC})"
echo -e " Checks Passed: ${PASSED_CHECKS}/${TOTAL_CHECKS}"
else
echo -e " No checks were run."
fi
echo -e "${BOLD}================================================${NC}"
echo ""
}
# --- Main Execution ---
print_banner
print_score
Your reporting framework is in place. Next up, you will write the first real security check that inspects your server's SSH configuration and feeds results into this scoring system.
Write SSH Configuration Checks
SSH is the front door to your server. If it's misconfigured, attackers can brute-force passwords or log in directly as root.
In this step, you'll write a function that reads the SSH configuration file and checks three critical settings. Each check uses grep and awk to parse values from /etc/ssh/sshd_config and reports PASS or FAIL using the color functions you built earlier.
In this step, get ready to:
- Create a check_ssh function that reads the SSH config file.
- Check PermitRootLogin, MaxAuthTries, and PasswordAuthentication settings.
- Run the audit and review your first real security results.
Create the check_ssh function
The SSH daemon stores its configuration in /etc/ssh/sshd_config. Your function will read this file and use pattern matching to extract the value of each setting.
- Open the script by running:
- nano security-audit.sh
nano security-audit.sh
- Scroll down to the line that says # --- Main Execution ---.
- Add the following code ABOVE that line to create the check_ssh function with PermitRootLogin and MaxAuthTries checks:
# --- Security Check Functions ---
check_ssh() {
print_section "SSH CONFIGURATION"
local sshd_config="/etc/ssh/sshd_config"
if [ ! -f "$sshd_config" ]; then
print_result "WARN" "sshd_config not found - SSH may not be installed"
return
fi
# Check PermitRootLogin
local root_login
root_login=$(grep -i "^PermitRootLogin" "$sshd_config" | awk '{print $2}')
if [ "$root_login" = "no" ]; then
print_result "PASS" "Root login is disabled"
else
print_result "FAIL" "Root login is not disabled (current: ${root_login:-default})"
fi
# Check MaxAuthTries
local max_auth
max_auth=$(grep -i "^MaxAuthTries" "$sshd_config" | awk '{print $2}')
if [ -n "$max_auth" ] && [ "$max_auth" -le 4 ]; then
print_result "PASS" "MaxAuthTries is set to $max_auth (4 or less)"
else
print_result "FAIL" "MaxAuthTries is too high or not set (current: ${max_auth:-6 default})"
fi
}
What does this code do?
The function starts by checking if /etc/ssh/sshd_config exists at all. If it doesn't, SSH isn't installed and there's nothing to check.
- grep -i "^PermitRootLogin" searches for lines starting with PermitRootLogin (the ^ means "start of line" so commented-out lines are ignored).
- awk '{print $2}' extracts the second word on that line, which is the setting's value.
- The ${root_login:-default} syntax provides a fallback word if the variable is empty.
Why check these SSH settings?
PermitRootLogin should be no because root access over SSH gives an attacker full control immediately. MaxAuthTries limits password guessing attempts. The CIS Benchmark recommends keeping it at 4 or fewer.
Add the PasswordAuthentication check
Key-based authentication is far more secure than passwords. This check flags whether the server still allows password logins.
- In security-audit.sh, find the closing } of the check_ssh function.
- Add the following code ABOVE that closing }, after the MaxAuthTries block:
# Check PasswordAuthentication
local pass_auth
pass_auth=$(grep -i "^PasswordAuthentication" "$sshd_config" | awk '{print $2}')
if [ "$pass_auth" = "no" ]; then
print_result "PASS" "Password authentication is disabled (key-only)"
else
print_result "FAIL" "Password authentication is enabled (current: ${pass_auth:-yes default})"
fi
What about commented-out settings?
If a setting is commented out (has a # in front), SSH uses its built-in default. Your grep pattern uses ^PasswordAuthentication which only matches uncommented lines.
If the setting is commented out, grep returns nothing, pass_auth is empty, and the check correctly reports FAIL because the default behavior is to allow password authentication.
Test the SSH checks
Before running the script, you need to wire the new function into the main execution block.
- Scroll down to the # --- Main Execution --- section.
- Find this line:
print_banner
print_score
- Add check_ssh between print_banner and print_score, so the section now looks like this:
print_banner
check_ssh
print_score
- Save the file with Ctrl+S, then exit nano with Ctrl+X.
- Run the script by executing:
./security-audit.sh
You should see the banner, then an SSH CONFIGURATION section with three checks showing green PASS or red FAIL results, followed by your score.
Why some checks might show FAIL
On a default Ubuntu Server install, PermitRootLogin and PasswordAuthentication are often not explicitly set to no. That's expected. Your audit tool is doing its job by reporting the current state honestly.
If you see a WARN about reading /etc/shadow, that's from a different check. For the SSH checks specifically, you don't need sudo since /etc/ssh/sshd_config is typically world-readable.
Seeing a syntax error or no output?
- Check that every if has a matching fi, and the function ends with a closing }.
- Make sure there are spaces inside the square brackets: [ "$root_login" = "no" ] not ["$root_login"="no"].
- Verify the grep patterns use double quotes and the caret ^ is inside the quotes.
Help me debug a syntax error in my bash security audit script
- Inspect the actual SSH config to understand your results by running:
cat /etc/ssh/sshd_config | grep -i "PermitRootLogin\|MaxAuthTries\|PasswordAuthentication"
Compare the values you see here with what your script reported. Commented-out lines (starting with #) are ignored by your script's ^ pattern.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
#!/bin/bash
# ============================================================
# Linux Security Audit Script
# Audits Ubuntu server security across 5 categories
# Usage: sudo ./security-audit.sh
# ============================================================
# --- Color Definitions ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# --- Score Tracking ---
TOTAL_CHECKS=0
PASSED_CHECKS=0
# --- Helper Functions ---
print_banner() {
echo ""
echo -e "${BLUE}${BOLD}================================================${NC}"
echo -e "${BLUE}${BOLD} LINUX SECURITY AUDIT TOOL${NC}"
echo -e "${BLUE}${BOLD}================================================${NC}"
echo -e " Date: $(date '+%Y-%m-%d %H:%M:%S')"
echo -e " Hostname: $(hostname)"
echo -e " Kernel: $(uname -r)"
echo -e "${BLUE}${BOLD}================================================${NC}"
echo ""
}
print_section() {
echo ""
echo -e "${BOLD}[ $1 ]${NC}"
echo "------------------------------------------------"
}
print_result() {
local status="$1"
local message="$2"
TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
if [ "$status" = "PASS" ]; then
PASSED_CHECKS=$((PASSED_CHECKS + 1))
echo -e " ${GREEN}[PASS]${NC} $message"
elif [ "$status" = "FAIL" ]; then
echo -e " ${RED}[FAIL]${NC} $message"
else
echo -e " ${YELLOW}[WARN]${NC} $message"
fi
}
print_score() {
echo ""
echo -e "${BOLD}================================================${NC}"
if [ "$TOTAL_CHECKS" -gt 0 ]; then
local score=$(( (PASSED_CHECKS * 100) / TOTAL_CHECKS ))
local grade=""
if [ "$score" -ge 90 ]; then
grade="A"
elif [ "$score" -ge 75 ]; then
grade="B"
elif [ "$score" -ge 60 ]; then
grade="C"
elif [ "$score" -ge 40 ]; then
grade="D"
else
grade="F"
fi
echo -e " Security Score: ${BOLD}${score}/100${NC} (Grade: ${BOLD}${grade}${NC})"
echo -e " Checks Passed: ${PASSED_CHECKS}/${TOTAL_CHECKS}"
else
echo -e " No checks were run."
fi
echo -e "${BOLD}================================================${NC}"
echo ""
}
# --- Security Check Functions ---
check_ssh() {
print_section "SSH CONFIGURATION"
local sshd_config="/etc/ssh/sshd_config"
if [ ! -f "$sshd_config" ]; then
print_result "WARN" "sshd_config not found - SSH may not be installed"
return
fi
local root_login
root_login=$(grep -i "^PermitRootLogin" "$sshd_config" | awk '{print $2}')
if [ "$root_login" = "no" ]; then
print_result "PASS" "Root login is disabled"
else
print_result "FAIL" "Root login is not disabled (current: ${root_login:-default})"
fi
local max_auth
max_auth=$(grep -i "^MaxAuthTries" "$sshd_config" | awk '{print $2}')
if [ -n "$max_auth" ] && [ "$max_auth" -le 4 ]; then
print_result "PASS" "MaxAuthTries is set to $max_auth (4 or less)"
else
print_result "FAIL" "MaxAuthTries is too high or not set (current: ${max_auth:-6 default})"
fi
local pass_auth
pass_auth=$(grep -i "^PasswordAuthentication" "$sshd_config" | awk '{print $2}')
if [ "$pass_auth" = "no" ]; then
print_result "PASS" "Password authentication is disabled (key-only)"
else
print_result "FAIL" "Password authentication is enabled (current: ${pass_auth:-yes default})"
fi
}
# --- Main Execution ---
print_banner
check_ssh
print_score
Your audit tool now checks the most critical SSH settings. Next up, you'll add checks for user accounts and file permissions to catch backdoors and misconfigurations.
Write User Account and File Permission Checks
Your SSH checks are working. Now the script can flag insecure SSH settings, but a server can be compromised in other ways too.
Attackers often create hidden accounts with root-level privileges, or exploit files that are too open for anyone on the system to read. In this step, you will write two new functions that catch these problems.
In this step, get ready to:
- Write a function that audits user accounts for backdoors and weak passwords.
- Write a function that checks file permissions on sensitive system files.
- Test both functions together and manually verify the results.
Create the check_users function
On Linux, every account with UID 0 has full root privileges. If someone has planted an extra UID 0 account, that is a backdoor. Similarly, an account with an empty password field in /etc/shadow can be logged into without credentials.
- In your nano session, scroll down to the blank line just above # --- Main Execution ---.
- Add the following check_users function above the main execution section:
check_users() {
print_section "USER ACCOUNTS"
# Check for multiple UID 0 accounts (potential backdoors)
local uid_zero_count
uid_zero_count=$(awk -F: '($3 == 0) {print $1}' /etc/passwd | wc -l)
if [ "$uid_zero_count" -eq 1 ]; then
print_result "PASS" "Only root has UID 0"
else
print_result "FAIL" "Multiple accounts with UID 0 found ($uid_zero_count)"
fi
# Check for empty passwords in /etc/shadow
if [ -r /etc/shadow ]; then
local empty_pass
empty_pass=$(awk -F: '($2 == "") {print $1}' /etc/shadow | wc -l)
if [ "$empty_pass" -eq 0 ]; then
print_result "PASS" "No accounts with empty passwords"
else
print_result "FAIL" "Found $empty_pass account(s) with empty passwords"
fi
else
print_result "WARN" "Cannot read /etc/shadow (run as root)"
fi
# Check for world-readable home directories
local world_readable=0
while IFS=: read -r user _ uid _ _ home _; do
if [ "$uid" -ge 1000 ] && [ -d "$home" ]; then
local perms
perms=$(stat -c "%a" "$home" 2>/dev/null)
if [ -n "$perms" ]; then
local other_read=$((perms % 10))
if [ "$other_read" -ge 4 ]; then
world_readable=$((world_readable + 1))
fi
fi
fi
done < /etc/passwd
if [ "$world_readable" -eq 0 ]; then
print_result "PASS" "No world-readable home directories"
else
print_result "FAIL" "Found $world_readable world-readable home directory(ies)"
fi
}
What does this function do?
This function runs three user account checks:
- UID 0 check: awk scans /etc/passwd for any account with UID 0. Only root should have it.
- Empty password check: awk looks for blank second fields in /etc/shadow. The [ -r /etc/shadow ] guard prints a WARN if the script lacks permission to read that file.
- Home directory check: a while loop reads /etc/passwd line by line, filters to UIDs 1000+ (regular users), and uses stat -c "%a" to get octal permissions. If the last digit is 4 or higher, the directory is world-readable.
- Save the file with Ctrl+S.
You won't see output from this function yet. The next substep adds the file permissions function, then you will wire both into the main execution.
Create the check_permissions function
Sensitive system files like /etc/shadow store password hashes. If their permissions are too open, any user on the system can read them and crack passwords offline.
- Still in nano, add the following check_permissions function directly below check_users (above # --- Main Execution ---):
check_permissions() {
print_section "FILE PERMISSIONS"
# Check /etc/passwd permissions (should be 644 or less)
local passwd_perms
passwd_perms=$(stat -c "%a" /etc/passwd)
if [ "$passwd_perms" -le 644 ]; then
print_result "PASS" "/etc/passwd permissions are $passwd_perms (644 or less)"
else
print_result "FAIL" "/etc/passwd permissions are $passwd_perms (should be 644 or less)"
fi
# Check /etc/shadow permissions (should be 640 or less)
local shadow_perms
shadow_perms=$(stat -c "%a" /etc/shadow)
if [ "$shadow_perms" -le 640 ]; then
print_result "PASS" "/etc/shadow permissions are $shadow_perms (640 or less)"
else
print_result "FAIL" "/etc/shadow permissions are $shadow_perms (should be 640 or less)"
fi
# Check sshd_config permissions
if [ -f /etc/ssh/sshd_config ]; then
local sshd_perms
sshd_perms=$(stat -c "%a" /etc/ssh/sshd_config)
if [ "$sshd_perms" -le 644 ]; then
print_result "PASS" "sshd_config permissions are $sshd_perms"
else
print_result "FAIL" "sshd_config permissions are $sshd_perms (should be 644 or less)"
fi
fi
}
What does this function do?
Each check uses stat -c "%a" to read the file's permissions as an octal number, then compares it against the recommended maximum:
- /etc/passwd should be 644 or less. Everyone can read it (it holds usernames, not passwords), but only root should write.
- /etc/shadow should be 640 or less. It stores password hashes, so regular users must not be able to read it.
- /etc/ssh/sshd_config should be 644 or less. The SSH daemon config should not be world-writable.
- Save the file with Ctrl+S.
You won't see output yet. The next substep wires these functions into the main execution and runs the full audit.
Test both functions
Both functions exist in the file, but the main execution section still only calls check_ssh. You need to add calls to the two new functions.
- Scroll down to the # --- Main Execution --- section at the bottom of the file.
- Find these lines:
print_banner
check_ssh
print_score
- Add check_users and check_permissions between check_ssh and print_score, so the section looks like this:
print_banner
check_ssh
check_users
check_permissions
print_score
- Save with Ctrl+S and exit nano with Ctrl+X.
- Run the full script with root privileges by running this command:
sudo ./security-audit.sh
You should see the banner, then three sections: SSH CONFIGURATION, USER ACCOUNTS, and FILE PERMISSIONS. Each section displays PASS, FAIL, or WARN results followed by a score at the bottom.
- Manually verify the shadow file permissions by running this command:
ls -la /etc/shadow
Compare the permissions you see (e.g. -rw-r-----) with what the script reported. This confirms the script reads file permissions correctly.
Seeing WARN for /etc/shadow?
If you see a WARN message about not being able to read /etc/shadow, make sure you ran the script with sudo. Without root privileges, the script cannot access the shadow file.
If you see a syntax error, check that the awk -F: lines use single quotes around the pattern, and that the while loop ends with done < /etc/passwd on its own line.
Still stuck? Help me debug my check_users or check_permissions function in my bash security audit script.
Why some checks might FAIL
A FAIL result does not mean your server is broken. It means that particular setting does not match the hardened recommendation. For example, most default Ubuntu installs have PasswordAuthentication enabled and no firewall active.
These results give you a clear list of improvements to make. The goal is visibility, not a perfect score on the first run.
✔️ Awesome, I've got everything!
Great. Make sure you have saved security-audit.sh before moving on.
ⓧ I'd like to double check the full code
Here is the complete security-audit.sh file as it should look at the end of this step:
#!/bin/bash
# ============================================================
# Linux Security Audit Script
# Audits Ubuntu server security across 5 categories
# Usage: sudo ./security-audit.sh
# ============================================================
# --- Color Definitions ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# --- Score Tracking ---
TOTAL_CHECKS=0
PASSED_CHECKS=0
# --- Helper Functions ---
print_banner() {
echo ""
echo -e "${BLUE}${BOLD}================================================${NC}"
echo -e "${BLUE}${BOLD} LINUX SECURITY AUDIT TOOL${NC}"
echo -e "${BLUE}${BOLD}================================================${NC}"
echo -e " Date: $(date '+%Y-%m-%d %H:%M:%S')"
echo -e " Hostname: $(hostname)"
echo -e " Kernel: $(uname -r)"
echo -e "${BLUE}${BOLD}================================================${NC}"
echo ""
}
print_section() {
echo ""
echo -e "${BOLD}[ $1 ]${NC}"
echo "------------------------------------------------"
}
print_result() {
local status="$1"
local message="$2"
TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
if [ "$status" = "PASS" ]; then
PASSED_CHECKS=$((PASSED_CHECKS + 1))
echo -e " ${GREEN}[PASS]${NC} $message"
elif [ "$status" = "FAIL" ]; then
echo -e " ${RED}[FAIL]${NC} $message"
else
echo -e " ${YELLOW}[WARN]${NC} $message"
fi
}
print_score() {
echo ""
echo -e "${BOLD}================================================${NC}"
if [ "$TOTAL_CHECKS" -gt 0 ]; then
local score=$(( (PASSED_CHECKS * 100) / TOTAL_CHECKS ))
local grade=""
if [ "$score" -ge 90 ]; then
grade="A"
elif [ "$score" -ge 75 ]; then
grade="B"
elif [ "$score" -ge 60 ]; then
grade="C"
elif [ "$score" -ge 40 ]; then
grade="D"
else
grade="F"
fi
echo -e " Security Score: ${BOLD}${score}/100${NC} (Grade: ${BOLD}${grade}${NC})"
echo -e " Checks Passed: ${PASSED_CHECKS}/${TOTAL_CHECKS}"
else
echo -e " No checks were run."
fi
echo -e "${BOLD}================================================${NC}"
echo ""
}
# --- Security Check Functions ---
check_ssh() {
print_section "SSH CONFIGURATION"
local sshd_config="/etc/ssh/sshd_config"
if [ ! -f "$sshd_config" ]; then
print_result "WARN" "sshd_config not found - SSH may not be installed"
return
fi
local root_login
root_login=$(grep -i "^PermitRootLogin" "$sshd_config" | awk '{print $2}')
if [ "$root_login" = "no" ]; then
print_result "PASS" "Root login is disabled"
else
print_result "FAIL" "Root login is not disabled (current: ${root_login:-default})"
fi
local max_auth
max_auth=$(grep -i "^MaxAuthTries" "$sshd_config" | awk '{print $2}')
if [ -n "$max_auth" ] && [ "$max_auth" -le 4 ]; then
print_result "PASS" "MaxAuthTries is set to $max_auth (4 or less)"
else
print_result "FAIL" "MaxAuthTries is too high or not set (current: ${max_auth:-6 default})"
fi
local pass_auth
pass_auth=$(grep -i "^PasswordAuthentication" "$sshd_config" | awk '{print $2}')
if [ "$pass_auth" = "no" ]; then
print_result "PASS" "Password authentication is disabled (key-only)"
else
print_result "FAIL" "Password authentication is enabled (current: ${pass_auth:-yes default})"
fi
}
check_users() {
print_section "USER ACCOUNTS"
local uid_zero_count
uid_zero_count=$(awk -F: '($3 == 0) {print $1}' /etc/passwd | wc -l)
if [ "$uid_zero_count" -eq 1 ]; then
print_result "PASS" "Only root has UID 0"
else
print_result "FAIL" "Multiple accounts with UID 0 found ($uid_zero_count)"
fi
if [ -r /etc/shadow ]; then
local empty_pass
empty_pass=$(awk -F: '($2 == "") {print $1}' /etc/shadow | wc -l)
if [ "$empty_pass" -eq 0 ]; then
print_result "PASS" "No accounts with empty passwords"
else
print_result "FAIL" "Found $empty_pass account(s) with empty passwords"
fi
else
print_result "WARN" "Cannot read /etc/shadow (run as root)"
fi
local world_readable=0
while IFS=: read -r user _ uid _ _ home _; do
if [ "$uid" -ge 1000 ] && [ -d "$home" ]; then
local perms
perms=$(stat -c "%a" "$home" 2>/dev/null)
if [ -n "$perms" ]; then
local other_read=$((perms % 10))
if [ "$other_read" -ge 4 ]; then
world_readable=$((world_readable + 1))
fi
fi
fi
done < /etc/passwd
if [ "$world_readable" -eq 0 ]; then
print_result "PASS" "No world-readable home directories"
else
print_result "FAIL" "Found $world_readable world-readable home directory(ies)"
fi
}
check_permissions() {
print_section "FILE PERMISSIONS"
local passwd_perms
passwd_perms=$(stat -c "%a" /etc/passwd)
if [ "$passwd_perms" -le 644 ]; then
print_result "PASS" "/etc/passwd permissions are $passwd_perms (644 or less)"
else
print_result "FAIL" "/etc/passwd permissions are $passwd_perms (should be 644 or less)"
fi
local shadow_perms
shadow_perms=$(stat -c "%a" /etc/shadow)
if [ "$shadow_perms" -le 640 ]; then
print_result "PASS" "/etc/shadow permissions are $shadow_perms (640 or less)"
else
print_result "FAIL" "/etc/shadow permissions are $shadow_perms (should be 640 or less)"
fi
if [ -f /etc/ssh/sshd_config ]; then
local sshd_perms
sshd_perms=$(stat -c "%a" /etc/ssh/sshd_config)
if [ "$sshd_perms" -le 644 ]; then
print_result "PASS" "sshd_config permissions are $sshd_perms"
else
print_result "FAIL" "sshd_config permissions are $sshd_perms (should be 644 or less)"
fi
fi
}
# --- Main Execution ---
print_banner
check_ssh
check_users
check_permissions
print_score
Your script now audits three categories. Next up, you will add kernel security parameters and network checks to complete the full audit.
Write Kernel and Network Security Checks
Your script already checks SSH, user accounts, and file permissions. Those cover the application layer, but a server's security also depends on how the kernel itself is configured.
In this step, you'll add checks for kernel-level protections (like address randomization and packet forwarding) and network defenses (firewall status and open ports). These are the final two categories that complete your security audit tool.
In this step, get ready to:
- Write kernel security checks using sysctl to read live kernel parameters.
- Write network security checks for firewall status, open ports, and ICMP settings.
- Run the complete audit with all 5 sections and review your security score.
Create the check_kernel function
The Linux kernel exposes hundreds of tunable parameters that control how your system handles memory, networking, and security. The sysctl command lets you read these values at runtime.
- Open the script in nano by running:
nano security-audit.sh
- Scroll down to find the closing } of the check_permissions function.
- Add the following check_kernel function below it:
check_kernel() {
print_section "KERNEL SECURITY"
# Check ASLR (Address Space Layout Randomization)
local aslr
aslr=$(sysctl -n kernel.randomize_va_space 2>/dev/null)
if [ "$aslr" = "2" ]; then
print_result "PASS" "ASLR is fully enabled (value: $aslr)"
elif [ "$aslr" = "1" ]; then
print_result "WARN" "ASLR is partially enabled (value: $aslr, recommended: 2)"
else
print_result "FAIL" "ASLR is disabled (value: ${aslr:-unknown})"
fi
# Check IP forwarding
local ip_forward
ip_forward=$(sysctl -n net.ipv4.ip_forward 2>/dev/null)
if [ "$ip_forward" = "0" ]; then
print_result "PASS" "IP forwarding is disabled"
else
print_result "FAIL" "IP forwarding is enabled (potential routing risk)"
fi
# Check SYN cookies
local syncookies
syncookies=$(sysctl -n net.ipv4.tcp_syncookies 2>/dev/null)
if [ "$syncookies" = "1" ]; then
print_result "PASS" "SYN cookies are enabled (SYN flood protection)"
else
print_result "FAIL" "SYN cookies are disabled (vulnerable to SYN floods)"
fi
}
What does this code do?
This function checks three kernel security parameters using sysctl -n (the -n flag prints only the value, not the key name).
- kernel.randomize_va_space controls ASLR (Address Space Layout Randomization). A value of 2 means full randomization is enabled, making it harder for attackers to predict memory addresses.
- net.ipv4.ip_forward controls whether the system routes packets between network interfaces. A value of 0 means disabled. Unless your server is intentionally a router, this should stay off.
- net.ipv4.tcp_syncookies protects against SYN flood attacks. A value of 1 means this protection is active.
What is sysctl?
sysctl is a Linux utility that reads and writes kernel parameters at runtime. Think of it as a control panel for the deepest settings on your system. Security auditors use it to verify that protective features are actually turned on.
Don't save yet. You'll add the network function next, then save everything together.
Create the check_network function
Network security goes beyond the kernel. You also need to know whether a firewall is running, how many services are exposed, and whether the system accepts potentially dangerous ICMP redirects.
- Add the following check_network function directly below the check_kernel function you just wrote:
check_network() {
print_section "NETWORK SECURITY"
# Check if firewall is active
if command -v ufw > /dev/null 2>&1; then
local ufw_status
ufw_status=$(ufw status 2>/dev/null | head -1)
if echo "$ufw_status" | grep -q "active"; then
print_result "PASS" "UFW firewall is active"
else
print_result "FAIL" "UFW firewall is inactive"
fi
else
print_result "WARN" "UFW is not installed"
fi
# Check listening services count
local listen_count
listen_count=$(ss -tlnp 2>/dev/null | tail -n +2 | wc -l)
if [ "$listen_count" -le 5 ]; then
print_result "PASS" "Listening services: $listen_count (5 or fewer)"
else
print_result "WARN" "Listening services: $listen_count (review if all are needed)"
fi
# Check ICMP redirects
local icmp_redirects
icmp_redirects=$(sysctl -n net.ipv4.conf.all.accept_redirects 2>/dev/null)
if [ "$icmp_redirects" = "0" ]; then
print_result "PASS" "ICMP redirects are disabled"
else
print_result "FAIL" "ICMP redirects are accepted (potential MITM risk)"
fi
}
What does this code do?
This function checks three network security indicators:
- command -v ufw first checks if UFW (Uncomplicated Firewall) is even installed. If it is, ufw status reports whether it is active or inactive.
- ss -tlnp lists all TCP listening sockets with numeric ports and process info. The function counts them and flags when more than 5 services are exposed.
- net.ipv4.conf.all.accept_redirects controls whether the system accepts ICMP redirect messages. Attackers can use these to redirect traffic through a malicious host (a man-in-the-middle attack).
Don't save yet. There is one more change to make in the main execution section.
Run the complete audit and review your score
Your two new functions exist, but the script does not call them yet. You need to add them to the main execution section at the bottom of the file.
- Scroll down to the # --- Main Execution --- section at the bottom of your file.
- Find this line:
check_permissions
print_score
- Add check_kernel and check_network between check_permissions and print_score, so the section now looks like this:
# --- Main Execution ---
print_banner
check_ssh
check_users
check_permissions
check_kernel
check_network
print_score
- Save the file with Ctrl+S and exit nano with Ctrl+X.
Why the order matters
The order of function calls in the main execution section determines the order of sections in your report. Placing kernel and network checks after file permissions keeps the report organized from application-level concerns down to system-level concerns.
- Run the complete audit with root privileges by running:
sudo ./security-audit.sh
You should now see all 5 sections in your report: SSH CONFIGURATION, USER ACCOUNTS, FILE PERMISSIONS, KERNEL SECURITY, and NETWORK SECURITY. Each check displays a color-coded PASS, FAIL, or WARN result, and the final score reflects all checks combined.
Why sudo?
Some checks (reading /etc/shadow, checking UFW status) require root privileges. Without sudo, those checks gracefully fall back to WARN messages instead of crashing.
Seeing syntax errors?
Check that every if statement has a matching fi. Missing a single fi causes the entire script to fail.
Make sure the closing } of each function is on its own line. If it is accidentally merged with another line, bash cannot parse the function boundary.
help me debug a syntax error in my bash security audit script
- Review your report output. Identify which checks passed and which failed.
- Manually verify a kernel check by running:
sysctl -n kernel.randomize_va_space
The value you see should match what the script reported for the ASLR check.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file and that all 5 sections appear when you run the script.
ⓧ I'd like to double check the full code
Here is the complete security-audit.sh file at this point:
#!/bin/bash
# ============================================================
# Linux Security Audit Script
# Audits Ubuntu server security across 5 categories
# Usage: sudo ./security-audit.sh
# ============================================================
# --- Color Definitions ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# --- Score Tracking ---
TOTAL_CHECKS=0
PASSED_CHECKS=0
# --- Helper Functions ---
print_banner() {
echo ""
echo -e "${BLUE}${BOLD}================================================${NC}"
echo -e "${BLUE}${BOLD} LINUX SECURITY AUDIT TOOL${NC}"
echo -e "${BLUE}${BOLD}================================================${NC}"
echo -e " Date: $(date '+%Y-%m-%d %H:%M:%S')"
echo -e " Hostname: $(hostname)"
echo -e " Kernel: $(uname -r)"
echo -e "${BLUE}${BOLD}================================================${NC}"
echo ""
}
print_section() {
echo ""
echo -e "${BOLD}[ $1 ]${NC}"
echo "------------------------------------------------"
}
print_result() {
local status="$1"
local message="$2"
TOTAL_CHECKS=$((TOTAL_CHECKS + 1))
if [ "$status" = "PASS" ]; then
PASSED_CHECKS=$((PASSED_CHECKS + 1))
echo -e " ${GREEN}[PASS]${NC} $message"
elif [ "$status" = "FAIL" ]; then
echo -e " ${RED}[FAIL]${NC} $message"
else
echo -e " ${YELLOW}[WARN]${NC} $message"
fi
}
print_score() {
echo ""
echo -e "${BOLD}================================================${NC}"
if [ "$TOTAL_CHECKS" -gt 0 ]; then
local score=$(( (PASSED_CHECKS * 100) / TOTAL_CHECKS ))
local grade=""
if [ "$score" -ge 90 ]; then
grade="A"
elif [ "$score" -ge 75 ]; then
grade="B"
elif [ "$score" -ge 60 ]; then
grade="C"
elif [ "$score" -ge 40 ]; then
grade="D"
else
grade="F"
fi
echo -e " Security Score: ${BOLD}${score}/100${NC} (Grade: ${BOLD}${grade}${NC})"
echo -e " Checks Passed: ${PASSED_CHECKS}/${TOTAL_CHECKS}"
else
echo -e " No checks were run."
fi
echo -e "${BOLD}================================================${NC}"
echo ""
}
# --- Security Check Functions ---
check_ssh() {
print_section "SSH CONFIGURATION"
local sshd_config="/etc/ssh/sshd_config"
if [ ! -f "$sshd_config" ]; then
print_result "WARN" "sshd_config not found - SSH may not be installed"
return
fi
local root_login
root_login=$(grep -i "^PermitRootLogin" "$sshd_config" | awk '{print $2}')
if [ "$root_login" = "no" ]; then
print_result "PASS" "Root login is disabled"
else
print_result "FAIL" "Root login is not disabled (current: ${root_login:-default})"
fi
local max_auth
max_auth=$(grep -i "^MaxAuthTries" "$sshd_config" | awk '{print $2}')
if [ -n "$max_auth" ] && [ "$max_auth" -le 4 ]; then
print_result "PASS" "MaxAuthTries is set to $max_auth (4 or less)"
else
print_result "FAIL" "MaxAuthTries is too high or not set (current: ${max_auth:-6 default})"
fi
local pass_auth
pass_auth=$(grep -i "^PasswordAuthentication" "$sshd_config" | awk '{print $2}')
if [ "$pass_auth" = "no" ]; then
print_result "PASS" "Password authentication is disabled (key-only)"
else
print_result "FAIL" "Password authentication is enabled (current: ${pass_auth:-yes default})"
fi
}
check_users() {
print_section "USER ACCOUNTS"
local uid_zero_count
uid_zero_count=$(awk -F: '($3 == 0) {print $1}' /etc/passwd | wc -l)
if [ "$uid_zero_count" -eq 1 ]; then
print_result "PASS" "Only root has UID 0"
else
print_result "FAIL" "Multiple accounts with UID 0 found ($uid_zero_count)"
fi
if [ -r /etc/shadow ]; then
local empty_pass
empty_pass=$(awk -F: '($2 == "") {print $1}' /etc/shadow | wc -l)
if [ "$empty_pass" -eq 0 ]; then
print_result "PASS" "No accounts with empty passwords"
else
print_result "FAIL" "Found $empty_pass account(s) with empty passwords"
fi
else
print_result "WARN" "Cannot read /etc/shadow (run as root)"
fi
local world_readable=0
while IFS=: read -r user _ uid _ _ home _; do
if [ "$uid" -ge 1000 ] && [ -d "$home" ]; then
local perms
perms=$(stat -c "%a" "$home" 2>/dev/null)
if [ -n "$perms" ]; then
local other_read=$((perms % 10))
if [ "$other_read" -ge 4 ]; then
world_readable=$((world_readable + 1))
fi
fi
fi
done < /etc/passwd
if [ "$world_readable" -eq 0 ]; then
print_result "PASS" "No world-readable home directories"
else
print_result "FAIL" "Found $world_readable world-readable home directory(ies)"
fi
}
check_permissions() {
print_section "FILE PERMISSIONS"
local passwd_perms
passwd_perms=$(stat -c "%a" /etc/passwd)
if [ "$passwd_perms" -le 644 ]; then
print_result "PASS" "/etc/passwd permissions are $passwd_perms (644 or less)"
else
print_result "FAIL" "/etc/passwd permissions are $passwd_perms (should be 644 or less)"
fi
local shadow_perms
shadow_perms=$(stat -c "%a" /etc/shadow)
if [ "$shadow_perms" -le 640 ]; then
print_result "PASS" "/etc/shadow permissions are $shadow_perms (640 or less)"
else
print_result "FAIL" "/etc/shadow permissions are $shadow_perms (should be 640 or less)"
fi
if [ -f /etc/ssh/sshd_config ]; then
local sshd_perms
sshd_perms=$(stat -c "%a" /etc/ssh/sshd_config)
if [ "$sshd_perms" -le 644 ]; then
print_result "PASS" "sshd_config permissions are $sshd_perms"
else
print_result "FAIL" "sshd_config permissions are $sshd_perms (should be 644 or less)"
fi
fi
}
check_kernel() {
print_section "KERNEL SECURITY"
local aslr
aslr=$(sysctl -n kernel.randomize_va_space 2>/dev/null)
if [ "$aslr" = "2" ]; then
print_result "PASS" "ASLR is fully enabled (value: $aslr)"
elif [ "$aslr" = "1" ]; then
print_result "WARN" "ASLR is partially enabled (value: $aslr, recommended: 2)"
else
print_result "FAIL" "ASLR is disabled (value: ${aslr:-unknown})"
fi
local ip_forward
ip_forward=$(sysctl -n net.ipv4.ip_forward 2>/dev/null)
if [ "$ip_forward" = "0" ]; then
print_result "PASS" "IP forwarding is disabled"
else
print_result "FAIL" "IP forwarding is enabled (potential routing risk)"
fi
local syncookies
syncookies=$(sysctl -n net.ipv4.tcp_syncookies 2>/dev/null)
if [ "$syncookies" = "1" ]; then
print_result "PASS" "SYN cookies are enabled (SYN flood protection)"
else
print_result "FAIL" "SYN cookies are disabled (vulnerable to SYN floods)"
fi
}
check_network() {
print_section "NETWORK SECURITY"
if command -v ufw > /dev/null 2>&1; then
local ufw_status
ufw_status=$(ufw status 2>/dev/null | head -1)
if echo "$ufw_status" | grep -q "active"; then
print_result "PASS" "UFW firewall is active"
else
print_result "FAIL" "UFW firewall is inactive"
fi
else
print_result "WARN" "UFW is not installed"
fi
local listen_count
listen_count=$(ss -tlnp 2>/dev/null | tail -n +2 | wc -l)
if [ "$listen_count" -le 5 ]; then
print_result "PASS" "Listening services: $listen_count (5 or fewer)"
else
print_result "WARN" "Listening services: $listen_count (review if all are needed)"
fi
local icmp_redirects
icmp_redirects=$(sysctl -n net.ipv4.conf.all.accept_redirects 2>/dev/null)
if [ "$icmp_redirects" = "0" ]; then
print_result "PASS" "ICMP redirects are disabled"
else
print_result "FAIL" "ICMP redirects are accepted (potential MITM risk)"
fi
}
# --- Main Execution ---
print_banner
check_ssh
check_users
check_permissions
check_kernel
check_network
print_score
Your security audit tool is complete. You now have a script that checks 13+ security settings across SSH, users, files, kernel, and network in seconds. Next up, the Secret Mission challenges you to add persistent logging so you can track your score over time.
Secret mission
Add Logging and Timestamp Tracking
Your audit script runs once and the results disappear from the screen. What if you could save every audit to a file and compare them over time? Build a logging feature that tracks your security posture across multiple runs.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local VMs, so there are no ongoing costs or cloud charges to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The ~/security-audit/ directory on your Ubuntu server (contains your script and logs).
- The security-audit.sh bash script.
- The ~/security-audit/logs/ directory with timestamped audit log files.
Keep everything running
No action needed. Choose this if you plan to keep auditing your Ubuntu server or want to continue improving the script.
- Your script stays at ~/security-audit/security-audit.sh and is ready to run anytime with sudo ./security-audit.sh.
- Your log history stays in ~/security-audit/logs/ so you can track changes over time.
- The script uses only built-in Linux utilities, so nothing extra is consuming resources on your system.
Pause - I'll come back to this later
Since this is a local script with no running processes, there is nothing to pause. The script only runs when you execute it manually.
- Your files remain on disk and take up minimal space (a few kilobytes).
- When you are ready to come back, connect to your Ubuntu server and run cd ~/security-audit to pick up where you left off.
Delete - I don't want to use this again
Remove the entire project directory from your Ubuntu server. This deletes the script, all log files, and the folder itself.
- Delete the project directory by running this command on your Ubuntu server:
rm -rf ~/security-audit
- Confirm the directory is gone by running:
ls ~/security-audit
- You should see a message saying the directory cannot be found. Your system is clean.
Nice Work!
Nice Work!
You just built a real security auditing tool from scratch using nothing but Bash and built-in Linux utilities. This is exactly the kind of automation that sysadmins and security engineers rely on every day.
You've learned how to:
- Write a modular bash script organized into functions with variables, conditionals, and color-coded terminal output.
- Automate 13+ security checks across SSH configuration, user accounts, file permissions, kernel parameters, and network settings.
- Generate a scored security report (0-100) with letter grades that you can run on any Ubuntu server you manage.
- Added persistent logging with timestamp tracking to monitor your security posture over time.
Ready to quiz yourself?