UNPKG

@cloud-copilot/iam-policy

Version:
375 lines 14.3 kB
const serviceRegex = /^[a-zA-Z0-9-]+$/; const actionRegex = /^[a-zA-Z0-9*\?]+$/; const allowedPolicyKeys = new Set(['Version', 'Statement', 'Id']); const allowedStatementKeys = new Set([ 'Sid', 'Effect', 'Action', 'NotAction', 'Resource', 'NotResource', 'Principal', 'NotPrincipal', 'Condition' ]); const allowedPrincipalKeys = new Set(['AWS', 'Service', 'Federated', 'CanonicalUser']); const validConditionOperatorPattern = /^[a-zA-Z0-9:]+$/; const allowedSetOperators = new Set(['forallvalues', 'foranyvalue']); export function validatePolicySyntax(policyDocument, validationCallbacks = {}) { const allErrors = []; if (typeof policyDocument !== 'object') { return [ { path: '', message: `Policy must be an object, received type ${typeof policyDocument}` } ]; } else if (Array.isArray(policyDocument)) { return [{ path: '', message: 'Policy must be an object, received an array' }]; } allErrors.push(...validateKeys(policyDocument, allowedPolicyKeys, '')); if (validationCallbacks.validateVersion) { allErrors.push(...validationCallbacks.validateVersion(policyDocument.Version, '')); } else { allErrors.push(...validatePolicyVersion(policyDocument.Version)); } allErrors.push(...validateDataTypeIfExists(policyDocument.Id, 'Id', 'string')); if (!policyDocument.Statement) { allErrors.push({ path: '', message: 'Statement is required' }); } allErrors.push(...validateTypeOrArrayOfTypeIfExists(policyDocument.Statement, 'Statement', ['object'])); if (typeof policyDocument.Statement === 'object' && !Array.isArray(policyDocument.Statement)) { allErrors.push(...validateStatement(policyDocument.Statement, 'Statement', validationCallbacks)); } else if (Array.isArray(policyDocument.Statement)) { for (let i = 0; i < policyDocument.Statement.length; i++) { allErrors.push(...validateStatement(policyDocument.Statement[i], `Statement[${i}]`, validationCallbacks)); } const statementIdCounts = policyDocument.Statement.reduce((acc, statement, index) => { if (statement.Sid) { if (!acc[statement.Sid]) { acc[statement.Sid] = []; } acc[statement.Sid].push(`Statement[${index}].Sid`); } return acc; }, {}); for (const [sid, paths] of Object.entries(statementIdCounts)) { if (paths.length > 1) { for (const path of paths) { allErrors.push({ path, message: `Statement Ids (Sid) must be unique` }); } } } } return allErrors; } function validatePolicyVersion(version) { if (version === undefined || version === null) { return []; } if (typeof version !== 'string') { return [ { path: 'Version', message: `Version must be a string if present` } ]; } if (version === '2012-10-17' || version === '2008-10-17') { return []; } return [ { path: 'Version', message: `Version must be either "2012-10-17" or "2008-10-17"` } ]; } function validateStatement(statement, path, validationCallbacks) { const statementErrors = []; statementErrors.push(...validateKeys(statement, allowedStatementKeys, path)); statementErrors.push(...validateDataTypeIfExists(statement.Sid, `${path}.Sid`, 'string')); if (!statement.Effect) { statementErrors.push({ path: `${path}`, message: `Effect must be present and exactly "Allow" or "Deny"` }); } else if (statement.Effect !== 'Allow' && statement.Effect !== 'Deny') { statementErrors.push({ path: `${path}.Effect`, message: `Effect must be present and exactly "Allow" or "Deny"` }); } statementErrors.push(...(validationCallbacks.validateStatement?.(statement, path) || [])); statementErrors.push(...validateOnlyOneOf(statement, path, 'Action', 'NotAction')); statementErrors.push(...validateOnlyOneOf(statement, path, 'Resource', 'NotResource')); statementErrors.push(...validateOnlyOneOf(statement, path, 'Principal', 'NotPrincipal')); statementErrors.push(...validateTypeOrArrayOfTypeIfExists(statement.Action, `${path}.Action`, 'string')); statementErrors.push(...validateTypeOrArrayOfTypeIfExists(statement.NotAction, `${path}.NotAction`, 'string')); statementErrors.push(...validateActionIfPresent(statement.Action, `${path}.Action`)); statementErrors.push(...validateActionIfPresent(statement.NotAction, `${path}.NotAction`)); statementErrors.push(...validateStringOrArrayStringCallback(statement, 'Action', path, validationCallbacks.validateAction)); statementErrors.push(...validateStringOrArrayStringCallback(statement, 'NotAction', path, validationCallbacks.validateNotAction)); statementErrors.push(...validateResource(statement.Resource, `${path}.Resource`)); statementErrors.push(...validateResource(statement.NotResource, `${path}.NotResource`)); statementErrors.push(...validateDataTypeIfExists(statement.Principal, `${path}.Principal`, ['string', 'object'])); statementErrors.push(...validateDataTypeIfExists(statement.NotPrincipal, `${path}.NotPrincipal`, [ 'string', 'object' ])); statementErrors.push(...validatePrincipal(statement.Principal, `${path}.Principal`)); statementErrors.push(...validatePrincipal(statement.NotPrincipal, `${path}.NotPrincipal`)); //TODO: If the condition key exists but there is no value, it is an error statementErrors.push(...validateCondition(statement.Condition, `${path}.Condition`)); return statementErrors; } function validatePrincipal(principal, path) { const principalErrors = []; if (principal === undefined || typeof principal === 'string') { return []; } if (typeof principal === 'object') { principalErrors.push(...validateKeys(principal, allowedPrincipalKeys, path)); principalErrors.push(...validateTypeOrArrayOfTypeIfExists(principal.AWS, `${path}.AWS`, 'string')); principalErrors.push(...validateTypeOrArrayOfTypeIfExists(principal.Service, `${path}.Service`, 'string')); principalErrors.push(...validateTypeOrArrayOfTypeIfExists(principal.Federated, `${path}.Federated`, 'string')); principalErrors.push(...validateTypeOrArrayOfTypeIfExists(principal.CanonicalUser, `${path}.CanonicalUser`, 'string')); } return principalErrors; } function validateResource(resource, path) { if (resource === undefined) { return []; } if (typeof resource === 'string') { return validateResourceString(resource, path); } else if (Array.isArray(resource)) { const resourceErrors = []; for (let i = 0; i < resource.length; i++) { resourceErrors.push(...validateResourceString(resource[i], `${path}[${i}]`)); } return resourceErrors; } return [ { path, message: `Must be a string or array of strings` } ]; } function validateResourceString(resourceString, path) { if (resourceString === '*') { return []; } const parts = resourceString.split(':'); if (parts.length < 6 || parts.at(0) != 'arn') { return [ { path, message: `Resource arn must have 6 segments and start with "arn:"` } ]; } return []; } function validateActionIfPresent(action, path) { if (action === undefined || action === null) { return []; } //Type errors are caught elsewhere if (typeof action === 'string') { return validateActionString(action, path); } else if (Array.isArray(action)) { const actionErrors = []; for (let i = 0; i < action.length; i++) { const value = action[i]; if (typeof value === 'string') { actionErrors.push(...validateActionString(action[i], `${path}[${i}]`)); } } return actionErrors; } return []; } function validateActionString(string, path) { if (string === '*') { return []; } const parts = string.trim().split(':'); if (parts.length != 2) { return [ { path, message: `Action must be a wildcard (*) or have 2 segments` } ]; } const [service, action] = parts; const errors = []; if (!serviceRegex.test(service)) { errors.push({ path, message: `Service can only contain letters, numbers, and hyphens` }); } if (action.length === 0) { errors.push({ path, message: `Action is required for the service` }); } else if (!actionRegex.test(action)) { errors.push({ path, message: `Action can only contain letters, numbers, asterisks, and question marks` }); } return errors; } function validateCondition(condition, path) { const conditionErrors = []; if (condition === undefined || condition === null) { return []; } conditionErrors.push(...validateDataTypeIfExists(condition, path, 'object')); if (typeof condition !== 'object') { return conditionErrors; } else if (Array.isArray(condition)) { conditionErrors.push({ message: 'Condition must be an object, found an array', path }); return conditionErrors; } const conditionOperators = Object.keys(condition); for (const operator of conditionOperators) { //If not valid pattern if (!validConditionOperatorPattern.test(operator)) { conditionErrors.push({ path: `${path}.#${operator}`, message: `Condition operator is invalid` }); } const splitOperator = operator.split(':'); if (splitOperator.length > 2) { conditionErrors.push({ path: `${path}.#${operator}`, message: `Condition operator is invalid` }); } else if (splitOperator.length === 2) { const setOperator = splitOperator[0].toLowerCase(); if (!allowedSetOperators.has(setOperator)) { conditionErrors.push({ path: `${path}.#${operator}`, message: `Condition set operator must be either ForAllValues or ForAnyValue` }); } } conditionErrors.push(...validateDataTypeIfExists(condition[operator], `${path}.${operator}`, 'object')); if (Array.isArray(condition[operator])) { conditionErrors.push({ message: 'Condition operator must be an object, found an array', path: `${path}.${operator}` }); } if (typeof condition[operator] === 'object' && !Array.isArray(condition[operator])) { const conditionKeys = Object.keys(condition[operator]); for (const key of conditionKeys) { conditionErrors.push(...validateTypeOrArrayOfTypeIfExists(condition[operator][key], `${path}.${operator}.${key}`, 'string')); } } } return conditionErrors; } function validateKeys(object, allowedKeys, path) { const keyErrors = []; for (const key of Object.keys(object)) { if (!allowedKeys.has(key)) { keyErrors.push({ message: `Invalid key ${key}`, path: `${path}.#${key}` }); } else if (object[key] === undefined || object[key] === null) { keyErrors.push({ message: `If present, ${key} cannot be null or undefined`, path: `${path}.${key}` }); } } return keyErrors; } function validateTypeOrArrayOfTypeIfExists(value, path, allowedTypes) { if (value === undefined) { return []; } allowedTypes = Array.isArray(allowedTypes) ? allowedTypes : [allowedTypes]; const arrayOfTypeErrors = []; if (!Array.isArray(value)) { return validateDataTypeIfExists(value, path, allowedTypes); } else { for (let i = 0; i < value.length; i++) { arrayOfTypeErrors.push(...validateDataTypeIfExists(value[i], `${path}[${i}]`, allowedTypes)); } } return arrayOfTypeErrors; } function validateDataTypeIfExists(value, path, allowedDataTypes) { if (value === undefined) { return []; } allowedDataTypes = Array.isArray(allowedDataTypes) ? allowedDataTypes : [allowedDataTypes]; const errors = []; const foundDataType = typeof value; if (!allowedDataTypes.includes(foundDataType)) { errors.push({ message: `Found data type ${foundDataType} allowed type(s) are ${allowedDataTypes.join(', ')}`, path }); } return errors; } function validateOnlyOneOf(value, path, firstKey, secondKey) { const keys = Object.keys(value); if (keys.includes(firstKey) && keys.includes(secondKey)) { return [ { message: `Only one of ${firstKey} or ${secondKey} is allowed, found both`, path } ]; } return []; } function validateStringOrArrayStringCallback(statement, fieldName, path, callback) { if (statement === undefined || !statement[fieldName] || !callback) { return []; } const value = statement[fieldName]; path = `${path}.${fieldName}`; if (typeof value === 'string') { return callback(value, path); } else if (Array.isArray(value)) { const errors = []; for (let i = 0; i < value.length; i++) { errors.push(...callback(value[i], `${path}[${i}]`)); } return errors; } //If it's not a string or string array that is caught elsewhere return []; } //# sourceMappingURL=validate.js.map