@kya-os/agentshield-express
Version:
Express.js middleware for AgentShield AI agent detection
714 lines (705 loc) • 24.3 kB
JavaScript
'use strict';
var agentshield = require('@kya-os/agentshield');
// src/middleware.ts
function createAgentShieldMiddleware(config = {}) {
const detector = new agentshield.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 agentshield.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
*/
exports.ExpressSessionTracker = ExpressSessionTracker;
exports.MemoryStorageAdapter = MemoryStorageAdapter;
exports.RedisStorageAdapter = RedisStorageAdapter;
exports.VERSION = VERSION;
exports.agentShield = agentShield;
exports.createAgentShieldMiddleware = createAgentShieldMiddleware;
exports.createEnhancedAgentShieldMiddleware = createEnhancedAgentShieldMiddleware;
exports.createStorageAdapter = createStorageAdapter;
exports.withSessionTracking = withSessionTracking;
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map