# Count failed attempts by IP over the last 24h
grep "AH0179" /var/log/apache2/error.log | grep -oP '\[client \K[0-9.]+' | sort | uniq -c | sort -rn | head -20 # See full lines for a specific IP
grep "13.37.248.113" /var/log/apache2/error.log | tail -30
# Count failed attempts by IP over the last 24h
grep "AH0179" /var/log/apache2/error.log | grep -oP '\[client \K[0-9.]+' | sort | uniq -c | sort -rn | head -20 # See full lines for a specific IP
grep "13.37.248.113" /var/log/apache2/error.log | tail -30
# Count failed attempts by IP over the last 24h
grep "AH0179" /var/log/apache2/error.log | grep -oP '\[client \K[0-9.]+' | sort | uniq -c | sort -rn | head -20 # See full lines for a specific IP
grep "13.37.248.113" /var/log/apache2/error.log | tail -30
[Tue Feb 18 07:23:41.412893 2026] [auth_digest:error] [pid 1234] [client 13.37.248.113:58432] AH01790: user admin: password mismatch: /protected/
[Tue Feb 18 07:23:41.718204 2026] [auth_digest:error] [pid 1234] [client 13.37.248.113:58433] AH01794: user root in realm "Private Area" not found: /protected/
[Tue Feb 18 07:23:42.091337 2026] [auth_digest:error] [pid 1234] [client 13.37.248.113:58434] AH01790: user administrator: password mismatch: /protected/
[Tue Feb 18 07:23:41.412893 2026] [auth_digest:error] [pid 1234] [client 13.37.248.113:58432] AH01790: user admin: password mismatch: /protected/
[Tue Feb 18 07:23:41.718204 2026] [auth_digest:error] [pid 1234] [client 13.37.248.113:58433] AH01794: user root in realm "Private Area" not found: /protected/
[Tue Feb 18 07:23:42.091337 2026] [auth_digest:error] [pid 1234] [client 13.37.248.113:58434] AH01790: user administrator: password mismatch: /protected/
[Tue Feb 18 07:23:41.412893 2026] [auth_digest:error] [pid 1234] [client 13.37.248.113:58432] AH01790: user admin: password mismatch: /protected/
[Tue Feb 18 07:23:41.718204 2026] [auth_digest:error] [pid 1234] [client 13.37.248.113:58433] AH01794: user root in realm "Private Area" not found: /protected/
[Tue Feb 18 07:23:42.091337 2026] [auth_digest:error] [pid 1234] [client 13.37.248.113:58434] AH01790: user administrator: password mismatch: /protected/
# Quick whois
whois 13.37.248.113 # Geolocation without installing anything
-weight: 500;">curl -s https://ipinfo.io/13.37.248.113/json
# Quick whois
whois 13.37.248.113 # Geolocation without installing anything
-weight: 500;">curl -s https://ipinfo.io/13.37.248.113/json
# Quick whois
whois 13.37.248.113 # Geolocation without installing anything
-weight: 500;">curl -s https://ipinfo.io/13.37.248.113/json
# Successful SSH connections (look for unexpected logins)
grep "Accepted" /var/log/auth.log | tail -50 # Failed SSH attempts from the same IP
grep "13.37.248.113" /var/log/auth.log # SFTP activity (ProFTPD log)
grep "13.37.248.113" /var/log/proftpd/proftpd.log 2>/dev/null # Check timestamps on sensitive files
stat /etc/passwd /etc/shadow /etc/sudoers
ls -la /root/.ssh/
ls -la /home/ # Look for files modified recently in /etc (last 24h)
find /etc -newer /tmp/ref_file -ls 2>/dev/null
# Create the reference file first: touch -d "24 hours ago" /tmp/ref_file
# Successful SSH connections (look for unexpected logins)
grep "Accepted" /var/log/auth.log | tail -50 # Failed SSH attempts from the same IP
grep "13.37.248.113" /var/log/auth.log # SFTP activity (ProFTPD log)
grep "13.37.248.113" /var/log/proftpd/proftpd.log 2>/dev/null # Check timestamps on sensitive files
stat /etc/passwd /etc/shadow /etc/sudoers
ls -la /root/.ssh/
ls -la /home/ # Look for files modified recently in /etc (last 24h)
find /etc -newer /tmp/ref_file -ls 2>/dev/null
# Create the reference file first: touch -d "24 hours ago" /tmp/ref_file
# Successful SSH connections (look for unexpected logins)
grep "Accepted" /var/log/auth.log | tail -50 # Failed SSH attempts from the same IP
grep "13.37.248.113" /var/log/auth.log # SFTP activity (ProFTPD log)
grep "13.37.248.113" /var/log/proftpd/proftpd.log 2>/dev/null # Check timestamps on sensitive files
stat /etc/passwd /etc/shadow /etc/sudoers
ls -la /root/.ssh/
ls -la /home/ # Look for files modified recently in /etc (last 24h)
find /etc -newer /tmp/ref_file -ls 2>/dev/null
# Create the reference file first: touch -d "24 hours ago" /tmp/ref_file
-weight: 500;">apt -weight: 500;">install fail2ban
-weight: 500;">systemctl -weight: 500;">enable fail2ban
-weight: 500;">apt -weight: 500;">install fail2ban
-weight: 500;">systemctl -weight: 500;">enable fail2ban
-weight: 500;">apt -weight: 500;">install fail2ban
-weight: 500;">systemctl -weight: 500;">enable fail2ban
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 86400 ; 24h
findtime = 600 ; detection window: 10 minutes
maxretry = 5
banaction = iptables-multiport [sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
backend = %(sshd_backend)s
maxretry = 5
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 86400 ; 24h
findtime = 600 ; detection window: 10 minutes
maxretry = 5
banaction = iptables-multiport [sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
backend = %(sshd_backend)s
maxretry = 5
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 86400 ; 24h
findtime = 600 ; detection window: 10 minutes
maxretry = 5
banaction = iptables-multiport [sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
backend = %(sshd_backend)s
maxretry = 5
# /etc/fail2ban/filter.d/apache-auth.local
[Definition]
failregex = \[client <HOST>:.*\] AH01790: user .+: password mismatch \[client <HOST>:.*\] AH01794: user .+ in realm .+ not found \[client <HOST>:.*\] AH01788: .+nonce from .+ received on .+ - not found
# /etc/fail2ban/filter.d/apache-auth.local
[Definition]
failregex = \[client <HOST>:.*\] AH01790: user .+: password mismatch \[client <HOST>:.*\] AH01794: user .+ in realm .+ not found \[client <HOST>:.*\] AH01788: .+nonce from .+ received on .+ - not found
# /etc/fail2ban/filter.d/apache-auth.local
[Definition]
failregex = \[client <HOST>:.*\] AH01790: user .+: password mismatch \[client <HOST>:.*\] AH01794: user .+ in realm .+ not found \[client <HOST>:.*\] AH01788: .+nonce from .+ received on .+ - not found
[apache-auth]
enabled = true
filter = apache-auth
port = http,https
logpath = /var/log/apache2/error.log
maxretry = 8
bantime = 86400
findtime = 300
[apache-auth]
enabled = true
filter = apache-auth
port = http,https
logpath = /var/log/apache2/error.log
maxretry = 8
bantime = 86400
findtime = 300
[apache-auth]
enabled = true
filter = apache-auth
port = http,https
logpath = /var/log/apache2/error.log
maxretry = 8
bantime = 86400
findtime = 300
# Test the filter against a real log excerpt
fail2ban-regex /var/log/apache2/error.log /etc/fail2ban/filter.d/apache-auth.local --print-all-matched
# Test the filter against a real log excerpt
fail2ban-regex /var/log/apache2/error.log /etc/fail2ban/filter.d/apache-auth.local --print-all-matched
# Test the filter against a real log excerpt
fail2ban-regex /var/log/apache2/error.log /etc/fail2ban/filter.d/apache-auth.local --print-all-matched
[proftpd]
enabled = true
port = ftp,ftp-data,ftps,ftps-data
logpath = /var/log/proftpd/proftpd.log
maxretry = 6
bantime = 86400
[proftpd]
enabled = true
port = ftp,ftp-data,ftps,ftps-data
logpath = /var/log/proftpd/proftpd.log
maxretry = 6
bantime = 86400
[proftpd]
enabled = true
port = ftp,ftp-data,ftps,ftps-data
logpath = /var/log/proftpd/proftpd.log
maxretry = 6
bantime = 86400
# Overview
fail2ban-client -weight: 500;">status # Details of a specific jail
fail2ban-client -weight: 500;">status apache-auth
fail2ban-client -weight: 500;">status sshd # Manually unban an IP (if you ban yourself)
fail2ban-client set sshd unbanip 1.2.3.4 # fail2ban logs
tail -f /var/log/fail2ban.log
# Overview
fail2ban-client -weight: 500;">status # Details of a specific jail
fail2ban-client -weight: 500;">status apache-auth
fail2ban-client -weight: 500;">status sshd # Manually unban an IP (if you ban yourself)
fail2ban-client set sshd unbanip 1.2.3.4 # fail2ban logs
tail -f /var/log/fail2ban.log
# Overview
fail2ban-client -weight: 500;">status # Details of a specific jail
fail2ban-client -weight: 500;">status apache-auth
fail2ban-client -weight: 500;">status sshd # Manually unban an IP (if you ban yourself)
fail2ban-client set sshd unbanip 1.2.3.4 # fail2ban logs
tail -f /var/log/fail2ban.log
# Create the group
groupadd sftponly # Add existing users
usermod -aG sftponly alice
usermod -aG sftponly bob
# etc.
# Create the group
groupadd sftponly # Add existing users
usermod -aG sftponly alice
usermod -aG sftponly bob
# etc.
# Create the group
groupadd sftponly # Add existing users
usermod -aG sftponly alice
usermod -aG sftponly bob
# etc.
Match Group sftponly ForceCommand internal-sftp -l INFO -f AUTH ChrootDirectory %h AllowTcpForwarding no X11Forwarding no PermitTunnel no AllowAgentForwarding no
Match Group sftponly ForceCommand internal-sftp -l INFO -f AUTH ChrootDirectory %h AllowTcpForwarding no X11Forwarding no PermitTunnel no AllowAgentForwarding no
Match Group sftponly ForceCommand internal-sftp -l INFO -f AUTH ChrootDirectory %h AllowTcpForwarding no X11Forwarding no PermitTunnel no AllowAgentForwarding no
# Correct structure for an SFTP chroot
# The home must be root:root 755
ls -la /home/alice/
# drwxr-xr-x 3 root root 4096 ... # Subdirectories belong to the user
ls -la /home/alice/downloads/
# drwxr-xr-x 2 alice alice 4096 ... # Fix permissions if needed
chown root:root /home/alice
chmod 755 /home/alice # The user must still be able to write somewhere
mkdir -p /home/alice/uploads
chown alice:alice /home/alice/uploads
chmod 755 /home/alice/uploads
# Correct structure for an SFTP chroot
# The home must be root:root 755
ls -la /home/alice/
# drwxr-xr-x 3 root root 4096 ... # Subdirectories belong to the user
ls -la /home/alice/downloads/
# drwxr-xr-x 2 alice alice 4096 ... # Fix permissions if needed
chown root:root /home/alice
chmod 755 /home/alice # The user must still be able to write somewhere
mkdir -p /home/alice/uploads
chown alice:alice /home/alice/uploads
chmod 755 /home/alice/uploads
# Correct structure for an SFTP chroot
# The home must be root:root 755
ls -la /home/alice/
# drwxr-xr-x 3 root root 4096 ... # Subdirectories belong to the user
ls -la /home/alice/downloads/
# drwxr-xr-x 2 alice alice 4096 ... # Fix permissions if needed
chown root:root /home/alice
chmod 755 /home/alice # The user must still be able to write somewhere
mkdir -p /home/alice/uploads
chown alice:alice /home/alice/uploads
chmod 755 /home/alice/uploads
# In /etc/ssh/sshd_config
# Replace the existing Subsystem line with:
Subsystem sftp internal-sftp -l INFO -f AUTH
# In /etc/ssh/sshd_config
# Replace the existing Subsystem line with:
Subsystem sftp internal-sftp -l INFO -f AUTH
# In /etc/ssh/sshd_config
# Replace the existing Subsystem line with:
Subsystem sftp internal-sftp -l INFO -f AUTH
# In /etc/ssh/sshd_config
PermitRootLogin prohibit-password # Verify no unknown key exists
cat /root/.ssh/authorized_keys
# If the file contains keys you don't recognize: security incident
# In /etc/ssh/sshd_config
PermitRootLogin prohibit-password # Verify no unknown key exists
cat /root/.ssh/authorized_keys
# If the file contains keys you don't recognize: security incident
# In /etc/ssh/sshd_config
PermitRootLogin prohibit-password # Verify no unknown key exists
cat /root/.ssh/authorized_keys
# If the file contains keys you don't recognize: security incident
# Reload SSH after modification
sshd -t && -weight: 500;">systemctl reload sshd
# Reload SSH after modification
sshd -t && -weight: 500;">systemctl reload sshd
# Reload SSH after modification
sshd -t && -weight: 500;">systemctl reload sshd
# htdigest files — readable by root and www-data only
chown root:www-data /etc/apache2/.htdigest
chmod 640 /etc/apache2/.htdigest # Configuration files with passwords
chown root:www-data /var/www/html/config.php
chmod 640 /var/www/html/config.php # Normal user home directories
chmod 700 /home/alice
chown alice:alice /home/alice # Exception: home dirs of chrooted SFTP users → root:root 755 (see section 3)
# htdigest files — readable by root and www-data only
chown root:www-data /etc/apache2/.htdigest
chmod 640 /etc/apache2/.htdigest # Configuration files with passwords
chown root:www-data /var/www/html/config.php
chmod 640 /var/www/html/config.php # Normal user home directories
chmod 700 /home/alice
chown alice:alice /home/alice # Exception: home dirs of chrooted SFTP users → root:root 755 (see section 3)
# htdigest files — readable by root and www-data only
chown root:www-data /etc/apache2/.htdigest
chmod 640 /etc/apache2/.htdigest # Configuration files with passwords
chown root:www-data /var/www/html/config.php
chmod 640 /var/www/html/config.php # Normal user home directories
chmod 700 /home/alice
chown alice:alice /home/alice # Exception: home dirs of chrooted SFTP users → root:root 755 (see section 3)
# In .htaccess or the vhost
RewriteEngine On # Block direct access to internal data
RewriteRule ^/data/internal - [F,L]
RewriteRule ^/uploads/private - [F,L] # Block config files accidentally exposed
RewriteRule \.(env|log|sql|bak)$ - [F,L]
# In .htaccess or the vhost
RewriteEngine On # Block direct access to internal data
RewriteRule ^/data/internal - [F,L]
RewriteRule ^/uploads/private - [F,L] # Block config files accidentally exposed
RewriteRule \.(env|log|sql|bak)$ - [F,L]
# In .htaccess or the vhost
RewriteEngine On # Block direct access to internal data
RewriteRule ^/data/internal - [F,L]
RewriteRule ^/uploads/private - [F,L] # Block config files accidentally exposed
RewriteRule \.(env|log|sql|bak)$ - [F,L]
# List who has -weight: 600;">sudo
getent group -weight: 600;">sudo # Remove -weight: 600;">sudo from a non-root account
deluser adminuser -weight: 600;">sudo # Verify
-weight: 600;">sudo -l -U adminuser
# "User adminuser is not allowed to run -weight: 600;">sudo"
# List who has -weight: 600;">sudo
getent group -weight: 600;">sudo # Remove -weight: 600;">sudo from a non-root account
deluser adminuser -weight: 600;">sudo # Verify
-weight: 600;">sudo -l -U adminuser
# "User adminuser is not allowed to run -weight: 600;">sudo"
# List who has -weight: 600;">sudo
getent group -weight: 600;">sudo # Remove -weight: 600;">sudo from a non-root account
deluser adminuser -weight: 600;">sudo # Verify
-weight: 600;">sudo -l -U adminuser
# "User adminuser is not allowed to run -weight: 600;">sudo"
# /etc/sudoers.d/www-data
# Always use absolute paths
www-data ALL=(ALL) NOPASSWD: /usr/sbin/apachectl graceful
www-data ALL=(ALL) NOPASSWD: /usr/local/bin/maintenance-script.sh
# /etc/sudoers.d/www-data
# Always use absolute paths
www-data ALL=(ALL) NOPASSWD: /usr/sbin/apachectl graceful
www-data ALL=(ALL) NOPASSWD: /usr/local/bin/maintenance-script.sh
# /etc/sudoers.d/www-data
# Always use absolute paths
www-data ALL=(ALL) NOPASSWD: /usr/sbin/apachectl graceful
www-data ALL=(ALL) NOPASSWD: /usr/local/bin/maintenance-script.sh
# Correct permissions on this file
chmod 440 /etc/sudoers.d/www-data # Check syntax before saving
visudo -c -f /etc/sudoers.d/www-data
# Correct permissions on this file
chmod 440 /etc/sudoers.d/www-data # Check syntax before saving
visudo -c -f /etc/sudoers.d/www-data
# Correct permissions on this file
chmod 440 /etc/sudoers.d/www-data # Check syntax before saving
visudo -c -f /etc/sudoers.d/www-data
<?php
// Validate the parameter before using it
$user = $_POST['username'] ?? '';
if (!preg_match('/^[a-z][a-z0-9_]{2,31}$/', $user)) { die('Invalid username format');
} $escaped = escapeshellarg($user);
$output = shell_exec("-weight: 600;">sudo /usr/local/bin/create-user-dir.sh $escaped");
?>
<?php
// Validate the parameter before using it
$user = $_POST['username'] ?? '';
if (!preg_match('/^[a-z][a-z0-9_]{2,31}$/', $user)) { die('Invalid username format');
} $escaped = escapeshellarg($user);
$output = shell_exec("-weight: 600;">sudo /usr/local/bin/create-user-dir.sh $escaped");
?>
<?php
// Validate the parameter before using it
$user = $_POST['username'] ?? '';
if (!preg_match('/^[a-z][a-z0-9_]{2,31}$/', $user)) { die('Invalid username format');
} $escaped = escapeshellarg($user);
$output = shell_exec("-weight: 600;">sudo /usr/local/bin/create-user-dir.sh $escaped");
?>
-weight: 500;">apt -weight: 500;">install auditd audispd-plugins
-weight: 500;">systemctl -weight: 500;">enable auditd
-weight: 500;">apt -weight: 500;">install auditd audispd-plugins
-weight: 500;">systemctl -weight: 500;">enable auditd
-weight: 500;">apt -weight: 500;">install auditd audispd-plugins
-weight: 500;">systemctl -weight: 500;">enable auditd
# /etc/audit/rules.d/security.rules # sudoers modifications
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers # SSH configuration
-w /etc/ssh/sshd_config -p wa -k sshd_config # Root SSH keys
-w /root/.ssh/authorized_keys -p wa -k root_keys # System accounts
-w /etc/passwd -p wa -k accounts
-w /etc/shadow -p wa -k accounts
-w /etc/group -p wa -k accounts # su and -weight: 600;">sudo execution
-w /usr/bin/su -p x -k su_exec
-w /usr/bin/-weight: 600;">sudo -p x -k sudo_exec # Crontab
-w /etc/crontab -p wa -k crontab
-w /etc/cron.d/ -p wa -k crontab
-w /var/spool/cron/ -p wa -k crontab # fail2ban configuration
-w /etc/fail2ban/ -p wa -k fail2ban # Sensitive application configuration files
-w /var/www/html/config.php -p rwa -k app_config
-w /etc/apache2/ -p wa -k apache_config
# /etc/audit/rules.d/security.rules # sudoers modifications
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers # SSH configuration
-w /etc/ssh/sshd_config -p wa -k sshd_config # Root SSH keys
-w /root/.ssh/authorized_keys -p wa -k root_keys # System accounts
-w /etc/passwd -p wa -k accounts
-w /etc/shadow -p wa -k accounts
-w /etc/group -p wa -k accounts # su and -weight: 600;">sudo execution
-w /usr/bin/su -p x -k su_exec
-w /usr/bin/-weight: 600;">sudo -p x -k sudo_exec # Crontab
-w /etc/crontab -p wa -k crontab
-w /etc/cron.d/ -p wa -k crontab
-w /var/spool/cron/ -p wa -k crontab # fail2ban configuration
-w /etc/fail2ban/ -p wa -k fail2ban # Sensitive application configuration files
-w /var/www/html/config.php -p rwa -k app_config
-w /etc/apache2/ -p wa -k apache_config
# /etc/audit/rules.d/security.rules # sudoers modifications
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers # SSH configuration
-w /etc/ssh/sshd_config -p wa -k sshd_config # Root SSH keys
-w /root/.ssh/authorized_keys -p wa -k root_keys # System accounts
-w /etc/passwd -p wa -k accounts
-w /etc/shadow -p wa -k accounts
-w /etc/group -p wa -k accounts # su and -weight: 600;">sudo execution
-w /usr/bin/su -p x -k su_exec
-w /usr/bin/-weight: 600;">sudo -p x -k sudo_exec # Crontab
-w /etc/crontab -p wa -k crontab
-w /etc/cron.d/ -p wa -k crontab
-w /var/spool/cron/ -p wa -k crontab # fail2ban configuration
-w /etc/fail2ban/ -p wa -k fail2ban # Sensitive application configuration files
-w /var/www/html/config.php -p rwa -k app_config
-w /etc/apache2/ -p wa -k apache_config
# Load rules without restarting
augenrules --load # Verify active rules
auditctl -l # Consult events (last 24h)
ausearch -ts yesterday -te now | aureport -f -i | head -50
# Load rules without restarting
augenrules --load # Verify active rules
auditctl -l # Consult events (last 24h)
ausearch -ts yesterday -te now | aureport -f -i | head -50
# Load rules without restarting
augenrules --load # Verify active rules
auditctl -l # Consult events (last 24h)
ausearch -ts yesterday -te now | aureport -f -i | head -50
-weight: 500;">apt -weight: 500;">install acct
accton on # Enable recording # See recent commands by user
lastcomm --user alice | head -30 # Recent root commands
lastcomm --user root | head -50 # Filter by specific command
lastcomm --command bash
-weight: 500;">apt -weight: 500;">install acct
accton on # Enable recording # See recent commands by user
lastcomm --user alice | head -30 # Recent root commands
lastcomm --user root | head -50 # Filter by specific command
lastcomm --command bash
-weight: 500;">apt -weight: 500;">install acct
accton on # Enable recording # See recent commands by user
lastcomm --user alice | head -30 # Recent root commands
lastcomm --user root | head -50 # Filter by specific command
lastcomm --command bash
-weight: 500;">apt -weight: 500;">install rkhunter # Initialize the baseline (do this immediately after a clean installation)
rkhunter ---weight: 500;">update
rkhunter --propupd # Full scan
rkhunter --check --skip-keypress # After a system -weight: 500;">update, regenerate the baseline
-weight: 500;">apt -weight: 500;">upgrade && rkhunter --propupd
-weight: 500;">apt -weight: 500;">install rkhunter # Initialize the baseline (do this immediately after a clean installation)
rkhunter ---weight: 500;">update
rkhunter --propupd # Full scan
rkhunter --check --skip-keypress # After a system -weight: 500;">update, regenerate the baseline
-weight: 500;">apt -weight: 500;">upgrade && rkhunter --propupd
-weight: 500;">apt -weight: 500;">install rkhunter # Initialize the baseline (do this immediately after a clean installation)
rkhunter ---weight: 500;">update
rkhunter --propupd # Full scan
rkhunter --check --skip-keypress # After a system -weight: 500;">update, regenerate the baseline
-weight: 500;">apt -weight: 500;">upgrade && rkhunter --propupd
#!/bin/bash
# /root/scripts/security-audit.sh
# Runs every night via cron: 3 0 * * * /root/scripts/security-audit.sh set -euo pipefail REPORT_DIR="/var/log/security-audit"
TODAY=$(date +%Y-%m-%d)
REPORT="$REPORT_DIR/$TODAY.txt" mkdir -p "$REPORT_DIR"
chmod 700 "$REPORT_DIR" { echo "=== SECURITY REPORT - $TODAY ===" echo "Generated on: $(date)" echo "" echo "=== AUDITD EVENTS (24h) ===" ausearch -ts yesterday -te now 2>/dev/null | aureport -f -i 2>/dev/null | tail -100 || echo "auditd: no events" echo "" echo "=== FAIL2BAN - ACTIVE BANS ===" for jail in sshd apache-auth proftpd; do echo "--- $jail ---" fail2ban-client -weight: 500;">status "$jail" 2>/dev/null || echo "jail $jail not active" done echo "" echo "=== SSH - FAILED ATTEMPTS (24h) ===" grep "Failed password" /var/log/auth.log | grep "$(date +%b)" | tail -50 || echo "none" echo "" echo "=== SSH - SUCCESSFUL CONNECTIONS (24h) ===" grep "Accepted" /var/log/auth.log | grep "$(date +%b)" || echo "none" echo "" echo "=== LISTENING PORTS (check) ===" ss -tlnp echo "" echo "=== ESTABLISHED OUTBOUND CONNECTIONS ===" ss -tnp state established | grep -v "127.0.0.1" | grep -v "::1" | head -30 echo "" # rkhunter scan only on Sundays (day 7) if [ "$(date +%u)" -eq 7 ]; then echo "=== RKHUNTER SCAN (weekly) ===" rkhunter --check --skip-keypress --quiet 2>&1 | tail -30 || echo "rkhunter: error" echo "" fi echo "=== END OF REPORT ==="
} > "$REPORT" 2>&1 chmod 600 "$REPORT" # Run AI analysis separately
/root/scripts/security-analyze.sh "$REPORT"
#!/bin/bash
# /root/scripts/security-audit.sh
# Runs every night via cron: 3 0 * * * /root/scripts/security-audit.sh set -euo pipefail REPORT_DIR="/var/log/security-audit"
TODAY=$(date +%Y-%m-%d)
REPORT="$REPORT_DIR/$TODAY.txt" mkdir -p "$REPORT_DIR"
chmod 700 "$REPORT_DIR" { echo "=== SECURITY REPORT - $TODAY ===" echo "Generated on: $(date)" echo "" echo "=== AUDITD EVENTS (24h) ===" ausearch -ts yesterday -te now 2>/dev/null | aureport -f -i 2>/dev/null | tail -100 || echo "auditd: no events" echo "" echo "=== FAIL2BAN - ACTIVE BANS ===" for jail in sshd apache-auth proftpd; do echo "--- $jail ---" fail2ban-client -weight: 500;">status "$jail" 2>/dev/null || echo "jail $jail not active" done echo "" echo "=== SSH - FAILED ATTEMPTS (24h) ===" grep "Failed password" /var/log/auth.log | grep "$(date +%b)" | tail -50 || echo "none" echo "" echo "=== SSH - SUCCESSFUL CONNECTIONS (24h) ===" grep "Accepted" /var/log/auth.log | grep "$(date +%b)" || echo "none" echo "" echo "=== LISTENING PORTS (check) ===" ss -tlnp echo "" echo "=== ESTABLISHED OUTBOUND CONNECTIONS ===" ss -tnp state established | grep -v "127.0.0.1" | grep -v "::1" | head -30 echo "" # rkhunter scan only on Sundays (day 7) if [ "$(date +%u)" -eq 7 ]; then echo "=== RKHUNTER SCAN (weekly) ===" rkhunter --check --skip-keypress --quiet 2>&1 | tail -30 || echo "rkhunter: error" echo "" fi echo "=== END OF REPORT ==="
} > "$REPORT" 2>&1 chmod 600 "$REPORT" # Run AI analysis separately
/root/scripts/security-analyze.sh "$REPORT"
#!/bin/bash
# /root/scripts/security-audit.sh
# Runs every night via cron: 3 0 * * * /root/scripts/security-audit.sh set -euo pipefail REPORT_DIR="/var/log/security-audit"
TODAY=$(date +%Y-%m-%d)
REPORT="$REPORT_DIR/$TODAY.txt" mkdir -p "$REPORT_DIR"
chmod 700 "$REPORT_DIR" { echo "=== SECURITY REPORT - $TODAY ===" echo "Generated on: $(date)" echo "" echo "=== AUDITD EVENTS (24h) ===" ausearch -ts yesterday -te now 2>/dev/null | aureport -f -i 2>/dev/null | tail -100 || echo "auditd: no events" echo "" echo "=== FAIL2BAN - ACTIVE BANS ===" for jail in sshd apache-auth proftpd; do echo "--- $jail ---" fail2ban-client -weight: 500;">status "$jail" 2>/dev/null || echo "jail $jail not active" done echo "" echo "=== SSH - FAILED ATTEMPTS (24h) ===" grep "Failed password" /var/log/auth.log | grep "$(date +%b)" | tail -50 || echo "none" echo "" echo "=== SSH - SUCCESSFUL CONNECTIONS (24h) ===" grep "Accepted" /var/log/auth.log | grep "$(date +%b)" || echo "none" echo "" echo "=== LISTENING PORTS (check) ===" ss -tlnp echo "" echo "=== ESTABLISHED OUTBOUND CONNECTIONS ===" ss -tnp state established | grep -v "127.0.0.1" | grep -v "::1" | head -30 echo "" # rkhunter scan only on Sundays (day 7) if [ "$(date +%u)" -eq 7 ]; then echo "=== RKHUNTER SCAN (weekly) ===" rkhunter --check --skip-keypress --quiet 2>&1 | tail -30 || echo "rkhunter: error" echo "" fi echo "=== END OF REPORT ==="
} > "$REPORT" 2>&1 chmod 600 "$REPORT" # Run AI analysis separately
/root/scripts/security-analyze.sh "$REPORT"
#!/bin/bash
# /root/scripts/security-analyze.sh
# Receives the report path as argument set -euo pipefail REPORT="${1:-}"
ADMIN_EMAIL="[email protected]"
TODAY=$(date +%Y-%m-%d)
ANALYSIS_TIMEOUT=60 if [ -z "$REPORT" ] || [ ! -f "$REPORT" ]; then echo "Usage: $0 /path/to/report.txt" >&2 exit 1
fi # Prompt for Claude — ask for JSON only to make parsing easy
PROMPT="Analyze this Linux server security report. Distinguish real incidents from normal events (SSH attempts from random IPs are typical background noise). A real incident would be: a successful SSH connection from an unknown IP, a sensitive file change in auditd, an unexpected open port, or an abnormally high volume of attempts on a specific -weight: 500;">service. Reply ONLY with valid JSON, no markdown, no explanation: {\"alert\": true/false, \"summary\": \"2-3 sentence summary\", \"details\": [\"point1\", \"point2\"]}" # Claude call with timeout
AI_RESPONSE=""
AI_ERROR=0 if command -v claude >/dev/null 2>&1; then AI_RESPONSE=$(timeout "$ANALYSIS_TIMEOUT" bash -c "cat '$REPORT' | claude --print --model claude-haiku-4-5 '$PROMPT'" 2>/dev/null) || AI_ERROR=1
else AI_ERROR=1
fi # Safe fallback: if AI is unavailable, send raw report
# We don't miss an incident because the API was down
if [ "$AI_ERROR" -eq 1 ] || [ -z "$AI_RESPONSE" ]; then mail -s "[SECURITY] Report $TODAY - AI analysis unavailable" "$ADMIN_EMAIL" < "$REPORT" exit 0
fi # Parse the response JSON
ALERT=$(echo "$AI_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('alert', False))" 2>/dev/null || echo "parse_error") if [ "$ALERT" = "parse_error" ]; then # Invalid JSON → fallback raw report mail -s "[SECURITY] Report $TODAY - invalid AI response" "$ADMIN_EMAIL" < "$REPORT" exit 0
fi if [ "$ALERT" = "True" ]; then SUMMARY=$(echo "$AI_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('summary', 'N/A'))" 2>/dev/null || echo "N/A") { echo "AI Analysis: $SUMMARY" echo "" echo "--- Full report ---" cat "$REPORT" } | mail -s "[SECURITY ALERT] $TODAY - Incident detected" "$ADMIN_EMAIL"
fi # If alert=False: nothing. The report is archived in $REPORT_DIR for manual review.
#!/bin/bash
# /root/scripts/security-analyze.sh
# Receives the report path as argument set -euo pipefail REPORT="${1:-}"
ADMIN_EMAIL="[email protected]"
TODAY=$(date +%Y-%m-%d)
ANALYSIS_TIMEOUT=60 if [ -z "$REPORT" ] || [ ! -f "$REPORT" ]; then echo "Usage: $0 /path/to/report.txt" >&2 exit 1
fi # Prompt for Claude — ask for JSON only to make parsing easy
PROMPT="Analyze this Linux server security report. Distinguish real incidents from normal events (SSH attempts from random IPs are typical background noise). A real incident would be: a successful SSH connection from an unknown IP, a sensitive file change in auditd, an unexpected open port, or an abnormally high volume of attempts on a specific -weight: 500;">service. Reply ONLY with valid JSON, no markdown, no explanation: {\"alert\": true/false, \"summary\": \"2-3 sentence summary\", \"details\": [\"point1\", \"point2\"]}" # Claude call with timeout
AI_RESPONSE=""
AI_ERROR=0 if command -v claude >/dev/null 2>&1; then AI_RESPONSE=$(timeout "$ANALYSIS_TIMEOUT" bash -c "cat '$REPORT' | claude --print --model claude-haiku-4-5 '$PROMPT'" 2>/dev/null) || AI_ERROR=1
else AI_ERROR=1
fi # Safe fallback: if AI is unavailable, send raw report
# We don't miss an incident because the API was down
if [ "$AI_ERROR" -eq 1 ] || [ -z "$AI_RESPONSE" ]; then mail -s "[SECURITY] Report $TODAY - AI analysis unavailable" "$ADMIN_EMAIL" < "$REPORT" exit 0
fi # Parse the response JSON
ALERT=$(echo "$AI_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('alert', False))" 2>/dev/null || echo "parse_error") if [ "$ALERT" = "parse_error" ]; then # Invalid JSON → fallback raw report mail -s "[SECURITY] Report $TODAY - invalid AI response" "$ADMIN_EMAIL" < "$REPORT" exit 0
fi if [ "$ALERT" = "True" ]; then SUMMARY=$(echo "$AI_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('summary', 'N/A'))" 2>/dev/null || echo "N/A") { echo "AI Analysis: $SUMMARY" echo "" echo "--- Full report ---" cat "$REPORT" } | mail -s "[SECURITY ALERT] $TODAY - Incident detected" "$ADMIN_EMAIL"
fi # If alert=False: nothing. The report is archived in $REPORT_DIR for manual review.
#!/bin/bash
# /root/scripts/security-analyze.sh
# Receives the report path as argument set -euo pipefail REPORT="${1:-}"
ADMIN_EMAIL="[email protected]"
TODAY=$(date +%Y-%m-%d)
ANALYSIS_TIMEOUT=60 if [ -z "$REPORT" ] || [ ! -f "$REPORT" ]; then echo "Usage: $0 /path/to/report.txt" >&2 exit 1
fi # Prompt for Claude — ask for JSON only to make parsing easy
PROMPT="Analyze this Linux server security report. Distinguish real incidents from normal events (SSH attempts from random IPs are typical background noise). A real incident would be: a successful SSH connection from an unknown IP, a sensitive file change in auditd, an unexpected open port, or an abnormally high volume of attempts on a specific -weight: 500;">service. Reply ONLY with valid JSON, no markdown, no explanation: {\"alert\": true/false, \"summary\": \"2-3 sentence summary\", \"details\": [\"point1\", \"point2\"]}" # Claude call with timeout
AI_RESPONSE=""
AI_ERROR=0 if command -v claude >/dev/null 2>&1; then AI_RESPONSE=$(timeout "$ANALYSIS_TIMEOUT" bash -c "cat '$REPORT' | claude --print --model claude-haiku-4-5 '$PROMPT'" 2>/dev/null) || AI_ERROR=1
else AI_ERROR=1
fi # Safe fallback: if AI is unavailable, send raw report
# We don't miss an incident because the API was down
if [ "$AI_ERROR" -eq 1 ] || [ -z "$AI_RESPONSE" ]; then mail -s "[SECURITY] Report $TODAY - AI analysis unavailable" "$ADMIN_EMAIL" < "$REPORT" exit 0
fi # Parse the response JSON
ALERT=$(echo "$AI_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('alert', False))" 2>/dev/null || echo "parse_error") if [ "$ALERT" = "parse_error" ]; then # Invalid JSON → fallback raw report mail -s "[SECURITY] Report $TODAY - invalid AI response" "$ADMIN_EMAIL" < "$REPORT" exit 0
fi if [ "$ALERT" = "True" ]; then SUMMARY=$(echo "$AI_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('summary', 'N/A'))" 2>/dev/null || echo "N/A") { echo "AI Analysis: $SUMMARY" echo "" echo "--- Full report ---" cat "$REPORT" } | mail -s "[SECURITY ALERT] $TODAY - Incident detected" "$ADMIN_EMAIL"
fi # If alert=False: nothing. The report is archived in $REPORT_DIR for manual review.
# Make executable and schedule
chmod 700 /root/scripts/security-audit.sh
chmod 700 /root/scripts/security-analyze.sh # Root crontab
crontab -e
# Add:
# 0 3 * * * /root/scripts/security-audit.sh
# Make executable and schedule
chmod 700 /root/scripts/security-audit.sh
chmod 700 /root/scripts/security-analyze.sh # Root crontab
crontab -e
# Add:
# 0 3 * * * /root/scripts/security-audit.sh
# Make executable and schedule
chmod 700 /root/scripts/security-audit.sh
chmod 700 /root/scripts/security-analyze.sh # Root crontab
crontab -e
# Add:
# 0 3 * * * /root/scripts/security-audit.sh
<?php
// Configure before session_start()
ini_set('session.cookie_httponly', 1); // Inaccessible from JavaScript
ini_set('session.cookie_secure', 1); // HTTPS only
ini_set('session.cookie_samesite', 'Lax'); // Partial CSRF protection
ini_set('session.use_strict_mode', 1); // Reject session IDs not generated by the server session_start();
?>
<?php
// Configure before session_start()
ini_set('session.cookie_httponly', 1); // Inaccessible from JavaScript
ini_set('session.cookie_secure', 1); // HTTPS only
ini_set('session.cookie_samesite', 'Lax'); // Partial CSRF protection
ini_set('session.use_strict_mode', 1); // Reject session IDs not generated by the server session_start();
?>
<?php
// Configure before session_start()
ini_set('session.cookie_httponly', 1); // Inaccessible from JavaScript
ini_set('session.cookie_secure', 1); // HTTPS only
ini_set('session.cookie_samesite', 'Lax'); // Partial CSRF protection
ini_set('session.use_strict_mode', 1); // Reject session IDs not generated by the server session_start();
?>
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax
session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax
session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax
session.use_strict_mode = 1
<?php
session_start(); // Token generation (once per session or per form)
if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} // In the HTML form
// <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"> // Verification on POST requests
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $submitted_token = $_POST['csrf_token'] ?? ''; if (!hash_equals($_SESSION['csrf_token'], $submitted_token)) { http_response_code(403); die('Invalid CSRF token'); } // Regenerate after use for single-use forms $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<?php
session_start(); // Token generation (once per session or per form)
if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} // In the HTML form
// <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"> // Verification on POST requests
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $submitted_token = $_POST['csrf_token'] ?? ''; if (!hash_equals($_SESSION['csrf_token'], $submitted_token)) { http_response_code(403); die('Invalid CSRF token'); } // Regenerate after use for single-use forms $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<?php
session_start(); // Token generation (once per session or per form)
if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} // In the HTML form
// <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"> // Verification on POST requests
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $submitted_token = $_POST['csrf_token'] ?? ''; if (!hash_equals($_SESSION['csrf_token'], $submitted_token)) { http_response_code(403); die('Invalid CSRF token'); } // Regenerate after use for single-use forms $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<?php
function check_rate_limit(string $action, int $max_attempts, int $window_seconds): bool
{ $key = 'rate_limit_' . $action; $now = time(); if (!isset($_SESSION[$key])) { $_SESSION[$key] = ['count' => 0, 'reset_at' => $now + $window_seconds]; } if ($now > $_SESSION[$key]['reset_at']) { $_SESSION[$key] = ['count' => 0, 'reset_at' => $now + $window_seconds]; } $_SESSION[$key]['count']++; return $_SESSION[$key]['count'] <= $max_attempts;
} // Usage
if (!check_rate_limit('login', 5, 300)) { // 5 attempts per 5 minutes http_response_code(429); die('Too many attempts. Please try again in a few minutes.');
}
?>
<?php
function check_rate_limit(string $action, int $max_attempts, int $window_seconds): bool
{ $key = 'rate_limit_' . $action; $now = time(); if (!isset($_SESSION[$key])) { $_SESSION[$key] = ['count' => 0, 'reset_at' => $now + $window_seconds]; } if ($now > $_SESSION[$key]['reset_at']) { $_SESSION[$key] = ['count' => 0, 'reset_at' => $now + $window_seconds]; } $_SESSION[$key]['count']++; return $_SESSION[$key]['count'] <= $max_attempts;
} // Usage
if (!check_rate_limit('login', 5, 300)) { // 5 attempts per 5 minutes http_response_code(429); die('Too many attempts. Please try again in a few minutes.');
}
?>
<?php
function check_rate_limit(string $action, int $max_attempts, int $window_seconds): bool
{ $key = 'rate_limit_' . $action; $now = time(); if (!isset($_SESSION[$key])) { $_SESSION[$key] = ['count' => 0, 'reset_at' => $now + $window_seconds]; } if ($now > $_SESSION[$key]['reset_at']) { $_SESSION[$key] = ['count' => 0, 'reset_at' => $now + $window_seconds]; } $_SESSION[$key]['count']++; return $_SESSION[$key]['count'] <= $max_attempts;
} // Usage
if (!check_rate_limit('login', 5, 300)) { // 5 attempts per 5 minutes http_response_code(429); die('Too many attempts. Please try again in a few minutes.');
}
?>
# In .htaccess or the vhost config
# Requires mod_headers: a2enmod headers <IfModule mod_headers.c> # Prevents the browser from guessing the MIME type Header always set X-Content-Type-Options "nosniff" # Prevents inclusion in iframes (clickjacking protection) Header always set X-Frame-Options "SAMEORIGIN" # Controls information sent in the Referer header Header always set Referrer-Policy "strict-origin-when-cross-origin" # Disables sensitive unused features Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()" # Remove Apache version from responses Header always unset X-Powered-By ServerTokens Prod ServerSignature Off
</IfModule>
# In .htaccess or the vhost config
# Requires mod_headers: a2enmod headers <IfModule mod_headers.c> # Prevents the browser from guessing the MIME type Header always set X-Content-Type-Options "nosniff" # Prevents inclusion in iframes (clickjacking protection) Header always set X-Frame-Options "SAMEORIGIN" # Controls information sent in the Referer header Header always set Referrer-Policy "strict-origin-when-cross-origin" # Disables sensitive unused features Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()" # Remove Apache version from responses Header always unset X-Powered-By ServerTokens Prod ServerSignature Off
</IfModule>
# In .htaccess or the vhost config
# Requires mod_headers: a2enmod headers <IfModule mod_headers.c> # Prevents the browser from guessing the MIME type Header always set X-Content-Type-Options "nosniff" # Prevents inclusion in iframes (clickjacking protection) Header always set X-Frame-Options "SAMEORIGIN" # Controls information sent in the Referer header Header always set Referrer-Policy "strict-origin-when-cross-origin" # Disables sensitive unused features Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()" # Remove Apache version from responses Header always unset X-Powered-By ServerTokens Prod ServerSignature Off
</IfModule>
# /srv/wiki/-weight: 500;">docker-compose.yml services: wiki: image: requarks/wiki:2 # BAD — accessible from any IP on port 3000 ports: - "3000:3000" # GOOD — only from localhost, the reverse proxy can reach it, Internet cannot ports: - "127.0.0.1:3000:3000"
# /srv/wiki/-weight: 500;">docker-compose.yml services: wiki: image: requarks/wiki:2 # BAD — accessible from any IP on port 3000 ports: - "3000:3000" # GOOD — only from localhost, the reverse proxy can reach it, Internet cannot ports: - "127.0.0.1:3000:3000"
# /srv/wiki/-weight: 500;">docker-compose.yml services: wiki: image: requarks/wiki:2 # BAD — accessible from any IP on port 3000 ports: - "3000:3000" # GOOD — only from localhost, the reverse proxy can reach it, Internet cannot ports: - "127.0.0.1:3000:3000"
<VirtualHost *:443> ServerName wiki.example.com ProxyPreserveHost On ProxyPass / http://127.0.0.1:3000/ ProxyPassReverse / http://127.0.0.1:3000/ # Authentication is handled here, not in the container <Location /admin> AuthType Digest AuthName "Admin Area" AuthDigestProvider file AuthUserFile /etc/apache2/.htdigest Require valid-user </Location>
</VirtualHost>
<VirtualHost *:443> ServerName wiki.example.com ProxyPreserveHost On ProxyPass / http://127.0.0.1:3000/ ProxyPassReverse / http://127.0.0.1:3000/ # Authentication is handled here, not in the container <Location /admin> AuthType Digest AuthName "Admin Area" AuthDigestProvider file AuthUserFile /etc/apache2/.htdigest Require valid-user </Location>
</VirtualHost>
<VirtualHost *:443> ServerName wiki.example.com ProxyPreserveHost On ProxyPass / http://127.0.0.1:3000/ ProxyPassReverse / http://127.0.0.1:3000/ # Authentication is handled here, not in the container <Location /admin> AuthType Digest AuthName "Admin Area" AuthDigestProvider file AuthUserFile /etc/apache2/.htdigest Require valid-user </Location>
</VirtualHost>
# List exposed Docker ports
-weight: 500;">docker ps --format "table {{.Names}}\t{{.Ports}}" # Look for bindings on 0.0.0.0 (problematic)
-weight: 500;">docker ps --format "{{.Ports}}" | grep "0.0.0.0"
# List exposed Docker ports
-weight: 500;">docker ps --format "table {{.Names}}\t{{.Ports}}" # Look for bindings on 0.0.0.0 (problematic)
-weight: 500;">docker ps --format "{{.Ports}}" | grep "0.0.0.0"
# List exposed Docker ports
-weight: 500;">docker ps --format "table {{.Names}}\t{{.Ports}}" # Look for bindings on 0.0.0.0 (problematic)
-weight: 500;">docker ps --format "{{.Ports}}" | grep "0.0.0.0"
-weight: 500;">apt -weight: 500;">install unattended-upgrades -weight: 500;">apt-listchanges # Enable
dpkg-reconfigure -plow unattended-upgrades
-weight: 500;">apt -weight: 500;">install unattended-upgrades -weight: 500;">apt-listchanges # Enable
dpkg-reconfigure -plow unattended-upgrades
-weight: 500;">apt -weight: 500;">install unattended-upgrades -weight: 500;">apt-listchanges # Enable
dpkg-reconfigure -plow unattended-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
APT::Periodic::Unattended-Upgrade "1";
# Simulate what would be updated
unattended--weight: 500;">upgrade --dry-run -d # Force an -weight: 500;">update now
unattended--weight: 500;">upgrade -d # View logs of past updates
cat /var/log/unattended-upgrades/unattended-upgrades.log | tail -50
# Simulate what would be updated
unattended--weight: 500;">upgrade --dry-run -d # Force an -weight: 500;">update now
unattended--weight: 500;">upgrade -d # View logs of past updates
cat /var/log/unattended-upgrades/unattended-upgrades.log | tail -50
# Simulate what would be updated
unattended--weight: 500;">upgrade --dry-run -d # Force an -weight: 500;">update now
unattended--weight: 500;">upgrade -d # View logs of past updates
cat /var/log/unattended-upgrades/unattended-upgrades.log | tail -50
# /etc/logrotate.d/rsyslog — change rotate 4 to rotate 13 for ~3 months
# (check the existing content first)
cat /etc/logrotate.d/rsyslog
# /etc/logrotate.d/rsyslog — change rotate 4 to rotate 13 for ~3 months
# (check the existing content first)
cat /etc/logrotate.d/rsyslog
# /etc/logrotate.d/rsyslog — change rotate 4 to rotate 13 for ~3 months
# (check the existing content first)
cat /etc/logrotate.d/rsyslog
# Modified version
/var/log/syslog
/var/log/auth.log
/var/log/kern.log
/var/log/mail.log
/var/log/daemon.log
{ rotate 13 weekly missingok notifempty compress delaycompress sharedscripts postrotate /usr/lib/rsyslog/rsyslog-rotate endscript
}
# Modified version
/var/log/syslog
/var/log/auth.log
/var/log/kern.log
/var/log/mail.log
/var/log/daemon.log
{ rotate 13 weekly missingok notifempty compress delaycompress sharedscripts postrotate /usr/lib/rsyslog/rsyslog-rotate endscript
}
# Modified version
/var/log/syslog
/var/log/auth.log
/var/log/kern.log
/var/log/mail.log
/var/log/daemon.log
{ rotate 13 weekly missingok notifempty compress delaycompress sharedscripts postrotate /usr/lib/rsyslog/rsyslog-rotate endscript
}
# /etc/logrotate.d/apache2 — extend to 365 days
# Look for "rotate" in the existing file and adapt
# Recommended format for Apache: daily rotation, 365 files
/var/log/apache2/*.log { daily rotate 365 missingok notifempty compress delaycompress sharedscripts postrotate if invoke-rc.d apache2 -weight: 500;">status > /dev/null 2>&1; then invoke-rc.d apache2 reload > /dev/null 2>&1 fi endscript
}
# /etc/logrotate.d/apache2 — extend to 365 days
# Look for "rotate" in the existing file and adapt
# Recommended format for Apache: daily rotation, 365 files
/var/log/apache2/*.log { daily rotate 365 missingok notifempty compress delaycompress sharedscripts postrotate if invoke-rc.d apache2 -weight: 500;">status > /dev/null 2>&1; then invoke-rc.d apache2 reload > /dev/null 2>&1 fi endscript
}
# /etc/logrotate.d/apache2 — extend to 365 days
# Look for "rotate" in the existing file and adapt
# Recommended format for Apache: daily rotation, 365 files
/var/log/apache2/*.log { daily rotate 365 missingok notifempty compress delaycompress sharedscripts postrotate if invoke-rc.d apache2 -weight: 500;">status > /dev/null 2>&1; then invoke-rc.d apache2 reload > /dev/null 2>&1 fi endscript
}
# fail2ban: 54 weeks (~1 year)
# /etc/logrotate.d/fail2ban
/var/log/fail2ban.log { weekly rotate 54 compress delaycompress missingok postrotate fail2ban-client flushlogs 1>/dev/null || true endscript
} # Test logrotate config
logrotate --debug /etc/logrotate.conf
# fail2ban: 54 weeks (~1 year)
# /etc/logrotate.d/fail2ban
/var/log/fail2ban.log { weekly rotate 54 compress delaycompress missingok postrotate fail2ban-client flushlogs 1>/dev/null || true endscript
} # Test logrotate config
logrotate --debug /etc/logrotate.conf
# fail2ban: 54 weeks (~1 year)
# /etc/logrotate.d/fail2ban
/var/log/fail2ban.log { weekly rotate 54 compress delaycompress missingok postrotate fail2ban-client flushlogs 1>/dev/null || true endscript
} # Test logrotate config
logrotate --debug /etc/logrotate.conf
# Change a user's password in an htdigest file
htdigest /etc/apache2/.htdigest "Private Area" alice # Verify the resulting file (format: user:realm:md5_hash)
cat /etc/apache2/.htdigest
# Change a user's password in an htdigest file
htdigest /etc/apache2/.htdigest "Private Area" alice # Verify the resulting file (format: user:realm:md5_hash)
cat /etc/apache2/.htdigest
# Change a user's password in an htdigest file
htdigest /etc/apache2/.htdigest "Private Area" alice # Verify the resulting file (format: user:realm:md5_hash)
cat /etc/apache2/.htdigest
# Change a user's password
passwd alice # Change root password
passwd root # Force change on next login
chage -d 0 alice
# Change a user's password
passwd alice # Change root password
passwd root # Force change on next login
chage -d 0 alice
# Change a user's password
passwd alice # Change root password
passwd root # Force change on next login
chage -d 0 alice
# Search for references to a username in configs
grep -r "alice" /etc/apache2/ 2>/dev/null
grep -r "alice" /etc/proftpd/ 2>/dev/null
grep -r "alice" /var/www/html/ 2>/dev/null # Find htpasswd and htdigest files
find /etc/apache2 /var/www -name ".htpasswd" -o -name ".htdigest" 2>/dev/null
# Search for references to a username in configs
grep -r "alice" /etc/apache2/ 2>/dev/null
grep -r "alice" /etc/proftpd/ 2>/dev/null
grep -r "alice" /var/www/html/ 2>/dev/null # Find htpasswd and htdigest files
find /etc/apache2 /var/www -name ".htpasswd" -o -name ".htdigest" 2>/dev/null
# Search for references to a username in configs
grep -r "alice" /etc/apache2/ 2>/dev/null
grep -r "alice" /etc/proftpd/ 2>/dev/null
grep -r "alice" /var/www/html/ 2>/dev/null # Find htpasswd and htdigest files
find /etc/apache2 /var/www -name ".htpasswd" -o -name ".htdigest" 2>/dev/null - fail2ban installed and configured with custom filter for Apache auth_digest
- Active jails: sshd, apache-auth, proftpd
- sftponly group with chroot and ForceCommand internal-sftp
- Chrooted users' home dirs at root:root 755 (unavoidable constraint)
- Detailed SFTP logging enabled in sshd_config
- Root SSH login set to prohibit-password, authorized_keys verified
- Credential file permissions reviewed (640, root:www-data)
- Sensitive directories blocked by RewriteRule (not Location)
- Accounts without -weight: 600;">sudo need removed from the -weight: 600;">sudo group
- www-data sudoers limited to strictly necessary commands
- auditd installed with rules on critical files
- acct installed for per-user command history
- rkhunter installed, baseline initialized
- Daily audit script with AI analysis and raw report fallback
- Secure PHP sessions (httponly, secure, samesite)
- CSRF protection on forms
- Security HTTP headers configured in Apache
- Docker ports bound to 127.0.0.1 only
- unattended-upgrades active for security updates
- Extended log retention (3 months for auth.log, 1 year for Apache)
- All potentially exposed passwords changed