UNPKG

@redpillsec/cli

Version:

RedPill Security CLI - OpenAPI security scanner that reveals vulnerabilities in your API specifications

710 lines (645 loc) 29 kB
/** * OpenAPI security rules * Each rule has: * - id: Unique identifier for the rule * - description: Brief description of what the rule checks * - check: Function that evaluates the API and returns issues */ const openapiRules = [ { id: "OAS001", description: "Missing securitySchemes", check: (api) => { const hasSecuritySchemes = api.components && api.components.securitySchemes; if (!hasSecuritySchemes) { return [{ id: "OAS001", message: "OpenAPI spec does not define securitySchemes.", path: "components.securitySchemes" }]; } return []; }, }, { id: "OAS002", description: "Missing Core Status Codes (200, 400, 500)", check: (api) => { const issues = []; // Process all paths and their operations Object.entries(api.paths || {}).forEach(([pathName, pathObj]) => { // For each HTTP method (GET, POST, etc.) Object.entries(pathObj).forEach(([method, operation]) => { // Skip non-operation properties like parameters if (method.startsWith('x-') || ['parameters', 'servers', 'summary', 'description'].includes(method)) { return; } // Get all status codes defined in responses const responses = operation.responses || {}; const statusCodes = Object.keys(responses).filter(code => code !== 'default'); // Check for presence of status code groups const has2xx = statusCodes.some(code => /^2\d{2}$/.test(code)); const has4xx = statusCodes.some(code => /^4\d{2}$/.test(code)); const has5xx = statusCodes.some(code => /^5\d{2}$/.test(code)); const missingGroups = []; if (!has2xx) missingGroups.push('2xx'); if (!has4xx) missingGroups.push('4xx'); if (!has5xx) missingGroups.push('5xx'); if (missingGroups.length > 0) { issues.push({ id: "OAS002", message: `Missing ${missingGroups.join(', ')} response codes — ensure proper error handling.`, path: `${pathName} [${method.toUpperCase()}]` }); } }); }); return issues; }, }, { id: "OAS003", description: "Explicit auth structure in description", // Default patterns that can be overridden in config patterns: [ /jwt\s+format/i, /bearer\s+token/i, /api\s+key/i, /auth\s+header/i, /authorization\s+header/i, /token\s+format/i ], check: function(api) { const issues = []; // Use the patterns from this rule instance (which may be overridden by config) // Ensure patterns are RegExp objects const sensitivePatterns = Array.isArray(this.patterns) ? this.patterns.map(p => p instanceof RegExp ? p : new RegExp(p, 'i')) : this.patterns; // Function to recursively check all description fields const checkObject = (obj, path) => { if (!obj || typeof obj !== 'object') return; // Check description field if present if (obj.description && typeof obj.description === 'string') { for (const pattern of sensitivePatterns) { if (pattern.test(obj.description)) { issues.push({ id: "OAS003", message: `Description contains explicit auth structure: ${pattern.source}`, path: path ? `${path}.description` : 'description' }); break; // Only report once per description field } } } // Recursively check child objects Object.entries(obj).forEach(([key, value]) => { if (value && typeof value === 'object') { const newPath = path ? `${path}.${key}` : key; checkObject(value, newPath); } }); }; checkObject(api, ''); return issues; } }, { id: "OAS004", description: "Missing Rate Limiting Information", check: (api) => { const issues = []; const rateLimitTerms = [/rate/i, /limit/i, /throttle/i, /429/]; const rateLimitHeaders = [ /x-ratelimit/i, /retry-after/i, /rate-limit/i, /ratelimit/i, /quota/i ]; // Process all paths and their operations Object.entries(api.paths || {}).forEach(([pathName, pathObj]) => { // For each HTTP method (GET, POST, etc.) Object.entries(pathObj).forEach(([method, operation]) => { // Skip non-operation properties if (method.startsWith('x-') || ['parameters', 'servers', 'summary', 'description'].includes(method)) { return; } let hasRateLimitInfo = false; // Check description for rate limit terms if (operation.description) { if (rateLimitTerms.some(term => term.test(operation.description))) { hasRateLimitInfo = true; } } // Check response headers for rate limit headers if (!hasRateLimitInfo && operation.responses) { outer: for (const [, response] of Object.entries(operation.responses)) { if (response.headers) { for (const headerName of Object.keys(response.headers)) { if (rateLimitHeaders.some(pattern => pattern.test(headerName))) { hasRateLimitInfo = true; break outer; } } } } } if (!hasRateLimitInfo) { issues.push({ id: "OAS004", message: "No mention of rate limiting found in description or headers.", path: `${pathName} [${method.toUpperCase()}]` }); } }); }); return issues; }, }, { id: "OAS005", description: "Missing server URLs", check: (api) => { if (!api.servers || api.servers.length === 0) { return [{ id: "OAS005", message: "No servers defined in OpenAPI spec.", path: "servers" }]; } return []; }, }, { id: "OAS006", description: "Non-descriptive 'default' Endpoint Name Used", check: (api) => { const issues = []; // Check for 'default' as a path segment Object.keys(api.paths || {}).forEach(path => { const pathSegments = path.split('/'); for (const segment of pathSegments) { if (segment.toLowerCase() === 'default') { issues.push({ id: "OAS006", message: "Avoid using 'default' as an endpoint name. Use descriptive resource names instead.", path: path }); break; } } }); // Check if tags or operationIds use 'default' as a name Object.entries(api.paths || {}).forEach(([pathName, pathObj]) => { // For each HTTP method (GET, POST, etc.) Object.entries(pathObj).forEach(([method, operation]) => { // Skip non-operation properties if (method.startsWith('x-') || ['parameters', 'servers', 'summary', 'description'].includes(method)) { return; } // Check if 'default' is used in operationId if (operation.operationId && operation.operationId.toLowerCase().includes('default')) { issues.push({ id: "OAS006", message: "Avoid using 'default' in operationId. Use descriptive operation names.", path: `${pathName} [${method.toUpperCase()}] → operationId` }); } // Check if 'default' is used in tags if (operation.tags && operation.tags.some(tag => tag.toLowerCase() === 'default')) { issues.push({ id: "OAS006", message: "Avoid using 'default' as a tag name. Use descriptive category names instead.", path: `${pathName} [${method.toUpperCase()}] → tags` }); } }); }); return issues; } }, { id: "OAS007", description: "Missing Security on Sensitive Operations", check: (api) => { const issues = []; const sensitiveOps = ['post', 'put', 'patch', 'delete']; // Check global security const hasGlobalSecurity = api.security && api.security.length > 0; // Process all paths and their operations Object.entries(api.paths || {}).forEach(([pathName, pathObj]) => { // For each HTTP method Object.entries(pathObj).forEach(([method, operation]) => { // Skip non-operation properties if (method.startsWith('x-') || ['parameters', 'servers', 'summary', 'description'].includes(method)) { return; } // Check if it's a sensitive operation if (sensitiveOps.includes(method.toLowerCase())) { // Check if operation has security defined const hasOperationSecurity = operation.security !== undefined; // If no security at operation level and no global security if (!hasOperationSecurity && !hasGlobalSecurity) { issues.push({ id: "OAS007", message: `Sensitive operation lacks security requirements. ${method.toUpperCase()} operations should have authentication.`, path: `${pathName} [${method.toUpperCase()}]` }); } // If operation explicitly sets empty security array (overriding global) else if (hasOperationSecurity && Array.isArray(operation.security) && operation.security.length === 0) { issues.push({ id: "OAS007", message: `Sensitive operation explicitly removes security requirements. ${method.toUpperCase()} operations should have authentication.`, path: `${pathName} [${method.toUpperCase()}]` }); } } }); }); return issues; } }, { id: "OAS009", description: "Missing OAuth2 Scopes", check: (api) => { const issues = []; // Check security schemes for OAuth2 if (api.components && api.components.securitySchemes) { Object.entries(api.components.securitySchemes).forEach(([schemeName, scheme]) => { // Check if it's OAuth2 if (scheme.type === 'oauth2') { // let hasScopes = false; // Check flows for scopes if (scheme.flows) { const flowTypes = ['implicit', 'password', 'clientCredentials', 'authorizationCode']; flowTypes.forEach(flowType => { if (scheme.flows[flowType]) { const flow = scheme.flows[flowType]; if (!flow.scopes || Object.keys(flow.scopes).length === 0) { issues.push({ id: "OAS009", message: `OAuth2 flow '${flowType}' in security scheme '${schemeName}' lacks scope definitions. OAuth2 requires scopes for proper access control.`, path: `components.securitySchemes.${schemeName}.flows.${flowType}` }); } else { // hasScopes = true; } } }); } // If no flows defined at all if (!scheme.flows) { issues.push({ id: "OAS009", message: `OAuth2 security scheme '${schemeName}' lacks flow definitions. OAuth2 requires at least one flow with scopes.`, path: `components.securitySchemes.${schemeName}` }); } } }); } // Check if OAuth2 is used in operations without scopes Object.entries(api.paths || {}).forEach(([pathName, pathObj]) => { Object.entries(pathObj).forEach(([method, operation]) => { // Skip non-operation properties if (method.startsWith('x-') || ['parameters', 'servers', 'summary', 'description'].includes(method)) { return; } // Check operation security const securityRequirements = operation.security || api.security || []; securityRequirements.forEach(requirement => { Object.entries(requirement).forEach(([schemeName, scopes]) => { // Check if this references an OAuth2 scheme if (api.components && api.components.securitySchemes && api.components.securitySchemes[schemeName]) { const scheme = api.components.securitySchemes[schemeName]; if (scheme.type === 'oauth2') { // Check if scopes are specified in the operation if (!scopes || scopes.length === 0) { issues.push({ id: "OAS009", message: `Operation uses OAuth2 scheme '${schemeName}' without specifying required scopes.`, path: `${pathName} [${method.toUpperCase()}] → security.${schemeName}` }); } } } }); }); }); }); return issues; } }, { id: "OAS010", description: "Sensitive Data in Examples", check: (api) => { const issues = []; // Patterns for sensitive data in examples const sensitivePatterns = [ { pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/, type: 'email address' }, { pattern: /["']?password["']?\s*:\s*["'][^"']+["']/i, type: 'password' }, { pattern: /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12})\b/, type: 'credit card number' }, { pattern: /\b\d{3}-\d{2}-\d{4}\b/, type: 'SSN' }, { pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/, type: 'bearer token' }, { pattern: /\b(?:api[_-]?key|apikey)\s*[:=]\s*["']?[\w-]+["']?/i, type: 'API key' }, { pattern: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/, type: 'private key' }, { pattern: /\b(?:secret|token)\s*[:=]\s*["']?[\w-]+["']?/i, type: 'secret/token' } ]; // Helper to check if example contains sensitive data const checkExample = (example, path) => { if (!example) return; const exampleStr = typeof example === 'string' ? example : JSON.stringify(example); sensitivePatterns.forEach(({ pattern, type }) => { if (pattern.test(exampleStr)) { issues.push({ id: "OAS010", message: `Example contains sensitive data (${type}). Use placeholder or dummy data instead.`, path: path }); } }); }; // Check examples in responses Object.entries(api.paths || {}).forEach(([pathName, pathObj]) => { Object.entries(pathObj).forEach(([method, operation]) => { // Skip non-operation properties if (method.startsWith('x-') || ['parameters', 'servers', 'summary', 'description'].includes(method)) { return; } // Check response examples if (operation.responses) { Object.entries(operation.responses).forEach(([statusCode, response]) => { // Check examples in response content if (response.content) { Object.entries(response.content).forEach(([contentType, mediaType]) => { if (mediaType.example) { checkExample(mediaType.example, `${pathName} [${method.toUpperCase()}] → responses.${statusCode}.${contentType}.example`); } if (mediaType.examples) { Object.entries(mediaType.examples).forEach(([exampleName, exampleObj]) => { if (exampleObj.value) { checkExample(exampleObj.value, `${pathName} [${method.toUpperCase()}] → responses.${statusCode}.${contentType}.examples.${exampleName}`); } }); } }); } }); } // Check request body examples if (operation.requestBody && operation.requestBody.content) { Object.entries(operation.requestBody.content).forEach(([contentType, mediaType]) => { if (mediaType.example) { checkExample(mediaType.example, `${pathName} [${method.toUpperCase()}] → requestBody.${contentType}.example`); } if (mediaType.examples) { Object.entries(mediaType.examples).forEach(([exampleName, exampleObj]) => { if (exampleObj.value) { checkExample(exampleObj.value, `${pathName} [${method.toUpperCase()}] → requestBody.${contentType}.examples.${exampleName}`); } }); } }); } }); }); // Check examples in components if (api.components && api.components.examples) { Object.entries(api.components.examples).forEach(([exampleName, example]) => { if (example.value) { checkExample(example.value, `components.examples.${exampleName}`); } }); } return issues; } }, { id: "OAS012", description: "Missing Input Validation Constraints", check: (api) => { const issues = []; // Helper function to check if a schema has validation constraints const hasValidationConstraints = (schema) => { if (!schema) return true; // No schema is okay // For string types if (schema.type === 'string') { return schema.minLength !== undefined || schema.maxLength !== undefined || schema.pattern !== undefined || schema.enum !== undefined || schema.format !== undefined; } // For number/integer types if (schema.type === 'number' || schema.type === 'integer') { return schema.minimum !== undefined || schema.maximum !== undefined || schema.exclusiveMinimum !== undefined || schema.exclusiveMaximum !== undefined || schema.multipleOf !== undefined || schema.enum !== undefined; } // For array types if (schema.type === 'array') { return schema.minItems !== undefined || schema.maxItems !== undefined || schema.uniqueItems !== undefined; } // For object types - check if properties have constraints if (schema.type === 'object' && schema.properties) { // At least check for required fields return schema.required && schema.required.length > 0; } return true; // Other types don't need validation }; // Process all paths and their operations Object.entries(api.paths || {}).forEach(([pathName, pathObj]) => { // For each HTTP method Object.entries(pathObj).forEach(([method, operation]) => { // Skip non-operation properties if (method.startsWith('x-') || ['parameters', 'servers', 'summary', 'description'].includes(method)) { return; } // Check parameters const parameters = [...(pathObj.parameters || []), ...(operation.parameters || [])]; parameters.forEach(param => { // Skip header and cookie parameters if (param.in === 'header' || param.in === 'cookie') { return; } if (param.schema && !hasValidationConstraints(param.schema)) { issues.push({ id: "OAS012", message: `Parameter '${param.name}' lacks validation constraints (minLength, maxLength, pattern, min, max, etc.)`, path: `${pathName} [${method.toUpperCase()}] → parameter.${param.name}` }); } }); // Check request body if (operation.requestBody && operation.requestBody.content) { Object.entries(operation.requestBody.content).forEach(([contentType, mediaType]) => { if (mediaType.schema && mediaType.schema.type === 'object') { // Check each property in the schema if (mediaType.schema.properties) { Object.entries(mediaType.schema.properties).forEach(([propName, propSchema]) => { if (!hasValidationConstraints(propSchema)) { issues.push({ id: "OAS012", message: `Request body property '${propName}' lacks validation constraints`, path: `${pathName} [${method.toUpperCase()}] → requestBody.${contentType}.${propName}` }); } }); } // Check if the object itself has required fields if (!mediaType.schema.required || mediaType.schema.required.length === 0) { issues.push({ id: "OAS012", message: `Request body schema lacks required field definitions`, path: `${pathName} [${method.toUpperCase()}] → requestBody.${contentType}` }); } } }); } }); }); return issues; } }, { id: "OAS017", description: "Sensitive Data in URLs", check: (api) => { const issues = []; // Patterns for sensitive data const sensitivePatterns = [ { pattern: /password/i, type: 'password' }, { pattern: /passwd/i, type: 'password' }, { pattern: /pwd/i, type: 'password' }, { pattern: /secret/i, type: 'secret' }, { pattern: /token/i, type: 'token' }, { pattern: /api[_-]?key/i, type: 'API key' }, { pattern: /apikey/i, type: 'API key' }, { pattern: /auth/i, type: 'auth token' }, { pattern: /bearer/i, type: 'bearer token' }, { pattern: /jwt/i, type: 'JWT' }, { pattern: /oauth/i, type: 'OAuth token' }, { pattern: /ssn/i, type: 'SSN' }, { pattern: /social[_-]?security/i, type: 'SSN' }, { pattern: /credit[_-]?card/i, type: 'credit card' }, { pattern: /cc[_-]?number/i, type: 'credit card' }, { pattern: /cvv/i, type: 'CVV' }, { pattern: /pin/i, type: 'PIN' }, { pattern: /private[_-]?key/i, type: 'private key' } ]; // Check if parameter name suggests sensitive data const isSensitiveParam = (paramName) => { return sensitivePatterns.find(sp => sp.pattern.test(paramName)); }; // Process all paths and their operations Object.entries(api.paths || {}).forEach(([pathName, pathObj]) => { // Check path parameters in the URL itself const pathParamMatches = pathName.match(/{([^}]+)}/g); if (pathParamMatches) { pathParamMatches.forEach(match => { const paramName = match.slice(1, -1); // Remove { and } const sensitiveMatch = isSensitiveParam(paramName); if (sensitiveMatch) { issues.push({ id: "OAS017", message: `Sensitive data (${sensitiveMatch.type}) in path parameter '${paramName}'. Sensitive information should not be passed in URLs.`, path: pathName }); } }); } // For each HTTP method Object.entries(pathObj).forEach(([method, operation]) => { // Skip non-operation properties if (method.startsWith('x-') || ['parameters', 'servers', 'summary', 'description'].includes(method)) { return; } // Check parameters const parameters = [...(pathObj.parameters || []), ...(operation.parameters || [])]; parameters.forEach(param => { // Only check path and query parameters (not header or cookie) if (param.in === 'path' || param.in === 'query') { const sensitiveMatch = isSensitiveParam(param.name); if (sensitiveMatch) { issues.push({ id: "OAS017", message: `Sensitive data (${sensitiveMatch.type}) in ${param.in} parameter '${param.name}'. Sensitive information should not be passed in URLs.`, path: `${pathName} [${method.toUpperCase()}] → ${param.in}.${param.name}` }); } } }); }); }); return issues; } }, { id: "OAS018", description: "API Keys in Query Parameters", check: (api) => { const issues = []; // Check security schemes if (api.components && api.components.securitySchemes) { Object.entries(api.components.securitySchemes).forEach(([schemeName, scheme]) => { // Check if it's an API key in query if (scheme.type === 'apiKey' && scheme.in === 'query') { issues.push({ id: "OAS018", message: `Security scheme '${schemeName}' uses API key in query parameter. API keys should be passed in headers for security.`, path: `components.securitySchemes.${schemeName}` }); } }); } // Also check for API key-like parameters in operations const apiKeyPatterns = [ /api[_-]?key/i, /apikey/i, /access[_-]?key/i, /auth[_-]?key/i, /client[_-]?key/i, /subscription[_-]?key/i ]; // Process all paths and their operations Object.entries(api.paths || {}).forEach(([pathName, pathObj]) => { // For each HTTP method Object.entries(pathObj).forEach(([method, operation]) => { // Skip non-operation properties if (method.startsWith('x-') || ['parameters', 'servers', 'summary', 'description'].includes(method)) { return; } // Check parameters const parameters = [...(pathObj.parameters || []), ...(operation.parameters || [])]; parameters.forEach(param => { // Only check query parameters if (param.in === 'query') { // Check if parameter name suggests it's an API key const isApiKey = apiKeyPatterns.some(pattern => pattern.test(param.name)); if (isApiKey) { issues.push({ id: "OAS018", message: `API key parameter '${param.name}' is passed in query. API keys should be sent in headers instead of query parameters.`, path: `${pathName} [${method.toUpperCase()}] → query.${param.name}` }); } } }); }); }); return issues; } } ]; export { openapiRules };