@redpillsec/cli
Version:
RedPill Security CLI - OpenAPI security scanner that reveals vulnerabilities in your API specifications
710 lines (645 loc) • 29 kB
JavaScript
/**
* 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 };