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:
@@ -167,6 +167,86 @@ test('guard: scanner junk path returns 404, legit path passes through', async ()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Recent-events buffer, state snapshot, and unban ─────────────────────────
|
||||
|
||||
test('recentEvents records scoring events most-recent-first, with a ban event', () => {
|
||||
const ip = '198.51.100.20'
|
||||
const now = 4_000_000
|
||||
botScore.addScore(ip, 50, now, 'scan', '/wp-admin') // below threshold
|
||||
botScore.addScore(ip, 50, now, 'scan', '/wp-content') // crosses → ban
|
||||
|
||||
const events = botScore.recentEvents()
|
||||
// Most-recent-first: the ban event (recorded last) is at the front, then the
|
||||
// second scan, then the first scan.
|
||||
assert.equal(events[0].type, 'ban')
|
||||
assert.equal(events[0].ip, ip)
|
||||
assert.equal(events[0].score, 100)
|
||||
assert.equal(events[1].type, 'scan')
|
||||
assert.equal(events[1].path, '/wp-content')
|
||||
assert.equal(events[1].points, 50)
|
||||
assert.equal(events[2].path, '/wp-admin')
|
||||
// login-fail / honeypot reasons are captured too.
|
||||
botScore.recordLoginFailure('198.51.100.21', now)
|
||||
assert.equal(botScore.recentEvents()[0].reason, 'login-fail')
|
||||
})
|
||||
|
||||
test('recentEvents is bounded (oldest events fall off)', () => {
|
||||
const now = 4_100_000
|
||||
// Push well past the cap from many distinct IPs (each hit is one event).
|
||||
for (let i = 0; i < 400; i++) {
|
||||
botScore.addScore(`10.9.${Math.floor(i / 256)}.${i % 256}`, 10, now, 'scan', '/x')
|
||||
}
|
||||
const events = botScore.recentEvents()
|
||||
assert.ok(events.length <= 300, `buffer should be capped, got ${events.length}`)
|
||||
})
|
||||
|
||||
test('listState reports every stored IP with its ban state', () => {
|
||||
const now = 4_200_000
|
||||
botScore.addScore('198.51.100.30', 40, now) // scored, not banned
|
||||
botScore.addScore('198.51.100.31', botScore.BAN_THRESHOLD, now) // banned
|
||||
|
||||
const state = botScore.listState(now)
|
||||
const byIp = Object.fromEntries(state.map((s) => [s.ip, s]))
|
||||
assert.equal(byIp['198.51.100.30'].banned, false)
|
||||
assert.equal(byIp['198.51.100.30'].score, 40)
|
||||
assert.equal(byIp['198.51.100.31'].banned, true)
|
||||
assert.ok(byIp['198.51.100.31'].bannedUntil > now)
|
||||
})
|
||||
|
||||
test('unban clears an IP entry and lifts its ban', () => {
|
||||
const ip = '198.51.100.40'
|
||||
const now = 4_300_000
|
||||
botScore.addScore(ip, botScore.BAN_THRESHOLD, now)
|
||||
assert.equal(botScore.isBanned(ip, now), true)
|
||||
|
||||
assert.equal(botScore.unban(ip), true) // existed → removed
|
||||
assert.equal(botScore.isBanned(ip, now), false)
|
||||
assert.equal(botScore._snapshot(ip), null)
|
||||
// Unbanning an unknown IP is a no-op returning false.
|
||||
assert.equal(botScore.unban('198.51.100.99'), false)
|
||||
})
|
||||
|
||||
test('guard: an unbanned IP can reach normal routes again', async () => {
|
||||
const ip = '203.0.113.50'
|
||||
const app = await startApp((a) => {
|
||||
a.set('trust proxy', 1)
|
||||
a.use(botScore.guard)
|
||||
a.get('/', (req, res) => res.json({ ok: true }))
|
||||
})
|
||||
try {
|
||||
await fetch(`${app.url}/.env`, { headers: { 'X-Forwarded-For': ip } }) // instant ban
|
||||
const blocked = await fetch(`${app.url}/`, { headers: { 'X-Forwarded-For': ip } })
|
||||
assert.equal(blocked.status, 404)
|
||||
|
||||
botScore.unban(ip) // admin clears the false positive
|
||||
|
||||
const ok = await fetch(`${app.url}/`, { headers: { 'X-Forwarded-For': ip } })
|
||||
assert.equal(ok.status, 200)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('guard: once banned, an IP gets 404 on ALL routes', async () => {
|
||||
const bannedIp = '203.0.113.30'
|
||||
const app = await startApp((a) => {
|
||||
|
||||
Reference in New Issue
Block a user