Skip to content

Lab 03 β€” Linux Security Automation with Bash

Difficulty: Intermediate
Estimated Time: 90–120 minutes
Primary Language: Bash
Security Domain: Linux Security / SOC / System Hardening / Incident Triage
Environment: Local Linux lab
Automation Type: Defensive Security Assessment

Your task is to build a Bash-based Linux security assessment script for a Linux system that you own or are explicitly authorized to administer.

The script will collect and summarize:

SYSTEM INFORMATION
CURRENT USER CONTEXT
LOCAL USERS
GROUPS
SUDO / PRIVILEGED ACCESS CONTEXT
FILE PERMISSIONS
RUNNING PROCESSES
RUNNING SERVICES
LISTENING PORTS
DISK USAGE
MEMORY USAGE
AUTHENTICATION EVENTS
SELECTED SECURITY CONFIGURATION

The final result will be:

LINUX HOST
↓
BASH SCRIPT
↓
COLLECT
↓
FILTER
↓
SUMMARIZE
↓
REPORT

Linux appears everywhere in cybersecurity.

You may find it running:

WEB SERVERS
CLOUD SERVERS
CONTAINERS
SECURITY APPLIANCES
LOGGING SYSTEMS
SIEM INFRASTRUCTURE
DATABASE SERVERS
APPLICATION SERVERS
KUBERNETES NODES

A security professional therefore needs to quickly answer questions such as:

What system am I looking at?
Who is logged in?
Which users exist?
Which accounts have privileged access?
Which processes are running?
Which services are enabled?
Which ports are listening?
Is disk space healthy?
What authentication failures occurred?
Are there risky file permissions?

Bash is one of the fastest ways to automate those checks.

By completing this lab, you should be able to:

BUILD A BASH SCRIPT
USE VARIABLES
CREATE FUNCTIONS
USE CONDITIONS
USE LOOPS
HANDLE FILE PATHS SAFELY
USE grep
USE awk
USE sort
USE uniq
USE find
USE ps
USE ss
USE systemctl
USE journalctl
CREATE REPORT FILES
LOG SCRIPT EXECUTION
HANDLE ERRORS
BUILD A REUSABLE LINUX SECURITY WORKFLOW
LINUX HOST
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ BASH SCRIPT β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
↓ ↓ ↓
SYSTEM USERS PROCESSES
↓ ↓ ↓
SERVICES PERMISSIONS NETWORK
↓ ↓ ↓
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
AUTHENTICATION LOGS
↓
SECURITY REPORT

Run this lab only on:

YOUR OWN LINUX VM
TRAINING LAB
AUTHORIZED SERVER
SANDBOX ENVIRONMENT

This lab is designed for:

INVENTORY
VISIBILITY
AUDITING
DEFENSIVE REVIEW

It does not require exploitation, persistence, credential theft, or disabling security controls.

You can use:

Ubuntu
Debian
Rocky Linux
AlmaLinux
CentOS Stream
Fedora

Examples in this lab assume a modern Linux distribution using:

systemd

Some commands may vary slightly between distributions.

Run:

Terminal window
bash --version

You should see something similar to:

GNU bash

Create:

Terminal window
mkdir -p linux-security-automation/{reports,logs,src}
cd linux-security-automation

Verify:

Terminal window
tree

If tree is unavailable:

Terminal window
find . -maxdepth 2 -type d

Expected:

linux-security-automation/
|
+-- logs/
|
+-- reports/
|
+-- src/

Create:

src/linux-security-audit.sh

Open it using your preferred editor.

Example:

Terminal window
nano src/linux-security-audit.sh

Start with:

#!/usr/bin/env bash

This tells the system to execute the file using Bash.

Add:

Terminal window
set -euo pipefail

This enables:

-e
Stop on many command failures
-u
Treat unset variables as errors
-o pipefail
Detect failures inside pipelines

Without careful error handling:

COMMAND FAILS
↓
SCRIPT CONTINUES
↓
REPORT MAY BE WRONG

With safer handling:

COMMAND FAILS
↓
ERROR BECOMES VISIBLE
↓
SCRIPT CAN STOP OR HANDLE IT

Add:

Terminal window
SCRIPT_DIR="$(
cd "$(dirname "${BASH_SOURCE[0]}")"
&& pwd
)"
BASE_DIR="$(
cd "${SCRIPT_DIR}/.."
&& pwd
)"
REPORT_DIR="${BASE_DIR}/reports"
LOG_DIR="${BASE_DIR}/logs"

Add:

Terminal window
mkdir -p "$REPORT_DIR"
mkdir -p "$LOG_DIR"

Add:

Terminal window
RUN_TIMESTAMP="$(
date +"%Y%m%d-%H%M%S"
)"

Add:

Terminal window
REPORT_FILE="${REPORT_DIR}/linux-security-report-${RUN_TIMESTAMP}.txt"
LOG_FILE="${LOG_DIR}/linux-security-audit-${RUN_TIMESTAMP}.log"

Add:

Terminal window
log_message() {
local level="$1"
local message="$2"
printf '%s [%s] %s\n' \
"$(date +"%Y-%m-%dT%H:%M:%S%z")" \
"$level" \
"$message" \
| tee -a "$LOG_FILE"
}

Add temporarily:

Terminal window
log_message "INFO" "Test message"

Run:

Terminal window
bash src/linux-security-audit.sh

You should see:

[INFO] Test message

and a log file should appear in:

logs/

After confirming logging works, remove:

Terminal window
log_message "INFO" "Test message"

Add:

Terminal window
write_section() {
local title="$1"
{
echo
echo "============================================================"
echo "$title"
echo "============================================================"
} >> "$REPORT_FILE"
}

Add:

Terminal window
initialize_report() {
{
echo "Linux Security Assessment Report"
echo
echo "Generated: $(date)"
echo "Hostname: $(hostname)"
echo "Script: linux-security-audit.sh"
} > "$REPORT_FILE"
}

Create:

Terminal window
main() {
log_message "INFO" "Linux security assessment started"
initialize_report
log_message "INFO" "Report initialized"
log_message "INFO" "Linux security assessment completed"
echo
echo "Report:"
echo "$REPORT_FILE"
}

Then add:

Terminal window
main "$@"

Run:

Terminal window
chmod +x src/linux-security-audit.sh

Then:

Terminal window
./src/linux-security-audit.sh

Add:

Terminal window
collect_system_information() {
write_section "01 β€” System Information"
{
echo "Hostname:"
hostname
echo
echo "Kernel:"
uname -r
echo
echo "Architecture:"
uname -m
echo
echo "Operating System:"
if [[ -f /etc/os-release ]]; then
cat /etc/os-release
else
echo "OS information unavailable"
fi
echo
echo "Uptime:"
uptime
} >> "$REPORT_FILE"
}

Add:

Terminal window
collect_system_information

Run the script again.

Review:

reports/

Before analyzing a Linux server, determine:

HOSTNAME
OPERATING SYSTEM
KERNEL
ARCHITECTURE
UPTIME

These values help establish:

WHAT SYSTEM IS THIS?
HOW LONG HAS IT BEEN RUNNING?
WHAT PLATFORM-SPECIFIC COMMANDS APPLY?

Create:

Terminal window
collect_current_user() {
write_section "02 β€” Current User Context"
{
echo "Current user:"
whoami
echo
echo "User ID and groups:"
id
echo
echo "Current shell:"
echo "${SHELL:-unknown}"
echo
echo "Logged-in users:"
who || true
} >> "$REPORT_FILE"
}

Always know:

WHO AM I?
WHAT GROUPS DO I BELONG TO?
WHAT PRIVILEGES DOES THIS SESSION HAVE?

before interpreting results.

Create:

Terminal window
collect_local_users() {
write_section "03 β€” Local User Inventory"
awk -F: '
{
printf "user=%s uid=%s gid=%s shell=%s\n",
$1, $3, $4, $7
}
' /etc/passwd >> "$REPORT_FILE"
}

Typical format:

username:x:uid:gid:comment:home:shell

Example:

analyst:x:1001:1001:Analyst:/home/analyst:/bin/bash

The file does not normally contain plaintext passwords.

UID conventions vary, but regular interactive users often have higher UID values.

For many distributions you may inspect:

Terminal window
awk -F: '$3 >= 1000 {print $1, $3, $7}' /etc/passwd

Do not treat this as universal across every Linux environment.

Create an additional report block:

Terminal window
awk -F: '
$7 !~ /(nologin|false)$/ {
print $1, $3, $7
}
' /etc/passwd

This can help identify accounts capable of interactive login.

Create:

Terminal window
collect_groups() {
write_section "04 β€” Group Inventory"
awk -F: '
{
printf "group=%s gid=%s members=%s\n",
$1, $3, $4
}
' /etc/group >> "$REPORT_FILE"
}

Common administrative groups may include:

sudo
wheel

Distribution-dependent.

Create:

Terminal window
collect_privileged_groups() {
write_section "05 β€” Privileged Group Review"
{
if getent group sudo >/dev/null 2>&1; then
echo "sudo group:"
getent group sudo
else
echo "sudo group not present"
fi
echo
if getent group wheel >/dev/null 2>&1; then
echo "wheel group:"
getent group wheel
else
echo "wheel group not present"
fi
} >> "$REPORT_FILE"
}

Create:

Terminal window
collect_root_context() {
write_section "06 β€” Root Account Context"
{
getent passwd root
echo
echo "Accounts with UID 0:"
awk -F: '$3 == 0 {print $1}' /etc/passwd
} >> "$REPORT_FILE"
}

Normally you should understand why every:

UID 0

account exists.

Unexpected UID 0 accounts require review.

Create:

Terminal window
collect_home_directories() {
write_section "07 β€” Home Directory Review"
{
echo "Home directories:"
ls -ld /home/* 2>/dev/null || true
} >> "$REPORT_FILE"
}

Overly permissive home directories can expose:

CONFIGURATION FILES
SHELL HISTORY
APPLICATION DATA
USER FILES

A world-writable file can be modified by any local user.

This may be legitimate in some locations, but deserves review when found in sensitive directories.

Do not start by scanning every mounted filesystem.

For the lab, inspect:

/etc
/opt
/usr/local

where permitted.

Create:

Terminal window
collect_world_writable_files() {
write_section "08 β€” World-Writable File Review"
{
find /etc /opt /usr/local \
-xdev \
-type f \
-perm -0002 \
-print \
2>/dev/null || true
} >> "$REPORT_FILE"
}

-xdev helps prevent find from crossing into other mounted filesystems.

This reduces unexpected scope.

Create:

Terminal window
collect_world_writable_directories() {
write_section "09 β€” World-Writable Directory Review"
{
find /etc /opt /usr/local \
-xdev \
-type d \
-perm -0002 \
-print \
2>/dev/null || true
} >> "$REPORT_FILE"
}

World-writable does not automatically equal:

VULNERABILITY

For example, some directories intentionally support shared writing.

Always inspect:

PURPOSE
OWNER
STICKY BIT
LOCATION
APPLICATION REQUIREMENT

SUID files execute with the file owner’s effective permissions.

They deserve inventory and review.

For a controlled lab:

Terminal window
find /usr/bin /usr/sbin /bin /sbin \
-xdev \
-type f \
-perm -4000 \
-print \
2>/dev/null

Create:

Terminal window
collect_suid_files() {
write_section "10 β€” SUID File Inventory"
find \
/usr/bin \
/usr/sbin \
/bin \
/sbin \
-xdev \
-type f \
-perm -4000 \
-print \
2>/dev/null \
>> "$REPORT_FILE" || true
}

This lab inventories privileged files for defensive review.

Do not use the results to attempt unauthorized privilege escalation.

Create:

Terminal window
collect_sgid_files() {
write_section "11 β€” SGID File Inventory"
find \
/usr/bin \
/usr/sbin \
/bin \
/sbin \
-xdev \
-type f \
-perm -2000 \
-print \
2>/dev/null \
>> "$REPORT_FILE" || true
}

Create:

Terminal window
collect_processes() {
write_section "12 β€” Running Processes"
ps aux \
--sort=-%cpu \
>> "$REPORT_FILE"
}

Processes show:

WHAT IS CURRENTLY EXECUTING?
WHO OWNS IT?
HOW MUCH CPU / MEMORY?
WHAT COMMAND STARTED IT?

Add:

Terminal window
{
echo
echo "Top CPU-consuming processes:"
ps aux --sort=-%cpu | head -n 11
} >> "$REPORT_FILE"

Add:

Terminal window
{
echo
echo "Top memory-consuming processes:"
ps aux --sort=-%mem | head -n 11
} >> "$REPORT_FILE"

If systemd is available:

Terminal window
systemctl list-units \
--type=service \
--state=running

Create:

Terminal window
collect_running_services() {
write_section "13 β€” Running Services"
if command -v systemctl >/dev/null 2>&1; then
systemctl list-units \
--type=service \
--state=running \
--no-pager \
>> "$REPORT_FILE" || true
else
echo "systemctl unavailable" \
>> "$REPORT_FILE"
fi
}

A service may not be running now but may start automatically during boot.

Create:

Terminal window
collect_enabled_services() {
write_section "14 β€” Enabled Services"
if command -v systemctl >/dev/null 2>&1; then
systemctl list-unit-files \
--type=service \
--state=enabled \
--no-pager \
>> "$REPORT_FILE" || true
else
echo "systemctl unavailable" \
>> "$REPORT_FILE"
fi
}

For every running or enabled service, ask:

DO WE NEED THIS SERVICE?
WHO OWNS IT?
IS IT PATCHED?
IS IT NETWORK ACCESSIBLE?
IS IT CONFIGURED SECURELY?

Modern Linux systems commonly provide:

Terminal window
ss

Run:

Terminal window
ss -lntup

Common options:

-l
Listening
-n
Numeric
-t
TCP
-u
UDP
-p
Process information

Some process information may require elevated privileges.

Create:

Terminal window
collect_network_listeners() {
write_section "15 β€” Network Listeners"
if command -v ss >/dev/null 2>&1; then
ss -lntup \
>> "$REPORT_FILE" \
2>&1 || true
else
echo "ss command unavailable" \
>> "$REPORT_FILE"
fi
}

Listening services expand the system’s:

NETWORK ATTACK SURFACE

Ask:

WHY IS THIS PORT OPEN?
WHICH PROCESS OWNS IT?
IS IT EXPECTED?
WHICH INTERFACE IS IT BOUND TO?
IS A FIREWALL RESTRICTING IT?

Example:

127.0.0.1:5432

means:

LOCALHOST ONLY

Whereas:

0.0.0.0:5432

usually means:

ALL IPv4 INTERFACES

This difference matters.

Create:

Terminal window
collect_network_connections() {
write_section "16 β€” Active Network Connections"
if command -v ss >/dev/null 2>&1; then
ss -ntup \
>> "$REPORT_FILE" \
2>&1 || true
else
echo "ss unavailable" \
>> "$REPORT_FILE"
fi
}

Create:

Terminal window
collect_routes() {
write_section "17 β€” Routing Information"
if command -v ip >/dev/null 2>&1; then
ip route \
>> "$REPORT_FILE" \
2>&1 || true
fi
}

Create:

Terminal window
collect_interfaces() {
write_section "18 β€” Network Interfaces"
if command -v ip >/dev/null 2>&1; then
ip -brief address \
>> "$REPORT_FILE" \
2>&1 || true
fi
}

Create:

Terminal window
collect_disk_usage() {
write_section "19 β€” Disk Usage"
df -h \
>> "$REPORT_FILE"
}

A full disk can cause:

LOGGING FAILURE
APPLICATION FAILURE
DATABASE FAILURE
MONITORING BLIND SPOTS

Therefore disk health is a security-relevant operational control.

Create:

Terminal window
collect_inode_usage() {
write_section "20 β€” Inode Usage"
df -i \
>> "$REPORT_FILE"
}

Create:

Terminal window
collect_memory_usage() {
write_section "21 β€” Memory Usage"
if command -v free >/dev/null 2>&1; then
free -h \
>> "$REPORT_FILE"
else
cat /proc/meminfo \
>> "$REPORT_FILE"
fi
}

Create:

Terminal window
collect_load() {
write_section "22 β€” System Load"
uptime \
>> "$REPORT_FILE"
echo \
>> "$REPORT_FILE"
cat /proc/loadavg \
>> "$REPORT_FILE"
}

Create:

Terminal window
collect_mounts() {
write_section "23 β€” Mounted Filesystems"
findmnt \
>> "$REPORT_FILE" \
2>&1 || mount \
>> "$REPORT_FILE"
}

Linux authentication logs vary by distribution.

Common locations:

/var/log/auth.log

or:

/var/log/secure

Create:

Terminal window
get_auth_log() {
if [[ -f /var/log/auth.log ]]; then
echo "/var/log/auth.log"
elif [[ -f /var/log/secure ]]; then
echo "/var/log/secure"
else
echo ""
fi
}

Create:

Terminal window
collect_authentication_events() {
write_section "24 β€” Recent Authentication Events"
local auth_log
auth_log="$(get_auth_log)"
if [[ -n "$auth_log" ]]; then
tail -n 100 "$auth_log" \
>> "$REPORT_FILE" \
2>&1 || true
elif command -v journalctl >/dev/null 2>&1; then
journalctl \
--no-pager \
-n 100 \
_COMM=sshd \
>> "$REPORT_FILE" \
2>&1 || true
else
echo "Authentication logs unavailable" \
>> "$REPORT_FILE"
fi
}

Authentication logs may require:

ROOT
sudo
SECURITY LOG GROUP MEMBERSHIP

Your script should not assume every command succeeds.

Create:

Terminal window
collect_failed_authentication() {
write_section "25 β€” Failed Authentication Summary"
local auth_log
auth_log="$(get_auth_log)"
if [[ -n "$auth_log" ]]; then
grep -iE \
"failed password|authentication failure" \
"$auth_log" \
2>/dev/null \
| tail -n 100 \
>> "$REPORT_FILE" || true
elif command -v journalctl >/dev/null 2>&1; then
journalctl \
--no-pager \
_COMM=sshd \
2>/dev/null \
| grep -i "failed" \
| tail -n 100 \
>> "$REPORT_FILE" || true
else
echo "Failed authentication data unavailable" \
>> "$REPORT_FILE"
fi
}

For compatible SSH failure records, you can perform a simple summary.

Example:

Terminal window
grep "Failed password" /var/log/auth.log \
| awk '{print $(NF-3)}' \
| sort \
| uniq -c \
| sort -nr

Log formats differ, so validate the field position before relying on this in production.

This works only when the record format matches your assumption.

Do not write:

FIELD 11 = IP
FOREVER

without checking actual logs.

For the lab:

Terminal window
collect_failed_sources() {
write_section "26 β€” Failed Login Source Summary"
local auth_log
auth_log="$(get_auth_log)"
if [[ -z "$auth_log" ]]; then
echo "Authentication log unavailable" \
>> "$REPORT_FILE"
return
fi
grep -i "Failed password" "$auth_log" \
2>/dev/null \
| grep -oE \
'([0-9]{1,3}\.){3}[0-9]{1,3}' \
| sort \
| uniq -c \
| sort -nr \
| head -n 20 \
>> "$REPORT_FILE" || true
}

The regex finds IPv4-looking strings but does not fully validate them.

A production pipeline should validate extracted IPs before using them for decisions.

Create:

Terminal window
collect_successful_authentication() {
write_section "27 β€” Successful Authentication Summary"
local auth_log
auth_log="$(get_auth_log)"
if [[ -n "$auth_log" ]]; then
grep -i "Accepted" "$auth_log" \
2>/dev/null \
| tail -n 100 \
>> "$REPORT_FILE" || true
elif command -v journalctl >/dev/null 2>&1; then
journalctl \
--no-pager \
_COMM=sshd \
2>/dev/null \
| grep -i "Accepted" \
| tail -n 100 \
>> "$REPORT_FILE" || true
else
echo "Successful authentication data unavailable" \
>> "$REPORT_FILE"
fi
}

Create:

Terminal window
collect_login_history() {
write_section "28 β€” Login History"
last -n 20 \
>> "$REPORT_FILE" \
2>&1 || true
}

If available:

Terminal window
lastb

may display failed login history.

Add:

Terminal window
collect_failed_login_history() {
write_section "29 β€” Failed Login History"
if command -v lastb >/dev/null 2>&1; then
lastb -n 20 \
>> "$REPORT_FILE" \
2>&1 || true
else
echo "lastb unavailable" \
>> "$REPORT_FILE"
fi
}

Scheduled jobs should be understood.

Create:

Terminal window
collect_cron_configuration() {
write_section "30 β€” Scheduled Task Inventory"
{
echo "/etc/crontab:"
cat /etc/crontab \
2>/dev/null || true
echo
echo "/etc/cron.d:"
ls -la /etc/cron.d \
2>/dev/null || true
echo
echo "Current user crontab:"
crontab -l \
2>/dev/null || \
echo "No accessible user crontab"
} >> "$REPORT_FILE"
}

Ask:

WHO CREATED THE JOB?
WHAT COMMAND RUNS?
WHICH ACCOUNT RUNS IT?
WHAT FILES CAN IT MODIFY?
IS THE SCRIPT PATH WRITABLE?

Create:

Terminal window
collect_ssh_configuration() {
write_section "31 β€” SSH Configuration Review"
local ssh_config="/etc/ssh/sshd_config"
if [[ -f "$ssh_config" ]]; then
grep -Ei \
'^[[:space:]]*(PermitRootLogin|PasswordAuthentication|PubkeyAuthentication|MaxAuthTries|AllowUsers|AllowGroups)' \
"$ssh_config" \
>> "$REPORT_FILE" || true
else
echo "sshd_config unavailable" \
>> "$REPORT_FILE"
fi
}

The pattern begins with:

^

and looks for actual configuration lines rather than commented examples where possible.

But keep in mind:

INCLUDED CONFIGURATION FILES

may also affect effective SSH configuration.

Where supported:

Terminal window
sshd -T

can show effective configuration.

This may require appropriate permissions and a valid SSH configuration.

For the lab, treat this as optional.

Common Linux firewall tools include:

ufw
firewalld
nftables

Create:

Terminal window
collect_firewall_status() {
write_section "32 β€” Firewall Status"
if command -v ufw >/dev/null 2>&1; then
ufw status verbose \
>> "$REPORT_FILE" \
2>&1 || true
elif command -v firewall-cmd >/dev/null 2>&1; then
firewall-cmd --state \
>> "$REPORT_FILE" \
2>&1 || true
firewall-cmd --list-all \
>> "$REPORT_FILE" \
2>&1 || true
elif command -v nft >/dev/null 2>&1; then
nft list ruleset \
>> "$REPORT_FILE" \
2>&1 || true
else
echo "No supported firewall utility detected" \
>> "$REPORT_FILE"
fi
}

This lab only:

READS
REPORTS

firewall configuration.

Do not automatically alter production firewall rules during an assessment.

Package commands vary by distribution.

The assessment can record package manager information without automatically installing updates.

Create:

Terminal window
collect_package_manager() {
write_section "33 β€” Package Manager Information"
for command_name in \
apt \
dnf \
yum \
zypper \
pacman
do
if command -v "$command_name" >/dev/null 2>&1; then
echo "Detected package manager: $command_name" \
>> "$REPORT_FILE"
fi
done
}

Automatic patching can affect:

APPLICATION COMPATIBILITY
REBOOT REQUIREMENTS
BUSINESS OPERATIONS

Assessment automation should generally report:

STATE

before changing:

STATE

Accurate time matters for security logs.

Create:

Terminal window
collect_time_status() {
write_section "34 β€” Time Synchronization"
{
date
echo
if command -v timedatectl >/dev/null 2>&1; then
timedatectl status
fi
} >> "$REPORT_FILE" 2>&1 || true
}

If server clocks differ significantly:

LOG CORRELATION

becomes difficult.

Incident timelines depend on trustworthy timestamps.

Create:

Terminal window
collect_environment_summary() {
write_section "35 β€” Environment Summary"
{
echo "PATH:"
printf '%s\n' "${PATH:-unknown}"
echo
echo "Shell:"
printf '%s\n' "${SHELL:-unknown}"
echo
echo "Locale:"
locale 2>/dev/null || true
} >> "$REPORT_FILE"
}

A poorly controlled PATH can create unexpected command resolution behavior.

Your script should use trusted execution environments and controlled paths where appropriate.

Create:

Terminal window
hash_report() {
local hash_file="${REPORT_FILE}.sha256"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$REPORT_FILE" \
> "$hash_file"
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$REPORT_FILE" \
> "$hash_file"
fi
}

A cryptographic hash helps identify whether the report changed after generation.

Conceptually:

REPORT
↓
SHA-256
↓
INTEGRITY VALUE

Create:

Terminal window
generate_summary() {
write_section "36 β€” Assessment Summary"
{
echo "Assessment completed."
echo
echo "Review areas:"
echo "- Local accounts"
echo "- Administrative groups"
echo "- Privileged files"
echo "- Running services"
echo "- Network listeners"
echo "- Authentication failures"
echo "- Scheduled tasks"
echo "- Firewall status"
echo
echo "Important:"
echo "This report identifies configuration and activity requiring review."
echo "It does not automatically prove compromise or vulnerability."
} >> "$REPORT_FILE"
}

Your main() should now resemble:

Terminal window
main() {
log_message "INFO" \
"Linux security assessment started"
initialize_report
collect_system_information
collect_current_user
collect_local_users
collect_groups
collect_privileged_groups
collect_root_context
collect_home_directories
collect_world_writable_files
collect_world_writable_directories
collect_suid_files
collect_sgid_files
collect_processes
collect_running_services
collect_enabled_services
collect_network_listeners
collect_network_connections
collect_routes
collect_interfaces
collect_disk_usage
collect_inode_usage
collect_memory_usage
collect_load
collect_mounts
collect_authentication_events
collect_failed_authentication
collect_failed_sources
collect_successful_authentication
collect_login_history
collect_failed_login_history
collect_cron_configuration
collect_ssh_configuration
collect_firewall_status
collect_package_manager
collect_time_status
collect_environment_summary
generate_summary
hash_report
log_message "INFO" \
"Linux security assessment completed"
echo
echo "Report:"
echo "$REPORT_FILE"
}

Run:

Terminal window
./src/linux-security-audit.sh

You should now have:

reports/
|
+-- linux-security-report-<timestamp>.txt
|
+-- linux-security-report-<timestamp>.txt.sha256

and:

logs/
|
+-- linux-security-audit-<timestamp>.log

Some data may be inaccessible to a standard account.

Start with:

Terminal window
./src/linux-security-audit.sh

Then, only on your authorized lab system, compare with:

Terminal window
sudo ./src/linux-security-audit.sh

Do not automatically assume:

MORE PRIVILEGE
=
BETTER SCRIPT

Use:

LEAST PRIVILEGE

and elevate only when the data requirement justifies it.

Do not just confirm the script ran.

Ask:

ARE UNEXPECTED USERS PRESENT?
WHO HAS ADMINISTRATIVE ACCESS?
ARE THERE EXTRA UID 0 ACCOUNTS?
ARE WORLD-WRITABLE FILES PRESENT?
ARE PRIVILEGED FILES EXPECTED?
WHICH SERVICES ARE RUNNING?
ARE UNEXPECTED PORTS LISTENING?
ARE AUTHENTICATION FAILURES REPEATED?
IS THE FIREWALL ENABLED?
IS SYSTEM TIME SYNCHRONIZED?

For the training lab, use:

INFORMATIONAL
REVIEW
HIGH REVIEW

Avoid automatically calling configuration differences:

CRITICAL VULNERABILITIES

without validation.

Possible output:

Finding:
Additional UID 0 account detected
Evidence:
service-admin
Status:
HIGH REVIEW

Analyst action:

VALIDATE BUSINESS REQUIREMENT
CHECK ACCOUNT OWNER
CHECK CREATION HISTORY
CHECK RECENT AUTHENTICATION

Suppose:

0.0.0.0:8080

is listening.

Do not assume:

MALICIOUS

Ask:

WHICH PROCESS OWNS IT?
IS THE SERVICE APPROVED?
IS IT EXTERNALLY ACCESSIBLE?
IS THE FIREWALL RESTRICTING IT?
IS AUTHENTICATION ENABLED?

Suppose:

25 failed logins

come from one source.

Investigate:

SOURCE IP
TARGET USERS
TIME WINDOW
SUCCESSFUL LOGIN AFTER FAILURES
VPN / NAT CONTEXT
MFA EVENTS

Suppose the script identifies:

/opt/example/config.txt

Review:

OWNER
GROUP
APPLICATION REQUIREMENT
WHO CAN MODIFY IT?
DOES A PRIVILEGED SERVICE READ IT?

For a more mature version, create separate outputs:

system-information.txt
users.txt
privileged-access.txt
processes.txt
services.txt
network-listeners.txt
authentication-summary.txt
permissions-review.txt

It makes integration easier.

For example:

BASH
↓
JSON / TEXT
↓
PYTHON
↓
NORMALIZE
↓
CENTRAL REPORT

A future version could convert:

ss

output into:

protocol
address
port
process

Then export:

network-listeners.csv

Bash can generate JSON, but complex JSON construction is easier and safer with tools such as:

jq

or by passing raw results to Python.

Run:

Terminal window
command -v jq

If installed:

jq

can support structured JSON workflows.

Use Bash when:

COMMANDS ALREADY EXIST
LINUX DATA IS TEXT-ORIENTED
WORKFLOW IS SIMPLE
SHELL PIPELINES ARE CLEAR

Move toward Python when:

DATA STRUCTURES BECOME COMPLEX
MULTIPLE APIs ARE REQUIRED
JSON IS HEAVY
CORRELATION GROWS
TESTING NEEDS INCREASE

Create:

Terminal window
show_help() {
cat <<'EOF'
Linux Security Audit
Usage:
./linux-security-audit.sh
Purpose:
Collect defensive Linux security inventory
and generate a local assessment report.
Run only on systems you own or are
explicitly authorized to administer.
EOF
}

Before running the assessment:

Terminal window
if [[ "${1:-}" == "--help" ]]; then
show_help
exit 0
fi

Run:

Terminal window
./src/linux-security-audit.sh --help

Create:

Terminal window
check_dependencies() {
local commands=(
awk
grep
sort
find
ps
df
)
local missing=0
for command_name in "${commands[@]}"; do
if ! command -v "$command_name" >/dev/null 2>&1; then
log_message \
"ERROR" \
"Missing command: $command_name"
missing=1
fi
done
if [[ "$missing" -ne 0 ]]; then
return 1
fi
}

Without it:

SCRIPT STARTS
↓
COMMAND MISSING
↓
PARTIAL REPORT

With validation:

CHECK FIRST
↓
KNOWN ENVIRONMENT
↓
RUN

At the beginning:

Terminal window
if ! check_dependencies; then
log_message \
"ERROR" \
"Required dependencies missing"
exit 1
fi

If your future script needs temporary files, use:

Terminal window
mktemp

instead of predictable names such as:

/tmp/report.tmp

Example:

Terminal window
TEMP_DIR="$(mktemp -d)"

Then:

Terminal window
cleanup() {
rm -rf "$TEMP_DIR"
}
trap cleanup EXIT

It ensures cleanup even when the script exits unexpectedly.

CREATE TEMP DATA
↓
SCRIPT RUNS
↓
EXIT
↓
CLEANUP

Prefer:

Terminal window
"$variable"

instead of:

Terminal window
$variable

when handling paths or user-supplied values.

Do not build command strings and execute them using:

Terminal window
eval

unless you have a very specific, reviewed requirement.

Prefer explicit commands and arguments.

If available, validate the script using:

Terminal window
shellcheck src/linux-security-audit.sh

ShellCheck helps identify:

QUOTING ISSUES
UNSAFE EXPANSIONS
COMMON SHELL BUGS

Run:

Terminal window
bash -n src/linux-security-audit.sh

No output generally means syntax parsing succeeded.

Before using any assessment script against a production system:

LAB VM
↓
TEST
↓
REVIEW OUTPUT
↓
FIX
↓
CONTROLLED DEPLOYMENT

Temporarily add a fake dependency such as:

not-a-real-command

Verify:

DEPENDENCY CHECK FAILS
CLEANLY

Then remove it.

Run as a normal user.

Document which sections return:

PERMISSION DENIED
PARTIAL DATA
NO DATA

This teaches an important lesson:

NO RESULT

does not always mean:

NO ACTIVITY

On distributions using only the journal:

/var/log/auth.log

may not exist.

Verify the script falls back to:

journalctl

where possible.

Time the script:

Terminal window
time ./src/linux-security-audit.sh

Security assessment automation should not create excessive load.

Avoid commands such as unrestricted:

Terminal window
find /

by default on production systems.

Why?

Because it may:

SCAN HUGE FILESYSTEMS
CROSS NETWORK MOUNTS
CREATE LOAD
GENERATE LARGE OUTPUT

Use:

KNOWN DIRECTORIES
-xdev
MAX DEPTH WHERE APPROPRIATE
EXPLICIT TARGETS

Add to the report header:

Terminal window
echo "Collection user: $(whoami)"
echo "Collection UID: $(id -u)"

This helps document how the report was collected.

Near the top:

Terminal window
SCRIPT_VERSION="1.0.0"

Include it in the report:

Terminal window
echo "Script version: $SCRIPT_VERSION"

If a report was created three months ago, you need to know:

WHICH SCRIPT VERSION
GENERATED IT?

Add:

Terminal window
ASSESSMENT_ID="LINUX-${RUN_TIMESTAMP}"

Include it in:

REPORT
LOG

A professional assessment should capture:

ASSESSMENT ID
HOSTNAME
DATE
COLLECTION USER
SCRIPT VERSION
REPORT HASH

Create:

README.md

Include:

PROJECT PURPOSE
LAB REQUIREMENTS
AUTHORIZED USE
SUPPORTED SYSTEMS
HOW TO RUN
OUTPUT FILES
SECURITY CHECKS
LIMITATIONS
TROUBLESHOOTING

Include:

This project performs read-only Linux security
inventory and defensive assessment activities.
Use it only against Linux systems you own or
are explicitly authorized to administer.

Your script does not provide:

FULL CIS BENCHMARK ASSESSMENT
MALWARE DETECTION
ROOTKIT DETECTION
FULL PACKAGE VULNERABILITY ANALYSIS
APPLICATION CONFIGURATION REVIEW
CONTAINER ANALYSIS
CLOUD CONTROL-PLANE REVIEW

These require dedicated workflows.

Your final repository can look like:

linux-security-automation/
|
+-- src/
| +-- linux-security-audit.sh
|
+-- reports/
|
+-- logs/
|
+-- tests/
|
+-- README.md
|
+-- architecture.md

Submit:

linux-security-audit.sh
Sample Report
Report SHA-256
Execution Log
README
Architecture Diagram
Analyst Findings Summary

Create:

Assessment ID:
Host:
Date:
Finding 01:
Title:
Evidence:
Status:
Recommendation:
Finding 02:
Title:
Evidence:
Status:
Recommendation:

The lab has now created:

LINUX SERVER
↓
SYSTEM INVENTORY
↓
IDENTITY REVIEW
↓
PERMISSION REVIEW
↓
PROCESS REVIEW
↓
SERVICE REVIEW
↓
NETWORK REVIEW
↓
AUTHENTICATION REVIEW
↓
SECURITY REPORT

Avoid:

RUNNING EVERYTHING AS ROOT
UNQUOTED VARIABLES
NO ERROR HANDLING
NO DEPENDENCY CHECKS
NO LOGGING
SCANNING ENTIRE FILESYSTEMS NEEDLESSLY
SUPPRESSING EVERY ERROR
ASSUMING LOG FORMATS NEVER CHANGE
AUTOMATICALLY CHANGING CONFIGURATION
NO REPORT TIMESTAMP
NO SCRIPT VERSION
NO DOCUMENTATION

You have used it selectively in this training script.

Do not apply:

Terminal window
2>/dev/null

to everything.

Otherwise you may hide:

PERMISSION ERRORS
COMMAND FAILURES
FILESYSTEM PROBLEMS

Sometimes errors are security-relevant information.

This lab follows:

COLLECT
↓
ASSESS
↓
REPORT

not:

COLLECT
↓
AUTOMATICALLY MODIFY SYSTEM

This is deliberate.

A professional workflow is:

ASSESS
↓
VALIDATE FINDING
↓
UNDERSTAND BUSINESS IMPACT
↓
CREATE CHANGE
↓
TEST
↓
APPROVE
↓
REMEDIATE
↓
VERIFY

Confirm:

  • Bash script created
  • set -euo pipefail used
  • Variables quoted
  • Report directory created
  • Logging implemented
  • Host information collected
  • User information collected
  • Group information collected
  • UID 0 accounts reviewed
  • Administrative groups reviewed
  • Home directories reviewed
  • World-writable files reviewed
  • World-writable directories reviewed
  • SUID files inventoried
  • SGID files inventoried
  • Processes inventoried
  • Running services inventoried
  • Enabled services inventoried
  • Listening ports inventoried
  • Active connections inventoried
  • Routes reviewed
  • Interfaces reviewed
  • Disk usage collected
  • Inodes collected
  • Memory status collected
  • Load collected
  • Authentication logs reviewed
  • Failed authentication summarized
  • Successful authentication reviewed
  • Login history collected
  • Cron configuration reviewed
  • SSH configuration reviewed
  • Firewall status reviewed
  • Time synchronization reviewed
  • Report generated
  • Report hashed
  • Script syntax checked
  • Script tested in an authorized lab
  • Findings manually validated

You started with:

LINUX COMMANDS

such as:

whoami
id
ps
ss
find
grep
awk
systemctl
journalctl

You converted them into:

ONE REPEATABLE
SECURITY ASSESSMENT
WORKFLOW

You now have a Bash-based Linux security assessment tool capable of collecting:

HOST INFORMATION
IDENTITY DATA
PRIVILEGED ACCESS CONTEXT
PERMISSION DATA
PROCESS DATA
SERVICE DATA
NETWORK DATA
AUTHENTICATION DATA
SYSTEM HEALTH DATA

and transforming it into:

ANALYST-READY
SECURITY REPORT

The most important lesson is:

INVENTORY
COMES BEFORE
SECURITY DECISIONS

You cannot decide whether something is suspicious until you first understand:

WHAT EXISTS
WHAT IS RUNNING
WHO HAS ACCESS
WHAT IS EXPOSED
WHAT ACTIVITY OCCURRED

Whenever you assess a Linux system, think:

WHO AM I?
↓
WHAT SYSTEM IS THIS?
↓
WHO CAN LOG IN?
↓
WHO HAS PRIVILEGE?
↓
WHAT IS RUNNING?
↓
WHAT IS LISTENING?
↓
WHAT FILES ARE HIGH RISK?
↓
WHAT AUTHENTICATION ACTIVITY OCCURRED?
↓
WHAT REQUIRES HUMAN REVIEW?

The value of Bash is not:

RUNNING ONE COMMAND

The value is:

TURNING MANY SMALL
LINUX SECURITY CHECKS
INTO ONE SAFE,
REPEATABLE WORKFLOW

➑️ Lab 04 β€” Windows Security Automation with PowerShell

In the next lab, you will move from Linux security automation into Windows security assessment.

You will build:

WINDOWS HOST
↓
POWERSHELL
↓
SYSTEM INVENTORY
↓
LOCAL USERS
↓
LOCAL ADMINISTRATORS
↓
PROCESSES
↓
SERVICES
↓
NETWORK CONNECTIONS
↓
WINDOWS EVENT LOGS
↓
DEFENDER STATUS
↓
FIREWALL STATUS
↓
SECURITY REPORT

You will use PowerShell objects and defensive Windows administration capabilities to create a reusable Windows security inventory and assessment workflow.