UNPKG

@kya-os/agentshield-express

Version:

Express.js middleware for AgentShield AI agent detection

704 lines (696 loc) 23.9 kB
import { AgentDetector } from '@kya-os/agentshield'; // src/middleware.ts function createAgentShieldMiddleware(config = {}) { const detector = new AgentDetector(config); const { onAgentDetected = "log", onDetection, skipPaths = [], blockedResponse = { status: 403, message: "Access denied: Automated agent detected", headers: { "Content-Type": "application/json" } } } = config; return async (req, res, next) => { try { const shouldSkip = skipPaths.some((pattern) => { if (typeof pattern === "string") { return req.path === pattern; } return pattern.test(req.path); }); if (shouldSkip) { req.agentShield = { result: { isAgent: false, confidence: 0, confidenceLevel: "low", reasons: ["Path skipped"], timestamp: /* @__PURE__ */ new Date() }, skipped: true }; return next(); } const context = { userAgent: req.get("User-Agent"), ipAddress: req.ip || req.connection.remoteAddress, headers: req.headers, url: req.url, method: req.method, body: req.body, timestamp: /* @__PURE__ */ new Date() }; const result = await detector.analyze(context); req.agentShield = { result, skipped: false }; if (result.isAgent && result.confidence >= (config.confidenceThreshold ?? 0.7)) { if (onDetection) { await onDetection(req, res, result); } switch (onAgentDetected) { case "block": if (blockedResponse.headers) { Object.entries(blockedResponse.headers).forEach( ([key, value]) => { res.setHeader(key, value); } ); } res.status(blockedResponse.status).json({ error: blockedResponse.message, detected: true, confidence: result.confidence, timestamp: result.timestamp }); return; case "log": console.warn("AgentShield: Agent detected", { ipAddress: context.ipAddress, userAgent: context.userAgent, confidence: result.confidence, reasons: result.reasons }); break; case "allow": default: break; } } next(); } catch (error) { console.error("AgentShield middleware error:", error); next(); } }; } function agentShield(config = {}) { return createAgentShieldMiddleware(config); } // src/session-helper.ts var ExpressSessionTracker = class { cookieName = "__agentshield_session"; /** * Check for existing session from Express request */ check(req) { try { if (req.cookies && req.cookies[this.cookieName]) { try { const decoded = Buffer.from( req.cookies[this.cookieName], "base64" ).toString(); const session = JSON.parse(decoded); if (session.expires > Date.now()) { return session; } } catch { } } const sessionHeader = req.get("X-AgentShield-Session"); if (sessionHeader) { try { const session = JSON.parse( Buffer.from(sessionHeader, "base64").toString() ); if (session.expires > Date.now()) { return session; } } catch { } } return null; } catch { return null; } } /** * Track a new session in Express response */ track(res, result) { try { if (!result.isAgent) return; const session = { id: this.generateId(), agent: result.detectedAgent?.name || "unknown", confidence: result.confidence, detectedAt: Date.now(), expires: Date.now() + 36e5 // 1 hour }; const encoded = Buffer.from(JSON.stringify(session)).toString("base64"); if (res.cookie) { res.cookie(this.cookieName, encoded, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 36e5 // 1 hour in milliseconds }); } res.setHeader("X-AgentShield-Session", encoded); res.setHeader("X-AgentShield-Session-Agent", session.agent); res.setHeader("X-AgentShield-Session-Id", session.id); } catch (error) { if (process.env.DEBUG_AGENTSHIELD) { console.warn("AgentShield: Failed to track session:", error); } } } /** * Generate a simple ID without crypto dependency */ generateId() { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } }; function withSessionTracking(middleware, config) { if (!config?.enabled) { return middleware; } const tracker = new ExpressSessionTracker(); return async function(req, res, next) { const session = tracker.check(req); if (session) { req.agentShield = { ...req.agentShield, session, result: { isAgent: true, confidence: session.confidence, detectedAgent: { name: session.agent }, timestamp: /* @__PURE__ */ new Date(), verificationMethod: "session" } }; res.setHeader("X-AgentShield-Detected", "true"); res.setHeader("X-AgentShield-Agent", session.agent); res.setHeader("X-AgentShield-Session", "continued"); } const originalEnd = res.end; res.end = function(...args) { const agentShield2 = req.agentShield; if (agentShield2?.result?.isAgent && !agentShield2.session) { tracker.track(res, agentShield2.result); } return originalEnd.apply(res, args); }; middleware(req, res, next); }; } // src/storage/memory-adapter.ts var MemoryStorageAdapter = class { events = /* @__PURE__ */ new Map(); sessions = /* @__PURE__ */ new Map(); eventTimeline = []; maxEventsPerSession = 100; maxTimelineEvents = 1e3; async storeEvent(event) { const sessionEvents = this.events.get(event.sessionId) || []; sessionEvents.push(event); if (sessionEvents.length > this.maxEventsPerSession) { sessionEvents.shift(); } this.events.set(event.sessionId, sessionEvents); this.eventTimeline.push(event); if (this.eventTimeline.length > this.maxTimelineEvents) { this.eventTimeline.shift(); } } async storeSession(session) { this.sessions.set(session.sessionId, session); } async getEvents(sessionId, limit) { const events = this.events.get(sessionId) || []; if (limit) { return events.slice(-limit); } return events; } async getSession(sessionId) { return this.sessions.get(sessionId) || null; } async getRecentEvents(limit = 50) { return this.eventTimeline.slice(-limit); } async getActiveSessions(limit = 50) { const sessions = Array.from(this.sessions.values()); sessions.sort((a, b) => { const timeA = new Date(a.lastSeen).getTime(); const timeB = new Date(b.lastSeen).getTime(); return timeB - timeA; }); return sessions.slice(0, limit); } async cleanup(before) { const cutoff = before.getTime(); for (const [sessionId, events] of this.events.entries()) { const filtered = events.filter( (e) => new Date(e.timestamp).getTime() >= cutoff ); if (filtered.length === 0) { this.events.delete(sessionId); } else { this.events.set(sessionId, filtered); } } for (const [sessionId, session] of this.sessions.entries()) { if (new Date(session.lastSeen).getTime() < cutoff) { this.sessions.delete(sessionId); } } this.eventTimeline = this.eventTimeline.filter( (e) => new Date(e.timestamp).getTime() >= cutoff ); } }; // src/storage/redis-adapter.ts var RedisStorageAdapter = class { constructor(redis, ttl = 86400) { this.redis = redis; this.ttl = ttl; } prefix = "agentshield:"; ttl; getKey(type, id) { return `${this.prefix}${type}:${id}`; } async storeEvent(event) { const eventKey = this.getKey("event", event.eventId); const sessionEventsKey = this.getKey("session_events", event.sessionId); const timelineKey = `${this.prefix}timeline`; await this.redis.set(eventKey, JSON.stringify(event), { ex: this.ttl }); const timestamp = new Date(event.timestamp).getTime(); await this.redis.zadd(sessionEventsKey, timestamp, event.eventId); await this.redis.expire(sessionEventsKey, this.ttl); await this.redis.zadd(timelineKey, timestamp, event.eventId); const timelineCutoff = Date.now() - this.ttl * 1e3; await this.redis.zadd(timelineKey, timelineCutoff, "cleanup"); } async storeSession(session) { const sessionKey = this.getKey("session", session.sessionId); const activeSessionsKey = `${this.prefix}active_sessions`; await this.redis.set(sessionKey, JSON.stringify(session), { ex: this.ttl }); const timestamp = new Date(session.lastSeen).getTime(); await this.redis.zadd(activeSessionsKey, timestamp, session.sessionId); } async getEvents(sessionId, limit) { const sessionEventsKey = this.getKey("session_events", sessionId); const eventIds = await this.redis.zrevrange( sessionEventsKey, 0, limit ? limit - 1 : -1 ); if (eventIds.length === 0) { return []; } const events = []; for (const eventId of eventIds) { const eventKey = this.getKey("event", eventId); const eventData = await this.redis.get(eventKey); if (eventData) { events.push(JSON.parse(eventData)); } } return events; } async getSession(sessionId) { const sessionKey = this.getKey("session", sessionId); const sessionData = await this.redis.get(sessionKey); if (!sessionData) { return null; } return JSON.parse(sessionData); } async getRecentEvents(limit = 50) { const timelineKey = `${this.prefix}timeline`; const eventIds = await this.redis.zrevrange(timelineKey, 0, limit - 1); if (eventIds.length === 0) { return []; } const events = []; for (const eventId of eventIds) { if (eventId === "cleanup") continue; const eventKey = this.getKey("event", eventId); const eventData = await this.redis.get(eventKey); if (eventData) { events.push(JSON.parse(eventData)); } } return events; } async getActiveSessions(limit = 50) { const activeSessionsKey = `${this.prefix}active_sessions`; const sessionIds = await this.redis.zrevrange(activeSessionsKey, 0, limit - 1); if (sessionIds.length === 0) { return []; } const sessions = []; for (const sessionId of sessionIds) { const sessionKey = this.getKey("session", sessionId); const sessionData = await this.redis.get(sessionKey); if (sessionData) { sessions.push(JSON.parse(sessionData)); } } return sessions; } async cleanup(before) { const cutoff = before.getTime(); const timelineKey = `${this.prefix}timeline`; const activeSessionsKey = `${this.prefix}active_sessions`; await this.redis.zadd(timelineKey, cutoff, "cleanup"); await this.redis.zadd(activeSessionsKey, cutoff, "cleanup"); } }; // src/storage/index.ts async function createStorageAdapter(config) { if (!config || config.type === "memory") { return new MemoryStorageAdapter(); } if (config.type === "custom" && config.custom) { return config.custom; } if (config.type === "redis" && config.redis) { try { const { Redis } = await import('@upstash/redis'); const redis = new Redis({ url: config.redis.url, token: config.redis.token }); return new RedisStorageAdapter(redis, config.ttl); } catch (error) { console.warn("[AgentShield] Failed to initialize Redis storage, falling back to memory:", error); return new MemoryStorageAdapter(); } } return new MemoryStorageAdapter(); } // src/enhanced-middleware.ts var SessionManager = class { sessionLastActivity = /* @__PURE__ */ new Map(); generateSessionId(ipAddress, userAgent) { const now = Date.now(); const timeWindow = Math.floor(now / (5 * 60 * 1e3)); const baseKey = `${ipAddress || "unknown"}:${userAgent || "unknown"}`; const windowKey = `${baseKey}:${timeWindow}`; const lastActivity = this.sessionLastActivity.get(windowKey); const shouldCreateNewSession = !lastActivity || now - lastActivity > 3e4; this.sessionLastActivity.set(windowKey, now); if (this.sessionLastActivity.size > 100) { const cutoff = now - 6e5; for (const [key, time] of this.sessionLastActivity.entries()) { if (time < cutoff) { this.sessionLastActivity.delete(key); } } } const data = shouldCreateNewSession ? `${windowKey}:${now}` : `${windowKey}:${lastActivity}`; let hash = 0; for (let i = 0; i < data.length; i++) { const char = data.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } return Math.abs(hash).toString(16).padStart(12, "0").substring(0, 12); } }; function getVerificationMethod(confidence, reasons = []) { if (reasons.some( (r) => r.includes("signature_agent") && !r.includes("signature_headers_present") )) { return "signature"; } if (confidence >= 0.85 && confidence < 1) { return "wasm-enhanced"; } return "pattern"; } function getWasmConfidenceBoost(baseConfidence, reasons = []) { if (reasons.some( (r) => r.includes("signature_agent") && !r.includes("signature_headers_present") )) { return 1; } if (baseConfidence >= 0.85) { return 0.95; } if (baseConfidence >= 0.7) { return Math.min(baseConfidence * 1.1, 0.9); } return baseConfidence; } function detectSuspiciousPatterns(req) { const headers = req.headers; const normalizedHeaders = {}; for (const [key, value] of Object.entries(headers)) { normalizedHeaders[key.toLowerCase()] = value; } const userAgent = normalizedHeaders["user-agent"] || ""; const hasChrome = userAgent.toLowerCase().includes("chrome"); const hasFirefox = userAgent.toLowerCase().includes("firefox"); const hasSafari = userAgent.toLowerCase().includes("safari"); const hasBrowserUA = hasChrome || hasFirefox || hasSafari; const hasSecChUa = !!normalizedHeaders["sec-ch-ua"]; const hasSecFetchHeaders = !!normalizedHeaders["sec-fetch-site"]; const hasCookies = !!normalizedHeaders["cookie"]; const hasAcceptLanguage = !!normalizedHeaders["accept-language"]; const reasons = []; let confidence = 0; let detectedAgent; if (hasBrowserUA) { const missingHeaders = []; if (!hasSecChUa && hasChrome) missingHeaders.push("sec-ch-ua"); if (!hasSecFetchHeaders) missingHeaders.push("sec-fetch-*"); if (!hasAcceptLanguage) missingHeaders.push("accept-language"); if (!hasCookies) missingHeaders.push("cookies"); if (missingHeaders.length >= 2) { confidence = 0.85; reasons.push("browser_ua_missing_headers"); reasons.push(`missing:${missingHeaders.join(",")}`); if (hasChrome && !hasSecChUa) { detectedAgent = { type: "perplexity", name: "Perplexity" }; } else { detectedAgent = { type: "unknown", name: "Unknown AI Agent" }; } } } const xForwardedFor = normalizedHeaders["x-forwarded-for"]; const ip = req.ip || xForwardedFor?.split(",")[0]; if (ip) { const cloudProviders = { aws: ["54.", "52.", "35.", "18.", "3."], gcp: ["35.", "34.", "104.", "107.", "108."], azure: ["13.", "20.", "40.", "52.", "104."] }; for (const [provider, prefixes] of Object.entries(cloudProviders)) { if (prefixes.some((prefix) => ip.startsWith(prefix))) { confidence = Math.max(confidence, 0.4); reasons.push(`cloud_provider:${provider}`); break; } } } const headerCount = Object.keys(normalizedHeaders).length; if (headerCount < 8 && hasBrowserUA) { confidence = Math.max(confidence, 0.6); reasons.push(`minimal_headers:${headerCount}`); } if (reasons.length >= 3) { confidence = Math.min(confidence * 1.15, 0.95); } return { confidence, reasons, ...detectedAgent && { detectedAgent } }; } function createEnhancedAgentShieldMiddleware(config = {}) { let storageAdapter = null; let storageInitPromise = null; const getStorage = async () => { if (storageAdapter) return storageAdapter; if (storageInitPromise) return storageInitPromise; storageInitPromise = createStorageAdapter(config.storage).then((adapter) => { storageAdapter = adapter; return adapter; }); return storageInitPromise; }; let detector = null; let detectorInitPromise = null; const getDetector = async () => { if (detector) return detector; if (detectorInitPromise) { await detectorInitPromise; return detector; } detectorInitPromise = (async () => { detector = new AgentDetector(); })(); await detectorInitPromise; return detector; }; const sessionManager = new SessionManager(); const sessionTrackingEnabled = config.sessionTracking?.enabled !== false; return async (req, res, next) => { const pathname = req.path; if (config.skipPaths?.some((path) => pathname.startsWith(path))) { return next(); } const userAgent = req.get("user-agent"); const ipAddress = req.ip || req.get("x-forwarded-for") || req.socket.remoteAddress; const context = { userAgent: userAgent || "", ipAddress: ipAddress || "", headers: req.headers, url: req.originalUrl, method: req.method, timestamp: /* @__PURE__ */ new Date() }; try { const activeDetector = await getDetector(); let result = await activeDetector.analyze(context); if (!result.isAgent || result.confidence < 0.3) { const patternResult = detectSuspiciousPatterns(req); if (patternResult.confidence > result.confidence) { result = { ...result, isAgent: patternResult.confidence > 0.3, confidence: patternResult.confidence, reasons: patternResult.reasons, detectedAgent: patternResult.detectedAgent, verificationMethod: "pattern", confidenceLevel: patternResult.confidence >= 0.8 ? "high" : patternResult.confidence >= 0.5 ? "medium" : "low" }; } } let finalConfidence = result.confidence; let verificationMethod = result.verificationMethod || "pattern"; if (result.isAgent) { const reasons = result.reasons || []; if (config.enableWasm !== false && finalConfidence >= 0.85 && finalConfidence < 1) { finalConfidence = getWasmConfidenceBoost(result.confidence, reasons); verificationMethod = getVerificationMethod(finalConfidence, reasons); } } req.agentShield = { detected: result.isAgent, confidence: finalConfidence, agent: result.detectedAgent, verificationMethod }; if (result.isAgent && finalConfidence >= (config.confidenceThreshold ?? 0.7)) { if (sessionTrackingEnabled) { const storage = await getStorage(); const sessionId = sessionManager.generateSessionId(ipAddress || void 0, userAgent || void 0); const event = { eventId: `agent_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`, sessionId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), agentType: result.detectedAgent?.type || "unknown", agentName: result.detectedAgent?.name || "Unknown", confidence: finalConfidence, path: req.originalUrl, ...userAgent && { userAgent }, ...ipAddress && { ipAddress }, method: req.method, detectionReasons: result.reasons || [], verificationMethod, detectionDetails: { patterns: result.detectedAgent?.patterns, behaviors: result.detectedAgent?.behaviors, fingerprintMatches: result.detectedAgent?.fingerprints } }; try { await storage.storeEvent(event); let session = await storage.getSession(sessionId); if (session) { session.lastSeen = event.timestamp; session.eventCount++; if (!session.paths.includes(req.originalUrl)) { session.paths.push(req.originalUrl); } session.averageConfidence = (session.averageConfidence * (session.eventCount - 1) + finalConfidence) / session.eventCount; if (!session.verificationMethods.includes(verificationMethod)) { session.verificationMethods.push(verificationMethod); } } else { session = { sessionId, ...ipAddress && { ipAddress }, ...userAgent && { userAgent }, agentType: result.detectedAgent?.type || "unknown", agentName: result.detectedAgent?.name || "Unknown", firstSeen: event.timestamp, lastSeen: event.timestamp, eventCount: 1, paths: [req.originalUrl], averageConfidence: finalConfidence, verificationMethods: [verificationMethod] }; } if (session) { await storage.storeSession(session); } } catch (error) { console.error("[AgentShield] Failed to store event:", error); } } if (config.onDetection) { await config.onDetection(result, req); } res.setHeader("x-agentshield-detected", "true"); res.setHeader("x-agentshield-confidence", String(Math.round(finalConfidence * 100))); res.setHeader("x-agentshield-agent", result.detectedAgent?.name || "Unknown"); res.setHeader("x-agentshield-verification", verificationMethod); if (finalConfidence > 0.9) { res.setHeader("x-ai-visitor", "true"); res.setHeader("x-ai-confidence", finalConfidence.toString()); res.setHeader("x-ai-verification", verificationMethod); } switch (config.onAgentDetected) { case "block": { const { status = 403, message = "Access denied: AI agent detected" } = config.blockedResponse || {}; res.status(status).json({ error: message, detected: true, confidence: finalConfidence }); return; } case "log": console.log(`[AgentShield] \u{1F916} AI Agent detected (${verificationMethod}):`, { agent: result.detectedAgent?.name, confidence: `${(finalConfidence * 100).toFixed(0)}%`, path: req.originalUrl, verification: verificationMethod }); break; case "allow": default: break; } } next(); } catch (error) { console.error("[AgentShield] Error during detection:", error); next(); } }; } // src/index.ts var VERSION = "0.1.0"; /** * @fileoverview AgentShield Express Middleware * @version 0.1.0 * @license MIT OR Apache-2.0 */ export { ExpressSessionTracker, MemoryStorageAdapter, RedisStorageAdapter, VERSION, agentShield, createAgentShieldMiddleware, createEnhancedAgentShieldMiddleware, createStorageAdapter, withSessionTracking }; //# sourceMappingURL=index.mjs.map //# sourceMappingURL=index.mjs.map