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:
2026-07-03 02:31:25 -05:00
parent 58852a5078
commit 870971fc12
9 changed files with 341 additions and 6 deletions

View File

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

View File

@@ -7,6 +7,7 @@ const { body, param } = require('express-validator')
const ctrl = require('./admin.controller')
const account = require('./account.controller')
const botActivity = require('./botActivity.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
@@ -178,6 +179,18 @@ adminRouter.put('/settings', adminOnly, ctrl.updateSettings)
// ── Activity log ──────────────────────────────────────────────────────
adminRouter.get('/activity', ctrl.listActivity)
// ── Bot activity (admin only) ─────────────────────────────────────────
// Read-only view of the botScore middleware's in-memory scoring/ban state and
// recent events, plus an emergency unban for false positives.
adminRouter.get('/bot-activity', adminOnly, botActivity.getBotActivity)
adminRouter.post(
'/bot-activity/unban',
adminOnly,
body('ip').isIP(),
validate,
botActivity.unbanIp,
)
// ── User management (admin only) ──────────────────────────────────────
adminRouter.use('/users', adminOnly)
adminRouter.get('/users', ctrl.listUsers)

View File

@@ -0,0 +1,31 @@
// Bot-scoring / IP-ban visibility for admins. Read-only view of the botScore
// middleware's in-memory state plus a recent-events feed, and a single mutating
// action — an emergency unban for false positives. Mounted behind the admin-only
// RBAC gate (see admin.routes.js). This is visibility + emergency unban only;
// there is deliberately no way to add a ban or change scoring weights from here.
const botScore = require('../../../middleware/botScore')
const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('botactivity')
// Current store state (all scored IPs, banned or not) plus the recent-events
// buffer, most-recent-first. Both are in-memory and reset on process restart.
async function getBotActivity(req, res) {
return res.json({
ips: botScore.listState(),
events: botScore.recentEvents(),
})
}
// Emergency unban: clear a single IP's entry so it is no longer banned or
// carrying score. A real administrative action — logged with the admin user.
async function unbanIp(req, res) {
const ip = req.body.ip
const removed = botScore.unban(ip)
await activity.log({ req, action: 'botscore.unban', detail: { ip, removed } })
log.info('IP unbanned by admin', { ip, admin: req.user.username, removed })
return res.json({ ip, removed })
}
module.exports = { getBotActivity, unbanIp }