Add Bot Activity admin panel: banned-IP view + recent events + emergency unban
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>
This commit is contained in:
@@ -72,6 +72,28 @@ const PATH_WEIGHTS = [
|
||||
// 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()
|
||||
@@ -97,7 +119,10 @@ function isBanned(ip, now = Date.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.
|
||||
function addScore(ip, points, now = Date.now(), reason = 'scan') {
|
||||
//
|
||||
// `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.
|
||||
@@ -106,10 +131,17 @@ function addScore(ip, points, now = Date.now(), reason = 'scan') {
|
||||
}
|
||||
e.score += points
|
||||
e.lastSeen = now
|
||||
if (e.score >= BAN_THRESHOLD && e.bannedUntil <= 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
|
||||
}
|
||||
|
||||
@@ -136,7 +168,7 @@ function guard(req, res, next) {
|
||||
// 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')
|
||||
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)
|
||||
}
|
||||
@@ -197,9 +229,34 @@ function stopSweeper() {
|
||||
// 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)
|
||||
@@ -213,6 +270,9 @@ module.exports = {
|
||||
isBanned,
|
||||
recordLoginFailure,
|
||||
recordHoneypot,
|
||||
recentEvents,
|
||||
listState,
|
||||
unban,
|
||||
sweep,
|
||||
startSweeper,
|
||||
stopSweeper,
|
||||
|
||||
Reference in New Issue
Block a user