Initial version: npm-scanner image, filters, entrypoint, watcher, testbed

- Image based on crazymax/fail2ban:1.1.1 with inotify-tools
- Filters npm-scanner and npm-wplogin, tested with fail2ban-regex in the build
- jail.local generated from .env, optional npm-wplogin and recidive
- Watcher reloads the jails when NPM creates a new proxy host log
- check command, docs, testbed for LXC

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schiwagoaandClaude Opus 5.5 committed 2026-10-25 20:41:08 +01:00
commit d7dcd9d6b1
29 files changed
+1163

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
# Testbed settings – copy to .env
TZ=Europe/Vienna
F2B_LOG_LEVEL=INFO
# 192.0.2.10 is used by check.sh as "whitelisted" address.
# Do NOT add the workstation here if you want to run the manual block test.
WHITELIST=192.0.2.10
F2B_CHAIN=DOCKER-USER
ENABLE_WPLOGIN=true
ENABLE_RECIDIVE=false
ENABLE_NEWLOG_WATCH=true
# Published port of the target container
TARGET_BIND=0.0.0.0
TARGET_PORT=8081
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# npm-scanner – automated testbed checks (run as root next to docker-compose.yaml)
#
# ./check.sh prepare create the simulated NPM log directories (before `docker compose up`)
# ./check.sh run all checks against the running testbed
# ./check.sh cleanup unban the test addresses
#
# Test addresses are from the documentation ranges (RFC 5737) and never route anywhere.
set -uo pipefail
cd "$(dirname "$0")"
C=npm-scanner-testbed
F=./fake-npm-logs.sh
pass=0 fail=0
ok() { echo " ok $*"; pass=$((pass + 1)); }
bad() { echo " FAIL $*"; fail=$((fail + 1)); }
f2b() { docker exec "$C" fail2ban-client "$@"; }
banned() { f2b get "$1" banip 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; }
wait_ban() { # jail ip [seconds]
for _ in $(seq 1 "${3:-15}"); do banned "$1" "$2" && return 0; sleep 1; done
return 1
}
if [[ "${1:-}" == "prepare" ]]; then
mkdir -p npm-logs/log npm-logs/logs data
touch npm-logs/log/proxy-host-1_access.log npm-logs/log/default-host_access.log
echo "prepared: npm-logs/log, npm-logs/logs, data"
exit 0
fi
if [[ "${1:-}" == "cleanup" ]]; then
for ip in 198.51.100.77 198.51.100.78 198.51.100.79 198.51.100.80 192.0.2.10 198.51.100.81 198.51.100.82; do
f2b unban "$ip" >/dev/null 2>&1 || true
done
echo "test addresses unbanned"
exit 0
fi
echo "== 1. container"
state=$(docker inspect -f '{{.State.Health.Status}}' "$C" 2>/dev/null)
[[ "$state" == healthy ]] && ok "health: $state" || bad "health: ${state:-not running}"
jails=$(f2b status | sed -n 's/.*Jail list:\s*//p')
[[ "$jails" == *npm-scanner* ]] && ok "jails: $jails" || bad "npm-scanner jail missing ($jails)"
[[ "$jails" == *npm-wplogin* ]] && ok "npm-wplogin active" || bad "npm-wplogin missing (ENABLE_WPLOGIN=true?)"
docker exec "$C" test -f /usr/share/npm-scanner/filter-tests.passed && ok "filter tests passed in build" || bad "filter test marker missing"
echo "== 2. scanner ban (log/, host 1)"
$F scan 198.51.100.77 3 1 log >/dev/null
wait_ban npm-scanner 198.51.100.77 && ok "198.51.100.77 banned" || bad "198.51.100.77 not banned"
iptables -S DOCKER-USER | grep -q -- '-j f2b-npm-scan' && ok "DOCKER-USER jumps to f2b-npm-scan" || bad "no jump in DOCKER-USER"
iptables -S f2b-npm-scan 2>/dev/null | grep -q '198.51.100.77' && ok "rule for 198.51.100.77 on the host" || bad "no host rule for 198.51.100.77"
echo "== 3. new host in second directory (logs/, host 40) -> watcher"
$F newhost 40 logs >/dev/null
sleep 6
f2b get npm-scanner logpath | grep -q '/logs/npm2/proxy-host-40_access.log' && ok "host 40 watched after reload" || bad "host 40 not watched"
docker logs "$C" 2>&1 | grep -q 'proxy-host-40_access.log – npm-scanner reloaded' && ok "watcher logged the reload" || bad "no watcher message"
banned npm-scanner 198.51.100.77 && ok "ban survived the reload" || bad "ban lost after reload"
$F scan 198.51.100.78 3 40 logs >/dev/null
wait_ban npm-scanner 198.51.100.78 && ok "198.51.100.78 banned from host 40" || bad "198.51.100.78 not banned"
echo "== 4. default host 444"
$F fallback 198.51.100.79 3 >/dev/null
wait_ban npm-scanner 198.51.100.79 && ok "198.51.100.79 banned (444)" || bad "198.51.100.79 not banned"
echo "== 5. wp-login brute force"
$F wplogin 198.51.100.80 5 1 log >/dev/null
wait_ban npm-wplogin 198.51.100.80 && ok "198.51.100.80 banned by npm-wplogin" || bad "198.51.100.80 not banned"
echo "== 6. must NOT ban"
$F scan 192.0.2.10 5 1 log >/dev/null
$F harmless 198.51.100.81 10 1 log >/dev/null
$F scan 198.51.100.82 2 1 log >/dev/null
sleep 5
banned npm-scanner 192.0.2.10 && bad "whitelisted 192.0.2.10 banned" || ok "whitelisted 192.0.2.10 not banned"
banned npm-scanner 198.51.100.81 && bad "harmless 198.51.100.81 banned" || ok "harmless 404/200 not banned"
banned npm-scanner 198.51.100.82 && bad "198.51.100.82 banned after 2 hits" || ok "2 hits below maxretry"
echo "== 7. restart keeps bans"
docker restart "$C" >/dev/null
for _ in $(seq 1 30); do f2b ping >/dev/null 2>&1 && break; sleep 1; done
sleep 3
banned npm-scanner 198.51.100.77 && ok "198.51.100.77 restored after restart" || bad "bans lost after restart"
iptables -S f2b-npm-scan 2>/dev/null | grep -q '198.51.100.77' && ok "host rule restored" || bad "host rule missing after restart"
echo "== 8. stop removes the rules"
docker stop "$C" >/dev/null
if iptables -S 2>/dev/null | grep -q 'f2b-npm-'; then bad "f2b-npm-* rules left after stop"; else ok "no f2b-npm-* rules after stop"; fi
docker start "$C" >/dev/null
for _ in $(seq 1 30); do f2b ping >/dev/null 2>&1 && break; sleep 1; done
sleep 3
banned npm-scanner 198.51.100.77 && ok "bans back after start" || bad "bans missing after start"
echo
echo "result: $pass ok, $fail failed"
[[ $fail -eq 0 ]]
+51
View File
@@ -0,0 +1,51 @@
# npm-scanner – testbed
#
# npm-scanner from the local source plus a small web server as target.
# NPM itself is simulated: fake-npm-logs.sh writes log lines in NPM format into
# ./npm-logs/log and ./npm-logs/logs (layout of jlesage/nginx-proxy-manager).
#
# cp .env.example .env && nano .env
# docker compose up -d --build
# ./check.sh
name: npm-scanner-testbed
services:
npm-scanner:
build: ../..
image: npm-scanner:testbed
container_name: npm-scanner-testbed
restart: "no"
init: true
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
env_file: .env
environment:
NEWLOG_SETTLE_SECONDS: "2"
volumes:
- ./data:/data
- ./npm-logs/log:/logs/npm1:ro
- ./npm-logs/logs:/logs/npm2:ro
# Target behind a published port – traffic to it passes DOCKER-USER.
target:
image: traefik/whoami:v1.12.0
container_name: npm-scanner-target
restart: "no"
ports:
- "${TARGET_BIND:-0.0.0.0}:${TARGET_PORT:-8081}:80"
networks:
npm-scanner-target.dockernetwork.local:
ipv4_address: 172.16.52.10
networks:
npm-scanner-target.dockernetwork.local:
name: npm-scanner-target.dockernetwork.local
driver: bridge
ipam:
config:
- subnet: 172.16.52.0/24
gateway: 172.16.52.1
ip_range: 172.16.52.128/25
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Writes log lines in Nginx Proxy Manager format, like NPM would.
#
# ./fake-npm-logs.sh scan <ip> [count] [host-id] [log|logs] 4xx on scanner paths
# ./fake-npm-logs.sh wplogin <ip> [count] [host-id] [log|logs] POST /wp-login.php
# ./fake-npm-logs.sh harmless <ip> [count] [host-id] [log|logs] 404 /favicon.ico, 200 /
# ./fake-npm-logs.sh fallback <ip> [count] 444 on the default host
# ./fake-npm-logs.sh newhost <host-id> [log|logs] empty log of a new proxy host
set -euo pipefail
cd "$(dirname "$0")"
cmd=${1:?command missing}
now() { date -u '+%d/%b/%Y:%H:%M:%S +0000'; }
proxy_line() { # status method path ip
printf '[%s] - %s %s - %s https test.example.com "%s" [Client %s] [Length 150] [Gzip -] [Sent-to 172.16.52.10] "curl/8.5.0" "-"\n' \
"$(now)" "$1" "$1" "$2" "$3" "$4"
}
case "$cmd" in
scan|wplogin|harmless)
ip=${2:?ip missing}; count=${3:-3}; host=${4:-1}; dir=npm-logs/${5:-log}
file="$dir/proxy-host-${host}_access.log"
mkdir -p "$dir"
paths=(/.env /.git/config /phpinfo.php /vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php /actuator/env)
for ((i = 0; i < count; i++)); do
case "$cmd" in
scan) proxy_line 404 GET "${paths[i % ${#paths[@]}]}" "$ip" ;;
wplogin) proxy_line 200 POST /wp-login.php "$ip" ;;
harmless) proxy_line 404 GET /favicon.ico "$ip"; proxy_line 200 GET / "$ip" ;;
esac >> "$file"
sleep 0.2
done
echo "$cmd: $count × $ip -> $file"
;;
fallback)
ip=${2:?ip missing}; count=${3:-3}
mkdir -p npm-logs/log
for ((i = 0; i < count; i++)); do
printf '%s - - [%s] "GET / HTTP/1.1" 444 0 "-" "curl/8.5.0"\n' "$ip" "$(now)" >> npm-logs/log/default-host_access.log
sleep 0.2
done
echo "fallback: $count × $ip -> npm-logs/log/default-host_access.log"
;;
newhost)
host=${2:?host-id missing}; dir=npm-logs/${3:-log}
mkdir -p "$dir"
touch "$dir/proxy-host-${host}_access.log"
echo "newhost: $dir/proxy-host-${host}_access.log"
;;
*)
sed -n '2,9p' "$0"; exit 1 ;;
esac