@ibm-cloud/openapi-ruleset
Version:
Spectral ruleset for validating IBM Cloud services
252 lines (227 loc) • 9.02 kB
JavaScript
/**
* Copyright 2017 - 2023 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/
const { LoggerFactory, operationMethods } = require('../utils');
let ruleId;
let logger;
module.exports = function (rootDocument, _opts, context) {
if (!logger) {
ruleId = context.rule.name;
logger = LoggerFactory.getInstance().getLogger(ruleId);
}
return checkSecuritySchemes(rootDocument);
};
// Security scheme types that support scopes.
const schemeTypesSupportScopes = ['oauth2', 'openIdConnect'];
// Security scheme types that define scopes.
const schemeTypesDefineScopes = ['oauth2'];
/**
* This function implements the 'securityschemes' validation rule.
* The specific checks that are performed are:
*
* 1. The name used within a security requirement object must correspond to a
* security scheme that is properly defined in "components.securitySchemes".
* Reference: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.2.md#patterned-fields-3
*
* 2. Each security scheme defined in "components.securitySchemes" should be referenced
* by at least one security requirement object.
*
* 3. Each scope referenced within a security requirement object for an oauth2-type security scheme
* must be defined within that security scheme.
*
* 4. Each scope that is defined within an oath2-type security scheme should be
* referenced by at least one security requirement object.
*
* 5. If a security requirement object is associated with a security scheme that does not support
* scopes, then its scopes array MUST be empty.
*
* @param {object} rootDocument the entire API definition (assumed to be an OpenAPI 3 document)
* @returns an array of error objects
*/
function checkSecuritySchemes(rootDocument) {
logger.debug(
`${ruleId}: checking securityScheme usage throughout API definition.`
);
// Grab the securitySchemes from the API definition.
const securitySchemes =
rootDocument.components && rootDocument.components.securitySchemes;
// If we have any security schemes, build an object to store usage info.
const usageInfo = {};
if (securitySchemes) {
for (const schemeName in securitySchemes) {
if (schemeName.slice(0, 2) === 'x-') continue;
const scheme = securitySchemes[schemeName];
// Build an entry for this scheme's usage info.
const usageEntry = {
used: false,
type: scheme.type,
scopeUsage: {},
};
// If applicable, set up the "scopeUsage" object for any scopes that are defined.
if (schemeTypesDefineScopes.includes(scheme.type) && scheme.flows) {
// Reference: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.2.md#oauth-flows-object
for (const flowType in scheme.flows) {
const flow = scheme.flows[flowType];
if (flow.scopes) {
for (const scope in flow.scopes) {
// Scopes can be shared across the different flow objects within a security scheme,
// so we'll build a scope usage entry only the first time we encounter
// a particular scope within this security scheme.
if (!usageEntry.scopeUsage[scope]) {
usageEntry.scopeUsage[scope] = {
used: false,
flow: flowType,
};
}
}
}
}
}
usageInfo[schemeName] = usageEntry;
}
logger.debug(
`${ruleId}: found these securitySchemes in the API definition: ${Object.keys(
usageInfo
).join(', ')}`
);
} else {
logger.debug(`${ruleId}: No securitySchemes found in the API definition!`);
}
// Next, visit each "security" object and record its usage.
// Security objects can exist in the "security" field at the root of the document,
// or within the "security" field of an operation.
const errors = [];
// 1. Record usage info for the global "security" field.
if (rootDocument.security) {
logger.debug(`${ruleId}: checking global security object`);
errors.push(...recordUsage(rootDocument.security, usageInfo, ['security']));
}
// 2. Visit each operation and record usage info for its "security" field.
if (rootDocument.paths) {
for (const pathStr in rootDocument.paths) {
const pathItem = rootDocument.paths[pathStr];
// Within the pathItem, visit only those fields that hold operations.
for (const methodName of operationMethods) {
const operationObj = pathItem[methodName];
if (operationObj && operationObj.security) {
logger.debug(
`${ruleId}: checking operation '${operationObj.operationId}'`
);
errors.push(
...recordUsage(operationObj.security, usageInfo, [
'paths',
pathStr,
methodName,
'security',
])
);
}
}
}
}
// Finally, report warnings for any unreferenced security schemes or scopes.
for (const schemeName in usageInfo) {
const usageEntry = usageInfo[schemeName];
if (usageEntry.used === false) {
logger.debug(
`${ruleId}: securityScheme '${schemeName}' defined but not used!`
);
errors.push({
message: 'A security scheme is defined but never used',
path: ['components', 'securitySchemes', schemeName],
});
}
for (const scope in usageEntry.scopeUsage) {
const scopeUsageEntry = usageEntry.scopeUsage[scope];
if (scopeUsageEntry.used === false) {
logger.debug(`${ruleId}: scope '${scope}' defined but not used!`);
errors.push({
message: 'A security scope is defined but never used',
path: [
'components',
'securitySchemes',
schemeName,
'flows',
scopeUsageEntry.flow,
'scopes',
scope,
],
});
}
}
}
return errors;
}
/**
* Record usage information for each "security requirement" object contained in "securityList".
* Each security requirement object contains a field (key) whose name matches the name of
* a securityScheme defined in components.securitySchemes.
* (reference: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.2.md#securityRequirementObject).
*
* @param {*} securityList a list of "security requirement" objects
* @param {*} usageInfo an object containing scheme and scope usage info
* @param {*} path a list of strings representing the json path to "securityList"
* @return a list of zero or more errors
*/
function recordUsage(securityList, usageInfo, path) {
if (!Array.isArray(securityList)) return [];
const errors = [];
for (const securityIndex in securityList) {
const securityObj = securityList[securityIndex];
for (const schemeName in securityObj) {
const usageEntry = usageInfo[schemeName];
if (usageEntry) {
// Record the usage of the scheme.
usageEntry.used = true;
if (schemeTypesSupportScopes.includes(usageEntry.type)) {
// For security schemes that support scopes, record the usage of the scopes.
for (const scopeIndex in securityObj[schemeName]) {
const scope = securityObj[schemeName][scopeIndex];
const scopeUsageEntry = usageEntry.scopeUsage[scope];
if (scopeUsageEntry) {
scopeUsageEntry.used = true;
} else {
if (schemeTypesDefineScopes.includes(usageEntry.type)) {
// Only return an error for an "undefined scope" if
// the scheme type is one where scopes are defined locally (e.g. 'oauth2').
logger.debug(
`${ruleId}: undefined security scope referenced: ${scope}`
);
errors.push({
message: 'An undefined security scope is referenced',
path: [
...path,
securityIndex.toString(),
schemeName,
scopeIndex,
],
});
}
}
}
} else {
// For a scheme that doesn't support scopes, verify that the scopes array is empty.
const scopes = securityObj[schemeName];
if (scopes && scopes.length) {
logger.debug(`${ruleId}: unexpected scopes found!`);
errors.push({
message:
'For security scheme types that do not support scopes, the value must be an empty array',
path: [...path, securityIndex.toString(), schemeName],
});
}
}
} else {
logger.debug(
`${ruleId}: undefined securitySchema referenced: ${schemeName}`
);
errors.push({
message: 'An undefined security scheme is referenced',
path: [...path, securityIndex.toString(), schemeName],
});
}
}
}
return errors;
}