惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
V2EX
Cisco Talos Blog
Cisco Talos Blog
MongoDB | Blog
MongoDB | Blog
IT之家
IT之家
N
News and Events Feed by Topic
博客园 - 叶小钗
Help Net Security
Help Net Security
美团技术团队
Attack and Defense Labs
Attack and Defense Labs
雷峰网
雷峰网
S
Security @ Cisco Blogs
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
WordPress大学
WordPress大学
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
AI
AI
Hacker News: Ask HN
Hacker News: Ask HN
T
The Blog of Author Tim Ferriss
Security Latest
Security Latest
Last Week in AI
Last Week in AI
S
Secure Thoughts
Simon Willison's Weblog
Simon Willison's Weblog
TaoSecurity Blog
TaoSecurity Blog
N
News and Events Feed by Topic
A
Arctic Wolf
Y
Y Combinator Blog
MyScale Blog
MyScale Blog
Cyberwarzone
Cyberwarzone
酷 壳 – CoolShell
酷 壳 – CoolShell
S
SegmentFault 最新的问题
罗磊的独立博客
Vercel News
Vercel News
D
DataBreaches.Net
博客园 - 聂微东
P
Palo Alto Networks Blog
N
News | PayPal Newsroom
GbyAI
GbyAI
B
Blog
A
About on SuperTechFans
PCI Perspectives
PCI Perspectives
S
Schneier on Security
Apple Machine Learning Research
Apple Machine Learning Research
I
InfoQ
The GitHub Blog
The GitHub Blog
P
Privacy International News Feed
Blog — PlanetScale
Blog — PlanetScale
博客园_首页
T
The Exploit Database - CXSecurity.com
V
Vulnerabilities – Threatpost
aimingoo的专栏
aimingoo的专栏
Google DeepMind News
Google DeepMind News

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Securing a dedicated Linux Debian 12 server — Complete post-incident guide
Odilon HUGON · 2026-04-30 · via DEV Community

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.

1. Detecting and responding to a brute force attack

Reading Apache logs to identify attempts

Apache's HTTP Digest authentication logs its failures in error.log, not access.log. The codes to look for are AH01790 and AH01794.

# 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

Enter fullscreen mode Exit fullscreen mode

Log lines look like this:

[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/

Enter fullscreen mode Exit fullscreen mode

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.

Identifying the attacker

# Quick whois
whois 13.37.248.113

# Geolocation without installing anything
curl -s https://ipinfo.io/13.37.248.113/json

Enter fullscreen mode Exit fullscreen mode

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.

Verifying no intrusion occurred

Before doing anything else, verify that nothing actually got in. The brute force may have found something before it was noticed.

# 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

Enter fullscreen mode Exit fullscreen mode

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.

2. fail2ban — Protection against brute force

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.

apt install fail2ban
systemctl enable fail2ban

Enter fullscreen mode Exit fullscreen mode

All customizations go in /etc/fail2ban/jail.local (never modify jail.conf directly — it will be overwritten during updates).

SSH jail

# /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

Enter fullscreen mode Exit fullscreen mode

apache-auth jail — the default filter trap

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:

# /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

Enter fullscreen mode Exit fullscreen mode

Then the corresponding jail in jail.local:

[apache-auth]
enabled  = true
filter   = apache-auth
port     = http,https
logpath  = /var/log/apache2/error.log
maxretry = 8
bantime  = 86400
findtime = 300

Enter fullscreen mode Exit fullscreen mode

To test the filter before enabling it in production:

# 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

Enter fullscreen mode Exit fullscreen mode

ProFTPD jail

[proftpd]
enabled  = true
port     = ftp,ftp-data,ftps,ftps-data
logpath  = /var/log/proftpd/proftpd.log
maxretry = 6
bantime  = 86400

Enter fullscreen mode Exit fullscreen mode

Checking jail status

# Overview
fail2ban-client status

# Details of a specific jail
fail2ban-client status apache-auth
fail2ban-client 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

Enter fullscreen mode Exit fullscreen mode

3. SSH/SFTP — Restricting access

About twenty users have SFTP access to upload and retrieve files. None of them need a full SSH shell. That's unnecessary attack surface.

sftponly group and ChrootDirectory

# Create the group
groupadd sftponly

# Add existing users
usermod -aG sftponly alice
usermod -aG sftponly bob
# etc.

Enter fullscreen mode Exit fullscreen mode

In /etc/ssh/sshd_config, add this block at the end (it must come after any existing Match directive):

Match Group sftponly
    ForceCommand internal-sftp -l INFO -f AUTH
    ChrootDirectory %h
    AllowTcpForwarding no
    X11Forwarding no
    PermitTunnel no
    AllowAgentForwarding no

Enter fullscreen mode Exit fullscreen mode

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.

# 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

Enter fullscreen mode Exit fullscreen mode

Detailed SFTP logging

By default, SFTP transfers are not logged in a useful way. Enable logging in the Subsystem directive:

# In /etc/ssh/sshd_config
# Replace the existing Subsystem line with:
Subsystem sftp internal-sftp -l INFO -f AUTH

Enter fullscreen mode Exit fullscreen mode

Transfers then appear in /var/log/auth.log with the format sftp-server[PID]: open "/path/file.txt" flags READ mode 0666.

Disable root SSH login

# 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

Enter fullscreen mode Exit fullscreen mode

prohibit-password (formerly without-password) forbids password login but allows SSH keys. It's safer than no if you need emergency access via key.

# Reload SSH after modification
sshd -t && systemctl reload sshd

Enter fullscreen mode Exit fullscreen mode

4. Permissions and sensitive files

After verifying nothing was modified, this is the opportunity to put permissions into a correct state once and for all.

Credential files

# 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)

Enter fullscreen mode Exit fullscreen mode

Block web access to sensitive directories

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:

# 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]

Enter fullscreen mode Exit fullscreen mode

The [F] flag returns a 403 immediately. [L] stops processing further rules.

5. Sudoers — Principle of least privilege

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.

# List who has sudo
getent group sudo

# Remove sudo from a non-root account
deluser adminuser sudo

# Verify
sudo -l -U adminuser
# "User adminuser is not allowed to run sudo"

Enter fullscreen mode Exit fullscreen mode

If the admin needs elevation, su is sufficient — they know the root password. No need for sudo on a personal server.

www-data and scripts with privileges

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:

# /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

Enter fullscreen mode Exit fullscreen mode

# Correct permissions on this file
chmod 440 /etc/sudoers.d/www-data

# Check syntax before saving
visudo -c -f /etc/sudoers.d/www-data

Enter fullscreen mode Exit fullscreen mode

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():

<?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("sudo /usr/local/bin/create-user-dir.sh $escaped");
?>

Enter fullscreen mode Exit fullscreen mode

6. Audit and intrusion detection (auditd)

fail2ban reacts. auditd observes and records. The two are complementary.

apt install auditd audispd-plugins
systemctl enable auditd

Enter fullscreen mode Exit fullscreen mode

Create the rules file /etc/audit/rules.d/security.rules:

# /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 sudo execution
-w /usr/bin/su -p x -k su_exec
-w /usr/bin/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

Enter fullscreen mode Exit fullscreen mode

# 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

Enter fullscreen mode Exit fullscreen mode

Process accounting with acct

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?"

apt 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

Enter fullscreen mode Exit fullscreen mode

rkhunter — Rootkit scanner

apt install rkhunter

# Initialize the baseline (do this immediately after a clean installation)
rkhunter --update
rkhunter --propupd

# Full scan
rkhunter --check --skip-keypress

# After a system update, regenerate the baseline
apt upgrade && rkhunter --propupd

Enter fullscreen mode Exit fullscreen mode

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.

7. Automated security audit with AI analysis

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.

Collection script

#!/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 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"

Enter fullscreen mode Exit fullscreen mode

AI analysis script

#!/bin/bash
# /root/scripts/security-analyze.sh
# Receives the report path as argument

set -euo pipefail

REPORT="${1:-}"
ADMIN_EMAIL="admin@example.com"
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 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.

Enter fullscreen mode Exit fullscreen mode

# 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

Enter fullscreen mode Exit fullscreen mode

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.

8. PHP security

Secure sessions

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:

<?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();
?>

Enter fullscreen mode Exit fullscreen mode

Or in /etc/php/8.x/apache2/php.ini to apply globally:

session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax
session.use_strict_mode = 1

Enter fullscreen mode Exit fullscreen mode

CSRF protection

Every form that performs an action (modification, deletion, submission) must be protected with a CSRF token. The minimal but correct implementation:

<?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));
}
?>

Enter fullscreen mode Exit fullscreen mode

Rate limiting on sensitive actions

Without a database or Redis, basic session-based rate limiting is sufficient for small sites:

<?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.');
}
?>

Enter fullscreen mode Exit fullscreen mode

9. Apache HTTP headers

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.

# 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>

Enter fullscreen mode Exit fullscreen mode

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.

10. Docker — Isolating containers

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.

# /srv/wiki/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"

Enter fullscreen mode Exit fullscreen mode

The Apache reverse proxy config:

<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>

Enter fullscreen mode Exit fullscreen mode

Check existing containers that might have this problem:

# List exposed Docker ports
docker ps --format "table {{.Names}}\t{{.Ports}}"

# Look for bindings on 0.0.0.0 (problematic)
docker ps --format "{{.Ports}}" | grep "0.0.0.0"

Enter fullscreen mode Exit fullscreen mode

11. Automatic updates

apt install unattended-upgrades apt-listchanges

# Enable
dpkg-reconfigure -plow unattended-upgrades

Enter fullscreen mode Exit fullscreen mode

Verify the generated configuration in /etc/apt/apt.conf.d/20auto-upgrades:

APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
APT::Periodic::Unattended-Upgrade "1";

Enter fullscreen mode Exit fullscreen mode

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.

# Simulate what would be updated
unattended-upgrade --dry-run -d

# Force an update now
unattended-upgrade -d

# View logs of past updates
cat /var/log/unattended-upgrades/unattended-upgrades.log | tail -50

Enter fullscreen mode Exit fullscreen mode

12. Log retention

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.

# /etc/logrotate.d/rsyslog — change rotate 4 to rotate 13 for ~3 months
# (check the existing content first)
cat /etc/logrotate.d/rsyslog

Enter fullscreen mode Exit fullscreen mode

# 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
}

Enter fullscreen mode Exit fullscreen mode

# /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 status > /dev/null 2>&1; then
            invoke-rc.d apache2 reload > /dev/null 2>&1
        fi
    endscript
}

Enter fullscreen mode Exit fullscreen mode

# 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

Enter fullscreen mode Exit fullscreen mode

13. Password changes post-incident

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.

htdigest password

# 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

Enter fullscreen mode Exit fullscreen mode

System passwords

# Change a user's password
passwd alice

# Change root password
passwd root

# Force change on next login
chage -d 0 alice

Enter fullscreen mode Exit fullscreen mode

Checking consistency

The classic trap: a password is referenced in multiple places. Before validating a change, search for all occurrences:

# 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

Enter fullscreen mode Exit fullscreen mode

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.

Conclusion — Security hardening checklist

What this audit produced concretely, summarized for a quick review:

  • 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 sudo need removed from the 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

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.