UNPKG

@himorishige/noren-plugin-security

Version:

Security-focused plugin for Noren (JWT tokens, API keys, HTTP headers, cookies)

450 lines (449 loc) 20 kB
import { SECURITY_CONTEXTS, SECURITY_PATTERNS } from './patterns.js'; import { logSecurityError, parseCookieHeader, parseSetCookieHeader } from './utils.js'; // Pre-calculated confidence thresholds for performance const CONFIDENCE_THRESHOLDS = { jwt: { base: 0.7, valid: 0.99, invalid: 0.4 }, apiKey: { base: 0.5, max: 0.95, min: 0.1 }, uuid: 0.7, hex: 0.65, session: 0.85, auth: 0.9, url: { high: 0.8, medium: 0.65 }, client: { secret: 0.9, other: 0.75 }, cookie: { base: 0.7, setCookie: 0.72 }, // === NEW THRESHOLDS FOR ENHANCED DETECTION === github: 0.95, // GitHub tokens have very stable format aws: 0.92, // AWS access keys are highly structured google: 0.93, // Google API keys have consistent format stripe: 0.91, // Stripe keys are well-defined slack: 0.89, // Slack tokens have good structure sendgrid: 0.9, // SendGrid has unique dot-separated format openai: 0.88, // OpenAI keys are structured azure: 0.86, // Azure subscription keys webhook: 0.82, // Webhook URLs need domain validation oauth: 0.75, // OAuth tokens vary more in format }; /** * Context-based scoring system for enhanced detection accuracy */ function _calculateContextScore(text, match, baseConfidence) { const reasons = []; let score = 80; // Base score for pattern match // Get surrounding context (±50 chars) const start = Math.max(0, (match.index || 0) - 50); const end = Math.min(text.length, (match.index || 0) + match[0].length + 50); const context = text.slice(start, end).toLowerCase(); // Positive indicators if (/(?:secret|key|token|password|bearer|api|auth|credential)/.test(context)) { score += 20; reasons.push('security_context'); } if (/(?:\.env|config|settings|\.npmrc|\.git-credentials)/.test(context)) { score += 25; reasons.push('config_file_context'); } if (/(?:authorization:|bearer |api[_-]?key:|x-api-key:)/.test(context)) { score += 30; reasons.push('header_context'); } // Negative indicators if (/(?:test|sample|dummy|placeholder|example|your_|xxxxx)/.test(context)) { score -= 40; reasons.push('test_sample_context'); } if (/(?:tests?\/|examples?\/|docs?\/|readme|documentation)/.test(context)) { score -= 35; reasons.push('documentation_context'); } // Calculate final confidence const finalConfidence = Math.max(0.1, Math.min(0.99, baseConfidence * (score / 100))); return { confidence: finalConfidence, reasons: [...reasons, `score_${score}`], }; } /** * Unified token validation for API keys and similar patterns */ function validateToken(token, minLength = 16) { const reasons = []; let confidence = 0.7; // Higher base confidence for API keys with prefixes // Length check if (token.length >= 20) { confidence += 0.15; reasons.push('sufficient_length'); } else if (token.length < minLength) { confidence -= 0.1; reasons.push('short_length'); } // Character diversity (simplified check) const charTypes = [ /[a-z]/.test(token), /[A-Z]/.test(token), /\d/.test(token), /[_\-+/=]/.test(token), ].filter(Boolean).length; if (charTypes >= 3) { confidence += 0.1; reasons.push('diverse_characters'); } // Check for obvious patterns if (/(.{3,})\1/.test(token)) { confidence -= 0.2; reasons.push('repeating_pattern'); } return { confidence: Math.max(CONFIDENCE_THRESHOLDS.apiKey.min, Math.min(CONFIDENCE_THRESHOLDS.apiKey.max, confidence)), reasons, }; } /** * Fast JWT structure validation with proper confidence scoring */ function validateJwtStructure(token) { const parts = token.split('.'); if (parts.length !== 3) { return { valid: false, confidence: 0.2, reasons: ['invalid_part_count'] }; } const [header, payload, signature] = parts; const reasons = ['three_part_format']; let confidence = 0.7; // Base confidence // Length validation if (header.length < 10 || payload.length < 10 || signature.length < 8) { return { valid: false, confidence: CONFIDENCE_THRESHOLDS.jwt.invalid, reasons: ['invalid_part_lengths'], }; } // Base64URL character validation const base64UrlPattern = /^[A-Za-z0-9_-]+$/; if (!base64UrlPattern.test(header) || !base64UrlPattern.test(payload) || !base64UrlPattern.test(signature)) { return { valid: false, confidence: CONFIDENCE_THRESHOLDS.jwt.invalid, reasons: ['invalid_base64url'], }; } reasons.push('valid_base64url'); confidence += 0.1; // JWT pattern starts with eyJ (Base64 for {"typ":"JWT",...}) if (header.startsWith('eyJ')) { confidence += 0.15; reasons.push('jwt_header_pattern'); } // Additional length bonus for realistic tokens if (token.length >= 100 && token.length <= 2000) { confidence += 0.05; reasons.push('realistic_length'); } return { valid: true, confidence: Math.min(CONFIDENCE_THRESHOLDS.jwt.valid, confidence), reasons, }; } /** * Generic detector helper for pattern matching with context */ function createContextualDetector(id, pattern, type, risk, confidence, contexts, validator, priority) { return { id, ...(priority !== undefined && { priority }), match: ({ src, push, hasCtx, canPush }) => { if (contexts && !hasCtx(contexts)) return; for (const m of src.matchAll(pattern)) { if (m.index === undefined || !m[0]) continue; if (!canPush?.()) break; let finalConfidence = confidence; let reasons = [ type === 'sec_jwt_token' ? 'jwt_pattern_match' : `${type.replace('sec_', '')}_pattern_match`, ]; let features = {}; if (contexts) { reasons.push('context_required'); features = { requiresContext: true }; } if (validator) { const validation = validator(m[0]); if (!validation) continue; finalConfidence = validation.confidence; reasons = [...reasons, ...validation.reasons]; // Add specific features for JWT if (type === 'sec_jwt_token' && 'valid' in validation) { features = { hasJwtStructure: validation.valid, partCount: m[0].split('.').length, validationPassed: validation.valid, }; } } push({ type, start: m.index, end: m.index + m[0].length, value: m[0], risk, confidence: finalConfidence, reasons, features, }); } }, }; } /** * Specialized detector for URL parameters with risk assessment */ function createUrlTokenDetector() { return { id: 'security.url-tokens', match: ({ src, push, canPush }) => { for (const m of src.matchAll(SECURITY_PATTERNS.urlTokens)) { if (m.index === undefined || !m[1] || !m[2] || m[2].length < 8) continue; if (!canPush?.()) break; const paramName = m[1].toLowerCase(); const isSensitive = ['secret', 'refresh', 'token', 'access_token'].some((sensitive) => paramName.includes(sensitive) || paramName === sensitive); const risk = isSensitive ? 'high' : 'medium'; const confidence = isSensitive ? CONFIDENCE_THRESHOLDS.url.high : CONFIDENCE_THRESHOLDS.url.medium; push({ type: 'sec_url_token', start: m.index, end: m.index + m[0].length, value: m[0], risk, confidence, reasons: ['url_token_match', `param_${paramName}`, `risk_${risk}`], features: { parameterName: paramName, tokenLength: m[2].length, isSensitive, }, }); } }, }; } /** * Webhook URL detector with domain validation */ function createWebhookDetector() { return { id: 'security.webhook-urls', match: ({ src, push, canPush }) => { // Check each webhook pattern const webhookPatterns = [ { pattern: SECURITY_PATTERNS.webhookUrls.slack, service: 'slack' }, { pattern: SECURITY_PATTERNS.webhookUrls.discord, service: 'discord' }, { pattern: SECURITY_PATTERNS.webhookUrls.github, service: 'github' }, ]; for (const { pattern, service } of webhookPatterns) { for (const m of src.matchAll(pattern)) { if (m.index === undefined || !m[0]) continue; if (!canPush?.()) break; push({ type: 'sec_webhook_url', start: m.index, end: m.index + m[0].length, value: m[0], risk: 'high', confidence: CONFIDENCE_THRESHOLDS.webhook, reasons: ['webhook_url_match', `service_${service}`, 'domain_validated'], features: { service, urlLength: m[0].length, isWebhook: true, }, }); } } }, }; } /** * Signed URL detector with parameter validation */ function createSignedUrlDetector() { return { id: 'security.signed-urls', match: ({ src, push, canPush }) => { const signedUrlPatterns = [ { pattern: SECURITY_PATTERNS.signedUrls.awsS3, service: 'aws_s3', minParams: 2 }, { pattern: SECURITY_PATTERNS.signedUrls.googleCloud, service: 'google_cloud', minParams: 2, }, { pattern: SECURITY_PATTERNS.signedUrls.azureSas, service: 'azure_sas', minParams: 3 }, ]; for (const { pattern, service, minParams } of signedUrlPatterns) { const matches = [...src.matchAll(pattern)]; // Group matches by proximity to detect complete signed URLs const urlGroups = new Map(); for (const match of matches) { if (!match.index) continue; // Find nearby matches (within 500 characters) const groupKey = Math.floor(match.index / 500); if (!urlGroups.has(groupKey)) { urlGroups.set(groupKey, []); } urlGroups.get(groupKey)?.push(match); } // Check each group for sufficient parameters for (const group of urlGroups.values()) { if (group.length >= minParams && canPush?.()) { const firstMatch = group[0]; const lastMatch = group[group.length - 1]; if (firstMatch.index !== undefined && lastMatch.index !== undefined) { push({ type: 'sec_signed_url', start: firstMatch.index, end: lastMatch.index + lastMatch[0].length, value: src.slice(firstMatch.index, lastMatch.index + lastMatch[0].length), risk: 'high', confidence: CONFIDENCE_THRESHOLDS.webhook, reasons: ['signed_url_match', `service_${service}`, `param_count_${group.length}`], features: { service, parameterCount: group.length, isSignedUrl: true, }, }); } } } } }, }; } /** * Cookie header detector with parsing */ function createCookieDetector(setCookie = false) { const pattern = setCookie ? SECURITY_PATTERNS.setCookie : SECURITY_PATTERNS.cookie; const type = setCookie ? 'sec_set_cookie' : 'sec_cookie'; const parseFunc = setCookie ? parseSetCookieHeader : parseCookieHeader; const confidence = setCookie ? CONFIDENCE_THRESHOLDS.cookie.setCookie : CONFIDENCE_THRESHOLDS.cookie.base; return { id: `security.${setCookie ? 'set-' : ''}cookie`, match: ({ src, push, hasCtx, canPush }) => { if (!hasCtx([...SECURITY_CONTEXTS.cookie])) return; for (const m of src.matchAll(pattern)) { if (m.index === undefined || !m[1]) continue; if (!canPush?.()) break; try { if (setCookie) { const cookie = parseFunc(m[0]); if (!cookie || cookie.value.length < 8) continue; push({ type, start: m.index, end: m.index + m[0].length, value: m[0], risk: 'medium', confidence, reasons: ['set_cookie_match', 'context_required'], features: { cookieName: cookie.name, cookieValueLength: cookie.value.length, requiresContext: true, }, }); } else { const cookies = parseFunc(m[0]); if (cookies.some((c) => c.value.length >= 8)) { push({ type, start: m.index, end: m.index + m[0].length, value: m[0], risk: 'medium', confidence, reasons: ['cookie_match', 'context_required'], features: { cookieCount: cookies.length, requiresContext: true, hasSensitiveCookies: cookies.some((c) => c.value.length >= 8), }, }); } } } catch (error) { const errorObj = error instanceof Error ? error : new Error(String(error)); logSecurityError(`${setCookie ? 'Set-Cookie' : 'Cookie'} parsing`, errorObj, m[0]); } } }, }; } /** Security plugin detectors */ export const detectors = [ // JWT Token Detection (highest priority) createContextualDetector('security.jwt', SECURITY_PATTERNS.jwt, 'sec_jwt_token', 'high', CONFIDENCE_THRESHOLDS.jwt.base, undefined, validateJwtStructure, -10), // API Key Detection createContextualDetector('security.api-key', SECURITY_PATTERNS.apiKey, 'sec_api_key', 'high', CONFIDENCE_THRESHOLDS.apiKey.base, undefined, (key) => validateToken(key), -5), // Authorization Header createContextualDetector('security.auth-header', SECURITY_PATTERNS.authHeader, 'sec_auth_header', 'high', CONFIDENCE_THRESHOLDS.auth, [...SECURITY_CONTEXTS.auth], undefined, -8), // API Key Header createContextualDetector('security.api-key-header', SECURITY_PATTERNS.apiKeyHeader, 'sec_api_key', 'high', 0.88, [...SECURITY_CONTEXTS.apiKey], undefined, -6), // UUID Token createContextualDetector('security.uuid', SECURITY_PATTERNS.uuid, 'sec_uuid_token', 'medium', CONFIDENCE_THRESHOLDS.uuid, [...SECURITY_CONTEXTS.auth, ...SECURITY_CONTEXTS.session]), // Hex Token createContextualDetector('security.hex-token', SECURITY_PATTERNS.hexToken, 'sec_hex_token', 'medium', CONFIDENCE_THRESHOLDS.hex, [...SECURITY_CONTEXTS.session, ...SECURITY_CONTEXTS.auth]), // Session ID createContextualDetector('security.session-id', SECURITY_PATTERNS.sessionId, 'sec_session_id', 'high', CONFIDENCE_THRESHOLDS.session), // Client Credentials createContextualDetector('security.client-credentials', SECURITY_PATTERNS.clientCredentials, 'sec_client_secret', 'high', CONFIDENCE_THRESHOLDS.client.secret), // URL Tokens createUrlTokenDetector(), // Cookie Headers createCookieDetector(false), createCookieDetector(true), // === NEW ENHANCED DETECTORS === // GitHub Personal Access Tokens (highest priority due to high frequency) createContextualDetector('security.github-token', SECURITY_PATTERNS.githubToken, 'sec_github_token', 'high', CONFIDENCE_THRESHOLDS.github, [...SECURITY_CONTEXTS.github], undefined, -15), // AWS Access Key ID createContextualDetector('security.aws-access-key', SECURITY_PATTERNS.awsAccessKey, 'sec_aws_access_key', 'high', CONFIDENCE_THRESHOLDS.aws, [...SECURITY_CONTEXTS.aws], undefined, -12), // Google/Firebase API Keys createContextualDetector('security.google-api-key', SECURITY_PATTERNS.googleApiKey, 'sec_google_api_key', 'high', CONFIDENCE_THRESHOLDS.google, [...SECURITY_CONTEXTS.google], undefined, -11), // Stripe API Keys createContextualDetector('security.stripe-api-key', SECURITY_PATTERNS.stripeApiKey, 'sec_stripe_api_key', 'high', CONFIDENCE_THRESHOLDS.stripe, [...SECURITY_CONTEXTS.stripe], undefined, -10), // Slack Tokens createContextualDetector('security.slack-token', SECURITY_PATTERNS.slackToken, 'sec_slack_token', 'high', CONFIDENCE_THRESHOLDS.slack, [...SECURITY_CONTEXTS.slack], undefined, -9), // SendGrid API Keys createContextualDetector('security.sendgrid-api-key', SECURITY_PATTERNS.sendGridApiKey, 'sec_sendgrid_api_key', 'high', CONFIDENCE_THRESHOLDS.sendgrid, [...SECURITY_CONTEXTS.sendgrid], undefined, -8), // OpenAI API Keys createContextualDetector('security.openai-api-key', SECURITY_PATTERNS.openAiApiKey, 'sec_openai_api_key', 'high', CONFIDENCE_THRESHOLDS.openai, [...SECURITY_CONTEXTS.openai], undefined, -7), // Google OAuth Tokens createContextualDetector('security.google-oauth-token', SECURITY_PATTERNS.googleOAuthToken, 'sec_google_oauth_token', 'medium', CONFIDENCE_THRESHOLDS.oauth, [...SECURITY_CONTEXTS.oauth], undefined, -6), // Azure Subscription Keys createContextualDetector('security.azure-subscription-key', SECURITY_PATTERNS.azureSubscriptionKey, 'sec_azure_subscription_key', 'high', CONFIDENCE_THRESHOLDS.azure, [...SECURITY_CONTEXTS.azure], undefined, -5), // Webhook URLs createWebhookDetector(), // Signed URLs createSignedUrlDetector(), ];