From 6aae3eae5e5c183b1ea56fa29097c506ef58099d Mon Sep 17 00:00:00 2001 From: Adebanjo Date: Sun, 7 Jun 2026 21:25:53 +0100 Subject: [PATCH] feat: block and ban vulnerability scanners probing for secrets --- agent-node/src/scannerBlock.js | 76 ++++++++++++++++++++++++++++++++++ agent-node/src/server.js | 4 ++ 2 files changed, 80 insertions(+) create mode 100644 agent-node/src/scannerBlock.js diff --git a/agent-node/src/scannerBlock.js b/agent-node/src/scannerBlock.js new file mode 100644 index 0000000..8a56896 --- /dev/null +++ b/agent-node/src/scannerBlock.js @@ -0,0 +1,76 @@ +/** + * scannerBlock.js + * Drops automated vulnerability scanners and bans their IP. + * + * Bots constantly probe public servers for leaked secrets and known exploits + * (/.env, /.aws/credentials, /wp-login.php, …). The first time an IP requests a + * path that no legitimate client would ever hit, we: + * 1. ban that IP for BAN_MS (default 24h), and + * 2. respond 404 (reveals nothing about what exists). + * Every subsequent request from a banned IP is dropped immediately with 403, + * before routing, logging, or any DB/chain work. + * + * Mounted FIRST in the middleware chain so banned traffic costs almost nothing. + */ + +const BAN_MS = parseInt(process.env.SCANNER_BAN_MS ?? String(24 * 60 * 60 * 1000), 10); +const MAX_TRACKED = 50_000; // hard cap so the ban map can't grow unbounded + +// ip -> unix ms when the ban expires +const _banned = new Map(); + +// Paths that are part of OUR surface and must never be flagged. +function isOwnSurface(path) { + return ( + path === '/' || + path === '/health' || + path === '/favicon.ico' || + path.startsWith('/api/') || + path.startsWith('/.well-known/') + ); +} + +// Probe signatures no real client of this API would ever request. +const SCANNER_PATTERNS = [ + /aws\.env|credentials|id_rsa|\.pem(\b|$)|\.pfx(\b|$)|\.p12(\b|$)/i, + /\.(env|sql|bak|backup|old|swp|ini|key)(\b|$|\.)/i, + /wp-admin|wp-login|wp-content|xmlrpc\.php|phpmyadmin|adminer|administrator/i, + /\/vendor\/|\/actuator|server-status|cgi-bin|\/telescope|_profiler|\/console(\b|\/)/i, + /config\.(php|json|ya?ml)|settings\.py|web\.config|\/\.git\//i, +]; + +function isScannerPath(rawPath) { + const path = (rawPath || '').toLowerCase(); + if (isOwnSurface(path)) return false; + // Any hidden dotfile at the root is a probe: /.env, /.git, /.aws/credentials … + if (/^\/\.[a-z]/.test(path)) return true; + return SCANNER_PATTERNS.some(re => re.test(path)); +} + +function sweepExpired(now) { + for (const [ip, expiry] of _banned) { + if (expiry <= now) _banned.delete(ip); + } +} + +export function scannerBlock(req, res, next) { + const now = Date.now(); + const ip = req.ip || req.socket?.remoteAddress || 'unknown'; + + // Already banned? Drop immediately (no logging, no routing). + const expiry = _banned.get(ip); + if (expiry !== undefined) { + if (expiry > now) return res.status(403).json({ error: 'Forbidden' }); + _banned.delete(ip); // ban lapsed + } + + if (isScannerPath(req.path)) { + // Avoid unbounded memory if we're being carpet-scanned from many IPs. + if (_banned.size >= MAX_TRACKED) sweepExpired(now); + _banned.set(ip, now + BAN_MS); + console.warn(`[scanner] Banned ${ip} for ${Math.round(BAN_MS / 3600000)}h — probed ${req.method} ${req.path}`); + return res.status(404).json({ error: 'Not found' }); + } + + next(); +} diff --git a/agent-node/src/server.js b/agent-node/src/server.js index 7af8968..d14194b 100644 --- a/agent-node/src/server.js +++ b/agent-node/src/server.js @@ -24,6 +24,7 @@ import publicApiRouter from './publicApi.js'; import { startStreamListeners } from './streamListener.js'; import { generateNonce, verifySiwe, issueJwt, verifyJwt, verifyJwtOrApiKey, verifyJwtOrApiKeyOrX402 } from './auth.js'; import { getInstallationToken } from './githubApp.js'; +import { scannerBlock } from './scannerBlock.js'; const app = express(); @@ -31,6 +32,9 @@ const app = express(); // Tell Express to trust the first proxy hop so rate-limit reads the real client IP. app.set('trust proxy', 1); +// Drop and ban automated vulnerability scanners before anything else runs. +app.use(scannerBlock); + // ─── Security headers (Helmet) ──────────────────────────────────────────────── // This is a public API server — disable policies that block cross-origin reads. app.use(helmet({