Tools: Update: Securing a dedicated Linux Debian 12 server — Complete post-incident guide

Tools: Update: Securing a dedicated Linux Debian 12 server — Complete post-incident guide

1. Detecting and responding to a brute force attack

Reading Apache logs to identify attempts

Identifying the attacker

Verifying no intrusion occurred

2. fail2ban — Protection against brute force

SSH jail

apache-auth jail — the default filter trap

ProFTPD jail

Checking jail status

3. SSH/SFTP — Restricting access

sftponly group and ChrootDirectory

Detailed SFTP logging

Disable root SSH login

4. Permissions and sensitive files

Credential files

Block web access to sensitive directories

5. Sudoers — Principle of least privilege

www-data and scripts with privileges

6. Audit and intrusion detection (auditd)

Process accounting with acct

rkhunter — Rootkit scanner

7. Automated security audit with AI analysis

Collection script

AI analysis script

8. PHP security

Secure sessions

CSRF protection

Rate limiting on sensitive actions

9. Apache HTTP headers

10. Docker — Isolating containers

11. Automatic updates

12. Log retention

13. Password changes post-incident

htdigest password

System passwords

Checking consistency

Conclusion — Security hardening checklist An ordinary Tuesday morning. I glance at Apache logs before starting work — a conditioned reflex, rarely useful. Except that morning. In error.log, hundreds of identical lines, all from the same IP: 13.37.248.113. HTTP Digest authentication attempts in a loop, combining common usernames with generic passwords. The server held. HTTP Digest auth with a correct password is a solid barrier against brute force if the password is strong. But the incident still triggered a full audit I'd been putting off for too long. This server hosts a private seedbox shared with about twenty users, a PHP website behind Apache with HTTP Digest authentication, SFTP access via ProFTPD, a wiki in Docker behind an Apache reverse proxy, and Jellyfin for streaming. A classic setup for a semi-professional personal server. Here is everything that was reviewed, fixed, and automated. Apache's HTTP Digest authentication logs its failures in error.log, not access.log. The codes to look for are AH01790 and AH01794. Log lines look like this: Three distinct patterns in these logs: password mismatch (known user, wrong password), not found (non-existent user), and sometimes nonce mismatch (replay of expired challenge). The attacker was clearly testing a list of generic login/password pairs. Result: IP belonging to Amazon AWS eu-west-3 (Paris). Classic. Cheap AWS VPS instances are used en masse for this kind of operation because they're easy to create, difficult to trace back to a real person, and often poorly monitored. The IP has since been reported on AbuseIPDB with around sixty reports. Before doing anything else, verify that nothing actually got in. The brute force may have found something before it was noticed. In this specific case, nothing. The attack was limited to HTTP Digest, without touching SSH. But that doesn't change the need to close the weak points that could have been exploited. fail2ban analyzes logs and bans IPs that exceed an attempt threshold. The default config is a good starting point, but it has significant gaps for this specific setup. All customizations go in /etc/fail2ban/jail.local (never modify jail.conf directly — it will be overwritten during updates). fail2ban includes a default apache-auth filter. It does not detect HTTP Digest authentication errors from Apache 2.4. The default filter looks for patterns like Authorization Required or Basic Auth errors — not the AH01790 / AH01794 from the auth_digest module. A custom filter is required: Then the corresponding jail in jail.local: To test the filter before enabling it in production: About twenty users have SFTP access to upload and retrieve files. None of them need a full SSH shell. That's unnecessary attack surface. In /etc/ssh/sshd_config, add this block at the end (it must come after any existing Match directive): The chroot trap. The ChrootDirectory directive imposes a severe and counter-intuitive constraint: the chroot root directory must belong to root:root with 755 permissions. If it's the user's home directory and they own it, SSH refuses the connection silently — the user just sees a connection error with no explanation on the client side. By default, SFTP transfers are not logged in a useful way. Enable logging in the Subsystem directive: Transfers then appear in /var/log/auth.log with the format sftp-server[PID]: open "/path/file.txt" flags READ mode 0666. prohibit-password (formerly without-password) forbids password login but allows SSH keys. It's safer than no if you need emergency access via key. After verifying nothing was modified, this is the opportunity to put permissions into a correct state once and for all. The natural temptation is to use <Location> with Require all denied. Problem: in Apache 2.4 with HTTP Digest authentication configured at the parent level, Require directives can interact unexpectedly with inherited auth. In some configurations, access is simply challenged with a Digest prompt instead of being denied. RewriteRule is more reliable because it runs in Apache's processing pipeline before authentication: The [F] flag returns a 403 immediately. [L] stops processing further rules. By default on Debian, the installation often creates a user in the sudo group. In a context where the server is shared and exposes services, keeping accounts with full sudo is unnecessary risk. If the admin needs elevation, su is sufficient — they know the root password. No need for sudo on a personal server. If PHP scripts need to execute system commands with elevated privileges (reload Apache, run a maintenance script), never put www-data in sudo globally. Create a file in /etc/sudoers.d/ with only what is strictly necessary: Scripts called by www-data must validate their inputs. If a parameter is passed from PHP, use escapeshellarg() and validate the format before calling shell_exec(): fail2ban reacts. auditd observes and records. The two are complementary. Create the rules file /etc/audit/rules.d/security.rules: acct records every command executed by every user, with timestamp and duration. Less verbose than auditd, but excellent for a post-incident audit: "what did user X do yesterday between 2pm and 3pm?" rkhunter will generate false positives at first — legitimate files it doesn't recognize. Go through the warnings once to qualify them. Real rootkits don't hide in plain sight (if the system is already compromised, rkhunter will likely be bypassed), but the tool is useful for detecting unexpected modifications to system binaries. The problem with monitoring logs on an exposed server: the background noise is enormous. Hundreds of SSH attempts per day from Chinese and Russian IPs is the norm. Alerting on all of them by email would mean never reading your email. The solution put in place: collect the raw report every night, pass this report to Claude via CLI to distinguish background noise from real incidents, and only send an email if something warrants attention. The advantage of the systematic fallback: if Claude API is down, times out, or returns invalid JSON, the raw report is sent anyway. You don't risk missing a real incident because the third-party analysis service was unavailable that night. By default, PHP sessions are not configured to resist cookie theft. Add to php.ini or at the start of every script that uses sessions: Or in /etc/php/8.x/apache2/php.ini to apply globally: Every form that performs an action (modification, deletion, submission) must be protected with a CSRF token. The minimal but correct implementation: Without a database or Redis, basic session-based rate limiting is sufficient for small sites: These headers strengthen security on the browser side. They don't prevent a server intrusion, but they reduce the attack surface for XSS and clickjacking vulnerabilities. HSTS (Strict-Transport-Security) is handled automatically by Certbot/Let's Encrypt if the site is on HTTPS. Don't configure it manually unless you know exactly what you're doing — a too-long duration with an HTTPS config error can make the site inaccessible for months from browsers that cached the header. When Apache acts as a reverse proxy to Docker containers, a common shortcut is to expose container ports on all network interfaces. The result: the service is directly accessible from the Internet, completely bypassing the reverse proxy and all the authentication that comes with it. The Apache reverse proxy config: Check existing containers that might have this problem: Verify the generated configuration in /etc/apt/apt.conf.d/20auto-upgrades: On Debian 12, security updates are included by default in the unattended-upgrades configuration. Check /etc/apt/apt.conf.d/50unattended-upgrades to ensure the Debian-Security origins are uncommented. Debian keeps system logs for 4 weeks by default. If you detect an incident and want to know what happened 6 weeks ago, you have nothing. Extend retention now, before you need it. The least glamorous but most critical part. After any suspicion of intrusion, even if the analysis concludes it was a failed attempt, change all passwords that could have been exposed. When in doubt, change them. The classic trap: a password is referenced in multiple places. Before validating a change, search for all occurrences: A password can live in the htdigest file, in an application's configuration (wiki, seedbox), in maintenance scripts, and in internal documentation. Updating just one of the four and re-explaining everything to all users three weeks later is not a recommended experience. What this audit produced concretely, summarized for a quick review: A server exposed on the Internet will always be attacked. That comes with the territory. The question isn't to prevent attempts — that's impossible — but to ensure that attempts fail, that any eventual successes are detected quickly, and that you have the logs needed to understand what happened. This audit took about two days of work spread over a week. Most points should have been done at initial installation. That's rarely the case. What matters is doing it before something serious happens — and automating monitoring so you don't have to revisit it manually every week. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

# 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