UNPKG

@openclueo/express-clueobots

Version:

Express.js middleware for ClueoBots AI Security - One line of code, full protection

226 lines (225 loc) 9.05 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.clueobots = clueobots; const clueobots_1 = __importDefault(require("@openclueo/clueobots")); /** * ClueoBots Express.js middleware for AI security protection * * @example * ```typescript * import express from 'express' * import { clueobots } from '@openclueo/express-clueobots' * * const app = express() * * // Protect all routes with one line * app.use(clueobots({ apiKey: 'your-api-key' })) * * // Your routes are now automatically protected * app.post('/api/chat', (req, res) => { * // req.body is already scanned for threats * res.json({ message: 'Safe to proceed!' }) * }) * ``` */ function clueobots(options = {}) { // Initialize ClueoBots client const client = new clueobots_1.default({ apiKey: options.apiKey || process.env.CLUEOBOTS_API_KEY || '', baseUrl: options.baseUrl }); // Default configuration const config = { blockThreshold: options.blockThreshold || 'medium', blockRequests: options.blockRequests !== false, debug: options.debug || false, skipContentTypes: options.skipContentTypes || ['image/*', 'video/*', 'audio/*'], maxBodySize: options.maxBodySize || 1048576, // 1MB ...options }; // Threat level to numeric score mapping const threatLevels = { low: 1, medium: 2, high: 3, critical: 4 }; // Path matching helper const shouldScanPath = (path) => { // Check exclude paths first if (config.excludePaths?.some(pattern => matchPath(path, pattern))) { return false; } // If specific paths are defined, only scan those if (config.paths?.length) { return config.paths.some(pattern => matchPath(path, pattern)); } // Default: scan all paths return true; }; // Simple path pattern matching const matchPath = (path, pattern) => { if (pattern.includes('*')) { const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); return regex.test(path); } return path.startsWith(pattern); }; // Content extraction helper const extractContentFromRequest = (req) => { const content = []; // Extract from request body if (req.body) { if (typeof req.body === 'string') { content.push(req.body); } else if (typeof req.body === 'object') { // Look for common fields that might contain user input const textFields = ['message', 'prompt', 'content', 'text', 'input', 'query', 'description']; textFields.forEach(field => { if (req.body[field] && typeof req.body[field] === 'string') { content.push(req.body[field]); } }); // Recursively extract text from nested objects content.push(...extractTextFromObject(req.body)); } } // Extract from query parameters Object.values(req.query).forEach(value => { if (typeof value === 'string') { content.push(value); } }); return content.filter(text => text && text.length > 0); }; // Helper to recursively extract text from objects const extractTextFromObject = (obj, maxDepth = 3) => { if (maxDepth <= 0) return []; const texts = []; for (const value of Object.values(obj)) { if (typeof value === 'string') { texts.push(value); } else if (typeof value === 'object' && value !== null) { texts.push(...extractTextFromObject(value, maxDepth - 1)); } } return texts; }; // Log helper const log = (...args) => { if (config.debug) { console.log('[ClueoBots]', ...args); } }; // Main middleware function return async (req, res, next) => { const startTime = Date.now(); try { // Skip if path doesn't match if (!shouldScanPath(req.path)) { log(`Skipping scan for path: ${req.path}`); return next(); } // Skip if content type should be skipped const contentType = req.get('content-type') || ''; if (config.skipContentTypes.some(skip => skip.includes('*') ? contentType.startsWith(skip.replace('*', '')) : contentType === skip)) { log(`Skipping scan for content type: ${contentType}`); return next(); } // Skip if body is too large const contentLength = parseInt(req.get('content-length') || '0'); if (contentLength > config.maxBodySize) { log(`Skipping scan - body too large: ${contentLength} bytes`); return next(); } // Extract content to scan const contentToScan = extractContentFromRequest(req); if (contentToScan.length === 0) { log(`No content to scan for ${req.method} ${req.path}`); return next(); } log(`Scanning ${contentToScan.length} pieces of content for ${req.method} ${req.path}`); // Scan all content pieces const scanPromises = contentToScan.map(content => client.scanContent({ content, type: 'text', context: `${req.method} ${req.path}` })); const scanResults = await Promise.all(scanPromises); // Find the highest threat level let highestThreat = null; for (const result of scanResults) { if (result.threat_detected) { const threat = { detected: result.threat_detected, threatType: result.threat_type, riskLevel: result.risk_level, confidence: result.confidence, explanation: result.explanation, suggestions: result.suggestions, scanId: result.scan_id, timestamp: result.timestamp }; if (!highestThreat || threatLevels[threat.riskLevel] > threatLevels[highestThreat.riskLevel]) { highestThreat = threat; } } } // Add scan info to request req.clueobots = { scanned: true, threat: highestThreat || undefined, scanTime: Date.now() - startTime }; // Handle threat detection if (highestThreat) { log(`🚨 Threat detected: ${highestThreat.threatType} (${highestThreat.riskLevel})`); // Call custom threat handler if provided if (config.onThreat) { config.onThreat(highestThreat, req, res); return; } // Check if we should block based on threshold const shouldBlock = config.blockRequests && threatLevels[highestThreat.riskLevel] >= threatLevels[config.blockThreshold]; if (shouldBlock) { log(`🛡️ Blocking request due to ${highestThreat.riskLevel} threat`); return res.status(400).json({ error: 'Security threat detected', details: { type: highestThreat.threatType, riskLevel: highestThreat.riskLevel, explanation: highestThreat.explanation, suggestions: highestThreat.suggestions, scanId: highestThreat.scanId }, timestamp: new Date().toISOString() }); } } else { log(`✅ No threats detected for ${req.method} ${req.path}`); } next(); } catch (error) { const scanError = error; log(`❌ Scan error: ${scanError.message}`); // Call custom error handler if provided if (config.onError) { config.onError(scanError, req, res); return; } // In case of error, log but don't block the request (fail open) console.error('[ClueoBots] Scan error:', scanError); req.clueobots = { scanned: false, scanTime: Date.now() - startTime }; next(); } }; } // Default export exports.default = clueobots;