Expose the botScore middleware's in-memory scoring/ban state to admins. Previously state lived only in the store Map with no persistence or API — the only visibility was tailing container logs. - botScore: bounded ring buffer (300) recording scan/login-fail/honeypot and ban events (most-recent-first); listState() snapshot of all scored IPs; unban() to clear a single IP. - New admin-only endpoints GET /admin/bot-activity and POST /admin/bot-activity/unban (RBAC admin gate, IP validated). Unban is activity-logged with the admin username. - Bot Activity tab: currently-banned table with Unban, plus a recent-events feed, following the existing admin table patterns. - Tests for the buffer, listState, and unban (guard lets an unbanned IP back through). README updated. Read + emergency-unban only — no ban-add or weight-editing surface. Buffer is in-memory, matching the store; not persisted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
289 lines
10 KiB
JavaScript
289 lines
10 KiB
JavaScript
// ── Bot / scanner scoring and IP banning ──────────────────────────────────
|
|
//
|
|
// This app has no WordPress, Drupal, phpMyAdmin, .env exposure, etc. Any hit on
|
|
// those well-known scanner targets is therefore pure bot signal. Two separate
|
|
// jobs happen here, and it matters that they stay separate:
|
|
//
|
|
// 1. Junk-path 404: every hit to a known scanner path is 404'd immediately and
|
|
// UNCONDITIONALLY — independent of any IP score or ban state. Much of the
|
|
// scanning traffic here comes through Cloudflare edge ranges (104.23.x,
|
|
// 162.158.x, 172.68-71.x), i.e. a large rotating pool of source IPs, so we
|
|
// must never give a fresh IP a "free pass" on a junk path while its score
|
|
// warms up. The 404 is the primary, always-on defense.
|
|
//
|
|
// 2. Per-IP temp-ban: scoring accumulates per IP and, past a threshold, bans
|
|
// that IP from ALL routes for a while. This exists mainly to protect the
|
|
// real /admin login from credential stuffing once a scanner pivots from
|
|
// probing junk to attacking login — NOT to stop the scanning itself (fresh
|
|
// IPs are cheap for this actor, so an IP ban can't win that race). Because
|
|
// of that we bias toward a slightly LOWER threshold rather than a high one
|
|
// tuned to avoid false positives from a small/stable IP pool.
|
|
//
|
|
// State is a single-instance in-memory Map — fine for one Node process. Scores
|
|
// decay after a quiet period so a transient burst does not ban an IP forever.
|
|
//
|
|
// All time-based logic takes an optional `now` argument (defaulting to
|
|
// Date.now()) so the decay/ban windows are deterministic to test.
|
|
|
|
const log = require('../utils/logger')('botscore')
|
|
|
|
// Score at/above which an IP is banned from ALL routes. Deliberately on the low
|
|
// side (see job #2 above): fresh IPs are cheap for this actor, so we'd rather
|
|
// ban an attacking IP a little early than tune high to protect a stable pool.
|
|
const BAN_THRESHOLD = 80
|
|
// How long a ban lasts.
|
|
const BAN_MS = 60 * 60 * 1000 // 1 hour
|
|
// Quiet period after which a non-banned IP's accumulated score resets to 0.
|
|
const QUIET_MS = 30 * 60 * 1000 // 30 min
|
|
// Points added for a failed /admin login (wired in from the auth controller).
|
|
const LOGIN_FAIL_POINTS = 34
|
|
// Points for a tripped honeypot — an unambiguous bot, ban on sight.
|
|
const HONEYPOT_POINTS = BAN_THRESHOLD
|
|
|
|
// Weighted scanner paths, matched as a prefix against the lowercased request
|
|
// path, FIRST match wins — so more specific paths must precede their prefixes
|
|
// (e.g. /wp-admin/install.php before /wp-admin). Heavier weights = more damning.
|
|
//
|
|
// /wp-admin/install.php is by far the most-hit junk path in the real Pangolin
|
|
// access logs (from many rotating IPs), so it carries the single highest weight:
|
|
// a lone hit exceeds the ban threshold on its own — effectively a 1-hit ban —
|
|
// and outweighs every other individual path.
|
|
const PATH_WEIGHTS = [
|
|
['/wp-admin/install.php', 200], // top offender in prod logs — near 1-hit ban
|
|
['/.env', 100],
|
|
['/.git', 100],
|
|
['/.aws', 100],
|
|
['/wp-login.php', 100],
|
|
['/xmlrpc.php', 100],
|
|
['/wp-admin', 50],
|
|
['/administrator', 50],
|
|
['/phpmyadmin', 50],
|
|
['/mysql', 50],
|
|
['/wp-content', 40],
|
|
['/wp-includes', 40],
|
|
['/wp-json', 40],
|
|
['/user/login', 40], // Drupal
|
|
['/console', 40],
|
|
['/actuator', 40], // Spring Boot
|
|
['/vendor/phpunit', 100],
|
|
['/cgi-bin', 40],
|
|
]
|
|
|
|
// ip -> { score, lastSeen, bannedUntil }
|
|
const store = new Map()
|
|
|
|
// ── Recent-events ring buffer ────────────────────────────────────────────────
|
|
// A bounded, most-recent-first log of notable events (scanner hit, ban, honeypot,
|
|
// login failure) so admins can see recent activity without tailing container
|
|
// logs. In-memory only, matching the store — not persisted. Oldest entries fall
|
|
// off once EVENT_CAP is reached. Each event: { ts, ip, type, path, points,
|
|
// score, reason }.
|
|
const EVENT_CAP = 300
|
|
const events = []
|
|
|
|
function recordEvent(evt) {
|
|
events.push(evt)
|
|
if (events.length > EVENT_CAP) events.shift()
|
|
}
|
|
|
|
// Most-recent-first slice of the event buffer (default: whole buffer, capped).
|
|
function recentEvents(limit = EVENT_CAP) {
|
|
const n = Math.min(limit, events.length)
|
|
const out = new Array(n)
|
|
for (let i = 0; i < n; i++) out[i] = events[events.length - 1 - i]
|
|
return out
|
|
}
|
|
|
|
// Return the scanner weight for a request path (0 if it is a legitimate path).
|
|
function scoreForPath(pathname) {
|
|
const p = String(pathname || '').toLowerCase()
|
|
for (const [prefix, weight] of PATH_WEIGHTS) {
|
|
if (p === prefix || p.startsWith(prefix)) return weight
|
|
}
|
|
return 0
|
|
}
|
|
|
|
function getEntry(ip) {
|
|
let e = store.get(ip)
|
|
if (!e) {
|
|
e = { score: 0, lastSeen: 0, bannedUntil: 0 }
|
|
store.set(ip, e)
|
|
}
|
|
return e
|
|
}
|
|
|
|
function isBanned(ip, now = Date.now()) {
|
|
const e = store.get(ip)
|
|
return Boolean(e && e.bannedUntil > now)
|
|
}
|
|
|
|
// Add points to an IP's score. Applies quiet-period decay first, then bans the
|
|
// IP if the new score crosses the threshold. Returns the updated entry.
|
|
//
|
|
// `path` is the request path when the points came from a scanned URL (else null),
|
|
// recorded into the event buffer alongside the resulting score.
|
|
function addScore(ip, points, now = Date.now(), reason = 'scan', path = null) {
|
|
const e = getEntry(ip)
|
|
// Decay: if the IP has been quiet longer than QUIET_MS (and is not currently
|
|
// banned), forget its accumulated score before adding the new hit.
|
|
if (e.bannedUntil <= now && e.lastSeen && now - e.lastSeen > QUIET_MS) {
|
|
e.score = 0
|
|
}
|
|
e.score += points
|
|
e.lastSeen = now
|
|
const justBanned = e.score >= BAN_THRESHOLD && e.bannedUntil <= now
|
|
if (justBanned) {
|
|
e.bannedUntil = now + BAN_MS
|
|
log.warn('IP banned', { ip, score: e.score, reason, banMs: BAN_MS })
|
|
}
|
|
// Record the scoring event, then a distinct ban event if this hit crossed the
|
|
// threshold — so the feed shows both "why" (the hit) and the resulting ban.
|
|
recordEvent({ ts: now, ip, type: reason, path, points, score: e.score, reason })
|
|
if (justBanned) {
|
|
recordEvent({ ts: now, ip, type: 'ban', path, points: 0, score: e.score, reason })
|
|
}
|
|
return e
|
|
}
|
|
|
|
// Points for a failed real login — called from the auth controller.
|
|
function recordLoginFailure(ip, now = Date.now()) {
|
|
return addScore(ip, LOGIN_FAIL_POINTS, now, 'login-fail')
|
|
}
|
|
|
|
// A tripped honeypot: instant ban-worthy score.
|
|
function recordHoneypot(ip, now = Date.now()) {
|
|
return addScore(ip, HONEYPOT_POINTS, now, 'honeypot')
|
|
}
|
|
|
|
// Early middleware: mounted before routing so banned IPs never reach a real
|
|
// handler. Everything here 404s (never 403) so we never confirm a path or a ban.
|
|
function guard(req, res, next) {
|
|
const ip = req.ip
|
|
const now = Date.now()
|
|
|
|
// (1) Known junk/scanner path → 404 FIRST, unconditionally. This is evaluated
|
|
// and returned before any ban check, so the 404 is fully independent of this
|
|
// IP's score/ban state: a scanner cycling through fresh Cloudflare IPs gets no
|
|
// free pass on a junk path. Scoring still runs (it accrues toward a /admin ban
|
|
// if the IP is reused), but the 404 does not depend on it.
|
|
const points = scoreForPath(req.path)
|
|
if (points > 0) {
|
|
const e = addScore(ip, points, now, 'scan', req.path)
|
|
log.warn('scanner path hit', { ip, path: req.path, points, score: e.score })
|
|
return notFound(res)
|
|
}
|
|
|
|
// (2) Non-junk path → block only if this IP is already banned (the
|
|
// credential-stuffing guard for the real /admin login), else let it through.
|
|
if (isBanned(ip, now)) {
|
|
log.debug('blocked banned IP', { ip, path: req.path })
|
|
return notFound(res)
|
|
}
|
|
|
|
return next()
|
|
}
|
|
|
|
// Uniform 404 — mirrors the SPA/API "Not found" shape without leaking anything.
|
|
function notFound(res) {
|
|
return res.status(404).json({ message: 'Not found' })
|
|
}
|
|
|
|
// ── Cleanup sweep ──────────────────────────────────────────────────────────
|
|
// Every unique IP that hits a scored path adds an entry and nothing else evicts
|
|
// it, so the store would grow unbounded. Periodically drop entries that are no
|
|
// longer meaningful: NOT banned and quiet longer than QUIET_MS (their score
|
|
// would already reset to 0 on next touch anyway). Banned entries, and entries
|
|
// still inside their quiet decay window, are left untouched. Returns the count
|
|
// removed. The eviction age reuses QUIET_MS; SWEEP_INTERVAL_MS is only cadence.
|
|
const SWEEP_INTERVAL_MS = 10 * 60 * 1000 // 10 min
|
|
|
|
function sweep(now = Date.now()) {
|
|
let removed = 0
|
|
for (const [ip, e] of store) {
|
|
if (e.bannedUntil <= now && now - e.lastSeen > QUIET_MS) {
|
|
store.delete(ip)
|
|
removed++
|
|
}
|
|
}
|
|
if (removed > 0) log.debug('store sweep', { removed, remaining: store.size })
|
|
return removed
|
|
}
|
|
|
|
let sweepTimer = null
|
|
|
|
function startSweeper() {
|
|
if (sweepTimer) return sweepTimer
|
|
sweepTimer = setInterval(() => sweep(), SWEEP_INTERVAL_MS)
|
|
// Never let the sweep timer alone keep the event loop alive (tests, shutdown).
|
|
if (sweepTimer.unref) sweepTimer.unref()
|
|
return sweepTimer
|
|
}
|
|
|
|
function stopSweeper() {
|
|
if (sweepTimer) {
|
|
clearInterval(sweepTimer)
|
|
sweepTimer = null
|
|
}
|
|
}
|
|
|
|
// Start sweeping on load — this is a single long-lived process.
|
|
startSweeper()
|
|
|
|
// Snapshot of every IP currently in the store, for the admin view: score, ban
|
|
// state, when the ban lifts, and last-seen. Most-recently-seen first.
|
|
function listState(now = Date.now()) {
|
|
const out = []
|
|
for (const [ip, e] of store) {
|
|
out.push({
|
|
ip,
|
|
score: e.score,
|
|
banned: e.bannedUntil > now,
|
|
bannedUntil: e.bannedUntil || 0,
|
|
lastSeen: e.lastSeen || 0,
|
|
})
|
|
}
|
|
out.sort((a, b) => b.lastSeen - a.lastSeen)
|
|
return out
|
|
}
|
|
|
|
// Manually clear a single IP's entry (admin emergency unban / false positive).
|
|
// Fully removes it from the store, so it is neither banned nor carrying score.
|
|
// Returns true if an entry existed and was removed.
|
|
function unban(ip) {
|
|
return store.delete(ip)
|
|
}
|
|
|
|
// Test/ops helpers.
|
|
function _reset() {
|
|
store.clear()
|
|
events.length = 0
|
|
}
|
|
function _snapshot(ip) {
|
|
const e = store.get(ip)
|
|
return e ? { ...e } : null
|
|
}
|
|
|
|
module.exports = {
|
|
guard,
|
|
scoreForPath,
|
|
addScore,
|
|
isBanned,
|
|
recordLoginFailure,
|
|
recordHoneypot,
|
|
recentEvents,
|
|
listState,
|
|
unban,
|
|
sweep,
|
|
startSweeper,
|
|
stopSweeper,
|
|
_reset,
|
|
_snapshot,
|
|
// Exported for tests / tuning.
|
|
BAN_THRESHOLD,
|
|
BAN_MS,
|
|
QUIET_MS,
|
|
LOGIN_FAIL_POINTS,
|
|
HONEYPOT_POINTS,
|
|
SWEEP_INTERVAL_MS,
|
|
}
|