diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index b89aa7c5..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "env": { - "node": true, - "es6": true - }, - "extends": "eslint:recommended", - "globals": { - "Atomics": "readonly", - "SharedArrayBuffer": "readonly" - }, - "parserOptions": { - "ecmaVersion": 2018 - }, - "ignorePatterns": [ - "node_modules/", - "tests/", - "scripts/", - ".github/", - "helpers/azure/functions.js", - "helpers/google/functions.js", - "helpers/oracle/functions.js", - "plugins/github/", - "plugins/oracle/", - "*.spec.js" - ], - "rules": { - "indent": [ - "error", - 4 - ], - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "single" - ], - "semi": [ - "error", - "always" - ], - "space-before-function-paren": [ - "error", - "never" - ], - "default-case": [ - "error" - ], - "no-eval": [ - "error" - ], - "keyword-spacing": [ - "error", - { - "before": true - } - ], - "brace-style": [ - "error", - "1tbs", - { - "allowSingleLine": true - } - ] - } -} \ No newline at end of file diff --git a/.github/workflows/scans_ci.yml b/.github/workflows/scans_ci.yml index 6338651f..940d8ef1 100644 --- a/.github/workflows/scans_ci.yml +++ b/.github/workflows/scans_ci.yml @@ -8,18 +8,10 @@ on: schedule: - cron: '0 0 * * 0' # Weekly run on Sunday at midnight -jobs: - test: - name: Test on Node.js ${{ matrix.node-version }} - runs-on: ubuntu-latest - strategy: - matrix: - node-version: [12.x, 14.x, 16.x] # Test on multiple Node.js versions - steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} cache: 'npm' diff --git a/collectors/azure/collector.js b/collectors/azure/collector.js index 6ebcd89d..8544dca6 100644 --- a/collectors/azure/collector.js +++ b/collectors/azure/collector.js @@ -108,7 +108,36 @@ let collect = function(AzureConfig, settings, callback, fargateFlag) { return retryAfter; }, errorFilter: function(err) { - return err.includes('TooManyRequests'); + const errorMessage = typeof err === 'string' ? err : err.message || err.toString(); + + // Azure throttling patterns + const throttlingPatterns = [ + 'TooManyRequests', + 'RateLimitExceeded', + 'Throttling', + 'Throttled', + 'RequestThrottled', + 'RequestLimitExceeded', + 'ServerBusy', + 'ServiceBusy', + 'toomanyrequests', + 'ratelimitexceeded', + 'throttling', + 'throttled', + 'requestthrottled', + 'requestlimitexceeded', + 'serverbusy', + 'servicebusy', + 'too many requests', + 'rate limit', + 'retry after', + 'the request is being throttled', + 'request rate is large', + 'rate exceeded' + ]; + + const errorMatch = throttlingPatterns.some(pattern => errorMessage.includes(pattern)); + return errorMatch; } }, function(retryCallback) { let localUrl = obj.nextUrl || obj.url.replace(/\{subscriptionId\}/g, AzureConfig.SubscriptionID); diff --git a/exports.js b/exports.js index 7aaa27bd..79656118 100644 --- a/exports.js +++ b/exports.js @@ -243,6 +243,8 @@ module.exports = { 'openAllPortsProtocolsEgress' : require(__dirname + '/plugins/aws/ec2/openAllPortsProtocolsEgress.js'), 'defaultSecurityGroupInUse' : require(__dirname + '/plugins/aws/ec2/defaultSecurityGroupInUse.js'), 'ec2NetworkExposure' : require(__dirname + '/plugins/aws/ec2/ec2NetworkExposure.js'), + 'ec2PrivilegeAnalysis' : require(__dirname + '/plugins/aws/ec2/ec2PrivilegeAnalysis.js'), + 'efsCmkEncrypted' : require(__dirname + '/plugins/aws/efs/efsCmkEncrypted.js'), 'efsEncryptionEnabled' : require(__dirname + '/plugins/aws/efs/efsEncryptionEnabled.js'), @@ -269,6 +271,8 @@ module.exports = { 'eksLatestPlatformVersion' : require(__dirname + '/plugins/aws/eks/eksLatestPlatformVersion.js'), 'eksClusterHasTags' : require(__dirname + '/plugins/aws/eks/eksClusterHasTags.js'), 'eksNetworkExposure' : require(__dirname + '/plugins/aws/eks/eksNetworkExposure.js'), + 'eksPrivilegeAnalysis' : require(__dirname + '/plugins/aws/eks/eksPrivilegeAnalysis.js'), + 'kendraIndexEncrypted' : require(__dirname + '/plugins/aws/kendra/kendraIndexEncrypted.js'), @@ -514,6 +518,8 @@ module.exports = { 'lambdaDeadLetterQueue' : require(__dirname + '/plugins/aws/lambda/lambdaDeadLetterQueue.js'), 'lambdaEnhancedMonitoring' : require(__dirname + '/plugins/aws/lambda/lambdaEnhancedMonitoring.js'), 'lambdaUniqueExecutionRole' : require(__dirname + '/plugins/aws/lambda/lambdaUniqueExecutionRole.js'), + 'lambdaNetworkExposure' : require(__dirname + '/plugins/aws/lambda/lambdaNetworkExposure.js'), + 'lambdaPrivilegeAnalysis' : require(__dirname + '/plugins/aws/lambda/lambdaPrivilegeAnalysis.js'), 'webServerPublicAccess' : require(__dirname + '/plugins/aws/mwaa/webServerPublicAccess.js'), 'environmentAdminPrivileges' : require(__dirname + '/plugins/aws/mwaa/environmentAdminPrivileges.js'), @@ -818,6 +824,7 @@ module.exports = { 'vmDiskCMKRotation' : require(__dirname + '/plugins/azure/virtualmachines/vmDiskCMKRotation.js'), 'vmDiskPublicAccess' : require(__dirname + '/plugins/azure/virtualmachines/vmDiskPublicAccess.js'), 'computeGalleryRbacSharing' : require(__dirname + '/plugins/azure/virtualmachines/computeGalleryRbacSharing.js'), + 'vmPrivilegeAnalysis' : require(__dirname + '/plugins/azure/virtualmachines/vmPrivilegeAnalysis.js'), 'vmNetworkExposure' : require(__dirname + '/plugins/azure/virtualmachines/vmNetworkExposure.js'), 'bastionHostExists' : require(__dirname + '/plugins/azure/bastion/bastionHostExists.js'), @@ -888,6 +895,8 @@ module.exports = { 'postgresqlPrivateEndpoints' : require(__dirname + '/plugins/azure/postgresqlserver/postgresqlPrivateEndpoints.js'), 'azureServicesAccessDisabled' : require(__dirname + '/plugins/azure/postgresqlserver/azureServicesAccessDisabled.js'), 'postgresqlTlsVersion' : require(__dirname + '/plugins/azure/postgresqlserver/postgresqlTlsVersion.js'), + 'postgresqlServerPublicAccess' : require(__dirname + '/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.js'), + 'postgresqlFlexibleServerPublicAccess': require(__dirname + '/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.js'), 'flexibleServerPrivateAccess' : require(__dirname + '/plugins/azure/postgresqlserver/flexibleServerPrivateAccess'), 'diagnosticLoggingEnabled' : require(__dirname + '/plugins/azure/postgresqlserver/diagnosticLoggingEnabled.js'), 'flexibleServerLogDisconnections': require(__dirname + '/plugins/azure/postgresqlserver/flexibleServerLogDisconnections.js'), @@ -1003,6 +1012,8 @@ module.exports = { 'disableFTPDeployments' : require(__dirname + '/plugins/azure/appservice/disableFTPDeployments.js'), 'accessControlAllowCredential' : require(__dirname + '/plugins/azure/appservice/accessControlAllowCredential.js'), 'appServiceDiagnosticLogs' : require(__dirname + '/plugins/azure/appservice/appServiceDiagnosticLogs.js'), + 'functionPrivilegeAnalysis' : require(__dirname + '/plugins/azure/appservice/functionPrivilegeAnalysis.js'), + 'functionAppNetworkExposure' : require(__dirname + '/plugins/azure/appservice/functionAppNetworkExposure.js'), 'rbacEnabled' : require(__dirname + '/plugins/azure/kubernetesservice/rbacEnabled.js'), 'aksManagedIdentity' : require(__dirname + '/plugins/azure/kubernetesservice/aksManagedIdentity.js'), @@ -1015,6 +1026,7 @@ module.exports = { 'aksHostBasedEncryption' : require(__dirname + '/plugins/azure/kubernetesservice/aksHostBasedEncryption.js'), 'aksApiAuthorizedIpRanges' : require(__dirname + '/plugins/azure/kubernetesservice/aksApiAuthorizedIpRanges.js'), 'aksNetworkExposure' : require(__dirname + '/plugins/azure/kubernetesservice/aksNetworkExposure.js'), + 'aksPrivilegeAnalysis' : require(__dirname + '/plugins/azure/kubernetesservice/aksPrivilegeAnalysis.js'), 'acrAdminUser' : require(__dirname + '/plugins/azure/containerregistry/acrAdminUser.js'), 'acrHasTags' : require(__dirname + '/plugins/azure/containerregistry/acrHasTags.js'), @@ -1029,14 +1041,14 @@ module.exports = { 'endpointLoggingEnabled' : require(__dirname + '/plugins/azure/cdnprofiles/endpointLoggingEnabled.js'), 'detectInsecureCustomOrigin' : require(__dirname + '/plugins/azure/cdnprofiles/detectInsecureCustomOrigin.js'), - 'passwordRequiresLowercase' : require(__dirname + '/plugins/azure/activedirectory/passwordRequiresLowercase.js'), - 'passwordRequiresNumbers' : require(__dirname + '/plugins/azure/activedirectory/passwordRequiresNumbers.js'), - 'passwordRequiresSymbols' : require(__dirname + '/plugins/azure/activedirectory/passwordRequiresSymbols.js'), - 'passwordRequiresUppercase' : require(__dirname + '/plugins/azure/activedirectory/passwordRequiresUppercase.js'), - 'minPasswordLength' : require(__dirname + '/plugins/azure/activedirectory/minPasswordLength.js'), - 'ensureNoGuestUser' : require(__dirname + '/plugins/azure/activedirectory/ensureNoGuestUser.js'), - 'noCustomOwnerRoles' : require(__dirname + '/plugins/azure/activedirectory/noCustomOwnerRoles.js'), - 'appOrgnaizationalDirectoryAccess' : require(__dirname + '/plugins/azure/activedirectory/appOrgnaizationalDirectoryAccess.js'), + 'passwordRequiresLowercase' : require(__dirname + '/plugins/azure/entraid/passwordRequiresLowercase.js'), + 'passwordRequiresNumbers' : require(__dirname + '/plugins/azure/entraid/passwordRequiresNumbers.js'), + 'passwordRequiresSymbols' : require(__dirname + '/plugins/azure/entraid/passwordRequiresSymbols.js'), + 'passwordRequiresUppercase' : require(__dirname + '/plugins/azure/entraid/passwordRequiresUppercase.js'), + 'minPasswordLength' : require(__dirname + '/plugins/azure/entraid/minPasswordLength.js'), + 'ensureNoGuestUser' : require(__dirname + '/plugins/azure/entraid/ensureNoGuestUser.js'), + 'noCustomOwnerRoles' : require(__dirname + '/plugins/azure/entraid/noCustomOwnerRoles.js'), + 'appOrgnaizationalDirectoryAccess' : require(__dirname + '/plugins/azure/entraid/appOrgnaizationalDirectoryAccess.js'), 'dbAuditingEnabled' : require(__dirname + '/plugins/azure/sqldatabases/dbAuditingEnabled.js'), 'dbDataMaskingEnabled' : require(__dirname + '/plugins/azure/sqldatabases/dbDataMaskingEnabled.js'), @@ -1076,6 +1088,7 @@ module.exports = { 'keyVaultHasTags' : require(__dirname + '/plugins/azure/keyvaults/keyVaultHasTags.js'), 'keyVaultsPrivateEndpoint' : require(__dirname + '/plugins/azure/keyvaults/keyVaultsPrivateEndpoint.js'), 'kvLogAnalyticsEnabled' : require(__dirname + '/plugins/azure/keyvaults/kvLogAnalyticsEnabled.js'), + 'keyVaultPublicAccess' : require(__dirname + '/plugins/azure/keyvaults/keyVaultPublicAccess.js'), 'advancedThreatProtection' : require(__dirname + '/plugins/azure/cosmosdb/advancedThreatProtection.js'), 'cosmosdbDiagnosticLogs' : require(__dirname + '/plugins/azure/cosmosdb/cosmosdbDiagnosticLogs.js'), @@ -1460,7 +1473,7 @@ module.exports = { 'imagesCMKEncrypted' : require(__dirname + '/plugins/google/compute/imagesCMKEncrypted.js'), 'snapshotEncryption' : require(__dirname + '/plugins/google/compute/snapshotEncryption.js'), 'instanceNetworkExposure' : require(__dirname + '/plugins/google/compute/instanceNetworkExposure.js'), - + 'computePrivilegeAnalysis' : require(__dirname + '/plugins/google/compute/computePrivilegeAnalysis.js'), 'keyRotation' : require(__dirname + '/plugins/google/cryptographickeys/keyRotation.js'), 'keyProtectionLevel' : require(__dirname + '/plugins/google/cryptographickeys/keyProtectionLevel.js'), 'kmsPublicAccess' : require(__dirname + '/plugins/google/cryptographickeys/kmsPublicAccess.js'), @@ -1569,7 +1582,7 @@ module.exports = { 'binaryAuthorizationEnabled' : require(__dirname + '/plugins/google/kubernetes/binaryAuthorizationEnabled.js'), 'clientCertificateDisabled' : require(__dirname + '/plugins/google/kubernetes/clientCertificateDisabled.js'), 'clusterNetworkExposure' : require(__dirname + '/plugins/google/kubernetes/clusterNetworkExposure.js'), - + 'kubernetesPrivilegeAnalysis' : require(__dirname + '/plugins/google/kubernetes/kubernetesPrivilegeAnalysis.js'), 'dnsSecEnabled' : require(__dirname + '/plugins/google/dns/dnsSecEnabled.js'), 'dnsSecSigningAlgorithm' : require(__dirname + '/plugins/google/dns/dnsSecSigningAlgorithm.js'), 'dnsZoneLabelsAdded' : require(__dirname + '/plugins/google/dns/dnsZoneLabelsAdded.js'), @@ -1608,9 +1621,9 @@ module.exports = { 'cloudFunctionLabelsAdded' : require(__dirname + '/plugins/google/cloudfunctions/cloudFunctionLabelsAdded.js'), 'cloudFunctionOldRuntime' : require(__dirname + '/plugins/google/cloudfunctions/cloudFunctionOldRuntime.js'), 'functionAllUsersPolicy' : require(__dirname + '/plugins/google/cloudfunctions/functionAllUsersPolicy.js'), - 'serverlessVPCAccess' : require(__dirname + '/plugins/google/cloudfunctions/serverlessVPCAccess.js'), - + 'cloudFunctionNetworkExposure' : require(__dirname + '/plugins/google/cloudfunctions/cloudFunctionNetworkExposure.js'), + 'cloudFunctionsPrivilegeAnalysis': require(__dirname + '/plugins/google/cloudfunctions/cloudFunctionsPrivilegeAnalysis.js'), 'computeAllowedExternalIPs' : require(__dirname + '/plugins/google/cloudresourcemanager/computeAllowedExternalIPs.js'), 'disableAutomaticIAMGrants' : require(__dirname + '/plugins/google/cloudresourcemanager/disableAutomaticIAMGrants.js'), 'disableGuestAttributes' : require(__dirname + '/plugins/google/cloudresourcemanager/disableGuestAttributes.js'), @@ -1740,4 +1753,4 @@ module.exports = { 'securityNotificationsEnabled' : require(__dirname + '/plugins/alibaba/securitycenter/securityNotificationsEnabled.js'), 'vulnerabilityScanEnabled' : require(__dirname + '/plugins/alibaba/securitycenter/vulnerabilityScanEnabled.js') } -}; +}; \ No newline at end of file diff --git a/helpers/asl/asl-1.js b/helpers/asl/asl-1.js index ac8420dc..1af0b082 100644 --- a/helpers/asl/asl-1.js +++ b/helpers/asl/asl-1.js @@ -1,30 +1,44 @@ var parse = function(obj, path, region, cloud, accountId, resourceId) { - //(Array.isArray(obj)) return [obj]; - if (typeof path == 'string' && path.includes('.')) path = path.split('.'); - if (Array.isArray(path) && path.length && typeof obj === 'object') { + // Enhanced path splitting: ensure [*] is always its own segment + if (typeof path === 'string') { + // Split on . but keep [*] as its own segment + // Example: networkAcls.ipRules[*].value => ['networkAcls', 'ipRules', '[*]', 'value'] + path = path + .replace(/\[\*\]/g, '.[$*].') // temporarily mark wildcards + .split('.') + .filter(Boolean) + .map(seg => seg === '[$*]' ? '[*]' : seg); // restore wildcard + } + + if (Array.isArray(path) && path.length) { var localPath = path.shift(); - if (localPath.includes('[*]')){ - localPath = localPath.split('[')[0]; - if (obj[localPath] && obj[localPath].length && obj[localPath].length === 1) { - if (!path || !path.length) { - return [obj[localPath][0], path]; - } else if (path.length === 1){ - return [obj[localPath],path[0]]; - //return parse(obj[localPath][0], path[0]); - } - } - if (path.length && path.join('.').includes('[*]')) { - return parse(obj[localPath], path); - } else if (!obj[localPath] || !obj[localPath].length) { - return ['not set']; + // Handle array wildcard syntax [*] + if (localPath === '[*]') { + if (Array.isArray(obj)) { + var results = []; + obj.forEach(function(item) { + var pathCopy = path.slice(); + var result = parse(item, pathCopy, region, cloud, accountId, resourceId); + if (Array.isArray(result)) { + results = results.concat(result); + } else if (result !== 'not set') { + results.push(result); + } + }); + return results.length > 0 ? results : 'not set'; + } else { + return 'not set'; } - return [obj[localPath], path]; } - if (obj[localPath] || typeof obj[localPath] === 'boolean') { - return parse(obj[localPath], path); - } else return ['not set']; + if (obj && Object.prototype.hasOwnProperty.call(obj, localPath)) { + return parse(obj[localPath], path, region, cloud, accountId, resourceId); + } else { + return 'not set'; + } + } else if (Array.isArray(path) && path.length === 0) { + return obj; } else if (!Array.isArray(obj) && path && path.length) { - if (obj[path]) return [obj[path]]; + if (obj[path] || typeof obj[path] === 'boolean') return obj[path]; else { if (cloud==='aws' && path.startsWith('arn:aws')) { const template_string = path; @@ -50,15 +64,201 @@ var parse = function(obj, path, region, cloud, accountId, resourceId) { } }); path = converted_string; - return [path]; - } else return ['not set']; + return path; + } else return 'not set'; } } else if (Array.isArray(obj)) { - return [obj]; + return obj; + } else { + return obj; + } +}; + +var inCidr = function(ip, cidr) { + if (!ip || !cidr || typeof ip !== 'string' || typeof cidr !== 'string') { + return { result: false, error: 'Malformed IP' }; + } + + ip = ip.trim(); + cidr = cidr.trim(); + + var isIpv6Cidr = cidr.includes(':'); + var isIpv6Ip = ip.includes(':'); + + if (isIpv6Cidr && !isIpv6Ip) { + return { result: false, error: 'Cannot check IPv4 address against IPv6 CIDR' }; + } + if (!isIpv6Cidr && isIpv6Ip) { + return { result: false, error: 'Cannot check IPv6 address against IPv4 CIDR' }; + } + + if (isIpv6Cidr && isIpv6Ip) { + return inCidrIPv6(ip, cidr); } else { - return [obj]; + return inCidrIPv4(ip, cidr); + } +}; + +var inCidrIPv4 = function(ip, cidr) { + var cidrMatch = cidr.match(/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/(\d{1,2})$/); + if (!cidrMatch) { + return { result: false, error: 'Malformed IP' }; + } + + var cidrIp = cidrMatch[1]; + var prefixLength = parseInt(cidrMatch[2]); + + var cidrParts = cidrIp.split('.').map(function(part) { return parseInt(part); }); + if (cidrParts.some(function(part) { return isNaN(part) || part < 0 || part > 255; }) || prefixLength < 0 || prefixLength > 32) { + return { result: false, error: 'Malformed IP' }; + } + + var ipMatch = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(\/\d{1,2})?$/); + if (!ipMatch) { + return { result: false, error: 'Malformed IP' }; + } + + var ipParts = ipMatch.slice(1, 5).map(function(part) { return parseInt(part); }); + if (ipParts.some(function(part) { return isNaN(part) || part < 0 || part > 255; })) { + return { result: false, error: 'Malformed IP' }; + } + + var cidrInt = ((cidrParts[0] << 24) + (cidrParts[1] << 16) + (cidrParts[2] << 8) + cidrParts[3]) >>> 0; + var ipInt = ((ipParts[0] << 24) + (ipParts[1] << 16) + (ipParts[2] << 8) + ipParts[3]) >>> 0; + + var mask = prefixLength === 0 ? 0 : (0xFFFFFFFF << (32 - prefixLength)) >>> 0; + var networkInt = (cidrInt & mask) >>> 0; + var broadcastInt = (networkInt | (0xFFFFFFFF >>> prefixLength)) >>> 0; + + var isInRange = ipInt >= networkInt && ipInt <= broadcastInt; + + var result = { + result: isInRange, + error: null, + message: isInRange ? 'IP in range' : 'IP not in range' + }; + + return result; +}; + +var inCidrIPv6 = function(ip, cidr) { + var cidrMatch = cidr.match(/^([0-9a-fA-F:]+)\/(\d{1,3})$/); + if (!cidrMatch) { + return { result: false, error: 'Malformed IP' }; + } + + var cidrIp = cidrMatch[1]; + var prefixLength = parseInt(cidrMatch[2]); + + if (prefixLength < 0 || prefixLength > 128) { + return { result: false, error: 'Malformed IP' }; + } + + var ipv6Pattern = /^[0-9a-fA-F:]+$/; + if (!ipv6Pattern.test(ip) || !ipv6Pattern.test(cidrIp)) { + return { result: false, error: 'Malformed IP' }; + } + + try { + var expandedCidr = expandIPv6Simple(cidrIp); + var expandedIp = expandIPv6Simple(ip); + + if (!expandedCidr || !expandedIp) { + return { result: false, error: 'Malformed IP' }; + } + + var fullNibbles = Math.floor(prefixLength / 4); + var remainingBits = prefixLength % 4; + + var cidrPrefix = expandedCidr.substring(0, fullNibbles); + var ipPrefix = expandedIp.substring(0, fullNibbles); + + var isInRange = ipPrefix === cidrPrefix; + + // Compare remaining bits if prefix is not nibble-aligned + if (isInRange && remainingBits > 0 && fullNibbles < expandedCidr.length) { + var cidrNibble = parseInt(expandedCidr.charAt(fullNibbles), 16); + var ipNibble = parseInt(expandedIp.charAt(fullNibbles), 16); + var mask = (0xF << (4 - remainingBits)) & 0xF; + isInRange = (cidrNibble & mask) === (ipNibble & mask); + } + + var result = { + result: isInRange, + error: null, + message: isInRange ? 'IP in range' : 'IP not in range' + }; + + return result; + } catch (e) { + return { result: false, error: 'Malformed IP' }; + } +}; + +var expandIPv6Simple = function(ip) { + try { + // Handle :: notation (simplified) + if (ip.includes('::')) { + var parts = ip.split('::'); + if (parts.length > 2) return null; + + var left = parts[0] ? parts[0].split(':') : []; + var right = parts[1] ? parts[1].split(':') : []; + + var totalParts = left.length + right.length; + var missingParts = 8 - totalParts; + + if (missingParts < 0) return null; + + var expanded = left.concat(Array(missingParts).fill('0000')).concat(right); + return expanded.map(function(part) { return part.padStart(4, '0'); }).join(''); + } else { + var ipParts = ip.split(':'); + if (ipParts.length !== 8) return null; + return ipParts.map(function(part) { return part.padStart(4, '0'); }).join(''); + } + } catch (e) { + return null; } }; + +var transformToIpRange = function(val) { + if (typeof val !== 'string') { + return { error: 'Value must be a string for IPRANGE transformation' }; + } + + var trimmedVal = val.trim(); + + var ipv4CidrPattern = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/(\d{1,2})$/; + var ipv4SinglePattern = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/; + var ipv6CidrPattern = /^([0-9a-fA-F:]+)\/(\d{1,3})$/; + var ipv6SinglePattern = /^[0-9a-fA-F:]+$/; + + var isValidFormat = ipv4CidrPattern.test(trimmedVal) || + ipv4SinglePattern.test(trimmedVal) || + ipv6CidrPattern.test(trimmedVal) || + ipv6SinglePattern.test(trimmedVal); + + if (!isValidFormat) { + return { error: 'Value must be a valid IP or CIDR format (e.g., "192.168.1.100" or "192.168.1.0/24")' }; + } + + var processedVal = trimmedVal; + if (ipv4SinglePattern.test(trimmedVal)) { + processedVal = trimmedVal + '/32'; + } else if (ipv6SinglePattern.test(trimmedVal) && !trimmedVal.includes('/')) { + processedVal = trimmedVal + '/128'; + } + + var result = { + type: 'iprange', + original: val, + cidr: processedVal + }; + + return result; +}; + var transform = function(val, transformation) { if (transformation == 'DATE') { return new Date(val); @@ -79,6 +279,8 @@ var transform = function(val, transformation) { return val; } else if (transformation == 'TOLOWERCASE') { return val.toLowerCase(); + } else if (transformation == 'IPRANGE') { + return transformToIpRange(val); } else { return val; } @@ -87,23 +289,55 @@ var transform = function(val, transformation) { var compositeResult = function(inputResultsArr, resource, region, results, logical) { let failingResults = []; let passingResults = []; + + // No results to process, exit early + if (!inputResultsArr || !inputResultsArr.length) { + results.push({ + status: 2, + resource: resource, + message: 'No results to evaluate', + region: region + }); + return; + } + + // If only one result, always use its status and message + if (inputResultsArr.length === 1) { + results.push({ + status: inputResultsArr[0].status, + resource: resource, + message: inputResultsArr[0].message, + region: region + }); + return; + } + inputResultsArr.forEach(localResult => { if (localResult.status === 2) { failingResults.push(localResult.message); } - if (localResult.status === 0) { passingResults.push(localResult.message); } }); if (!logical) { - results.push({ - status: inputResultsArr[0].status, - resource: resource, - message: inputResultsArr[0].message, - region: region - }); + // Default behavior: if any resource fails, overall result is FAIL + if (failingResults && failingResults.length) { + results.push({ + status: 2, + resource: resource, + message: failingResults.join(' and '), + region: region + }); + } else { + results.push({ + status: 0, + resource: resource, + message: passingResults.join(' and '), + region: region + }); + } } else if (logical === 'AND') { if (failingResults && failingResults.length) { results.push({ @@ -139,204 +373,12 @@ var compositeResult = function(inputResultsArr, resource, region, results, logic } }; -var validate = function(condition, conditionResult, inputResultsArr, message, property, parsed) { - - if (Array.isArray(property)){ - property = property[property.length-1]; - } - if (parsed && typeof parsed === 'object' && parsed[property]) { - condition.parsed = parsed[property]; - } - - if (condition.transform) { - try { - condition.parsed = transform(condition.parsed, condition.transform); - } catch (e) { - conditionResult = 2; - message.push(`${property}: unable to perform transformation`); - let resultObj = { - status: conditionResult, - message: message.join(', ') - }; - - inputResultsArr.push(resultObj); - return resultObj; - } - } - - // Compare the property with the operator - if (condition.op) { - let userRegex; - if (condition.op === 'MATCHES' || condition.op === 'NOTMATCHES') { - userRegex = new RegExp(condition.value); - } - if (condition.transform && condition.transform == 'EACH' && condition) { - if (condition.op == 'CONTAINS') { - var stringifiedCondition = JSON.stringify(condition.parsed); - if (condition.value && condition.value.includes(':')) { - var key = condition.value.split(/:(?!.*:)/)[0]; - var value = condition.value.split(/:(?!.*:)/)[1]; - - if (stringifiedCondition.includes(key) && stringifiedCondition.includes(value)){ - message.push(`${property}: ${condition.value} found in ${stringifiedCondition}`); - return 0; - } else { - message.push(`${condition.value} not found in ${stringifiedCondition}`); - return 2; - } - } else if (stringifiedCondition && stringifiedCondition.includes(condition.value)) { - message.push(`${property}: ${condition.value} found in ${stringifiedCondition}`); - return 0; - } else if (stringifiedCondition && stringifiedCondition.length){ - message.push(`${condition.value} not found in ${stringifiedCondition}`); - return 2; - } else { - message.push(`${condition.parsed} is not the right property type for this operation`); - return 2; - } - } else if (condition.op == 'NOTCONTAINS') { - var conditionStringified = JSON.stringify(condition.parsed); - if (condition.value && condition.value.includes(':')) { - - var conditionKey = condition.value.split(/:(?!.*:)/)[0]; - var conditionValue = condition.value.split(/:(?!.*:)/)[1]; - - if (conditionStringified.includes(conditionKey) && !conditionStringified.includes(conditionValue)){ - message.push(`${property}: ${condition.value} not found in ${conditionStringified}`); - return 0; - } else { - message.push(`${condition.value} found in ${conditionStringified}`); - return 2; - } - } else if (conditionStringified && !conditionStringified.includes(condition.value)) { - message.push(`${property}: ${condition.value} not found in ${conditionStringified}`); - return 0; - } else if (conditionStringified && conditionStringified.length){ - message.push(`${condition.value} found in ${conditionStringified}`); - return 2; - } else { - message.push(`${condition.parsed} is not the right property type for this operation`); - return 2; - } - } else { - // Recurse into the same function - var subProcessed = []; - if (!condition.parsed.length) { - conditionResult = 2; - message.push(`${property}: is not iterable using EACH transformation`); - } else { - condition.parsed.forEach(function(parsed) { - subProcessed.push(runValidation(parsed, condition, inputResultsArr)); - }); - subProcessed.forEach(function(sub) { - if (sub.status) conditionResult = sub.status; - if (sub.message) message.push(sub.message); - }); - } - } - } else if (condition.op == 'EQ') { - if (condition.parsed == condition.value) { - message.push(`${property}: ${condition.parsed} matched: ${condition.value}`); - return 0; - } else { - message.push(`${property}: ${condition.parsed} did not match: ${condition.value}`); - return 2; - } - } else if (condition.op == 'GT') { - if (condition.parsed > condition.value) { - message.push(`${property}: count of ${condition.parsed} was greater than: ${condition.value}`); - } else { - conditionResult = 2; - message.push(`${property}: count of ${condition.parsed} was not greater than: ${condition.value}`); - } - } else if (condition.op == 'NE') { - if (condition.parsed !== condition.value) { - message.push(`${property}: ${condition.parsed} is not: ${condition.value}`); - } else { - conditionResult = 2; - message.push(`${property}: ${condition.parsed} is: ${condition.value}`); - } - } else if (condition.op == 'MATCHES') { - if (userRegex.test(condition.parsed)) { - message.push(`${property}: ${condition.parsed} matches the regex: ${condition.value}`); - } else { - conditionResult = 2; - message.push(`${property}: ${condition.parsed} does not match the regex: ${condition.value}`); - } - } else if (condition.op == 'NOTMATCHES') { - if (!userRegex.test(condition.parsed)) { - message.push(`${condition.property}: ${condition.parsed} does not match the regex: ${condition.value}`); - } else { - conditionResult = 2; - message.push(`${condition.property}: ${condition.parsed} matches the regex : ${condition.value}`); - } - } else if (condition.op == 'EXISTS') { - if (condition.parsed !== 'not set') { - message.push(`${property}: set to ${condition.parsed}`); - return 0; - } else { - message.push(`${property}: ${condition.parsed}`); - return 2; - } - } else if (condition.op == 'ISTRUE') { - if (typeof condition.parsed == 'boolean' && condition.parsed) { - message.push(`${property} is true`); - return 0; - } else if (typeof condition.parsed == 'boolean' && !condition.parsed) { - conditionResult = 2; - message.push(`${property} is false`); - return 2; - } else { - message.push(`${property} is not a boolean value`); - return 2; - } - } else if (condition.op == 'ISFALSE') { - if (typeof condition.parsed == 'boolean' && !condition.parsed) { - message.push(`${property} is false`); - return 0; - } else if (typeof condition.parsed == 'boolean' && condition.parsed) { - conditionResult = 2; - message.push(`${property} is true`); - return 2; - } else { - message.push(`${property} is not a boolean value`); - return 2; - } - } else if (condition.op == 'CONTAINS') { - if (condition.parsed && condition.parsed.length && condition.parsed.includes(condition.value)) { - message.push(`${property}: ${condition.value} found in ${condition.parsed}`); - return 0; - } else if (condition.parsed && condition.parsed.length){ - message.push(`${condition.value} not found in ${condition.parsed}`); - return 2; - } else { - message.push(`${condition.parsed} is not the right property type for this operation`); - return 2; - } - } else if (condition.op == 'NOTCONTAINS') { - if (condition.parsed && condition.parsed.length && !condition.parsed.includes(condition.value)) { - message.push(`${property}: ${condition.value} not found in ${condition.parsed}`); - return 0; - } else if (condition.parsed && condition.parsed.length){ - message.push(`${condition.value} found in ${condition.parsed}`); - return 2; - } else { - message.push(`${condition.parsed} is not the right property type for this operation`); - return 2; - } - } - return conditionResult; - } -}; - -var runValidation = function(obj, condition, inputResultsArr, nestedResultArr) { - let result = 0; +var runValidation = function(obj, condition, inputResultsArr, nestedResultArr, region, cloud, accountId, resourceId) { let message = []; + let conditionResult = 0; // Initialize conditionResult at function level // Extract the values for the conditions if (condition.property) { - - let conditionResult = 0; let property; if (Array.isArray(condition.property)) { if (condition.property.length === 1) { @@ -347,87 +389,404 @@ var runValidation = function(obj, condition, inputResultsArr, nestedResultArr) { } else { property = condition.property; } - condition.parsed = parse(obj, condition.property)[0]; + + // Handle AliasTarget special cases + if (typeof property === 'string' && property.includes('AliasTarget')) { + const propertyParts = property.split('.'); + const aliasProperty = propertyParts.length > 1 ? propertyParts[1] : null; + + if (obj && obj.AliasTarget) { + if (aliasProperty && obj.AliasTarget[aliasProperty] !== undefined) { + condition.parsed = obj.AliasTarget[aliasProperty]; + } else if (!aliasProperty) { + condition.parsed = obj.AliasTarget; + } else { + condition.parsed = 'not set'; + } + } else { + condition.parsed = 'not set'; + } + } else { + const parseResult = parse(obj, condition.property, region, cloud, accountId, resourceId); + condition.parsed = parseResult; + } - // if ( Array.isArray(obj)) { - // condition.parsed = obj; - // } else { - // condition.parsed = parse(obj, condition.property)[0]; - // } + // Normalize: if property is wildcard and parse returned 'not set', treat as ['not set'] + if ((Array.isArray(condition.property) ? condition.property.join('.') : condition.property).includes('[*]') && condition.parsed === 'not set') { + condition.parsed = ['not set']; + } - if ((typeof condition.parsed !== 'boolean' && !condition.parsed)|| condition.parsed === 'not set'){ - conditionResult = 2; - message.push(`${property}: not set to any value`); + // Transform the property if required (except for IPRANGE which transforms the value) + if (condition.transform && condition.transform !== 'IPRANGE') { + try { + condition.parsed = transform(condition.parsed, condition.transform); + } catch (e) { + conditionResult = 2; + message.push(`${property}: unable to perform transformation`); + let resultObj = { + status: conditionResult, + message: message.join(', ') + }; - let resultObj = { - status: conditionResult, - message: message.join(', ') - }; + inputResultsArr.push(resultObj); + return resultObj; + } + } - inputResultsArr.push(resultObj); - return resultObj; + if (condition.parsed === 'not set'){ + conditionResult = 2; + message.push(`${condition.property}: not set to any value`); + } else if ((typeof condition.parsed !== 'boolean' && !condition.parsed) && !Array.isArray(condition.parsed)){ + conditionResult = 2; + message.push(`${property}: not set to any value`); } - if (property.includes('[*]')) { + // Compare the property with the operator + if (condition.op) { + let userRegex; + if (condition.op === 'MATCHES' || condition.op === 'NOTMATCHES') { + userRegex = new RegExp(condition.value); + } + + // Handle arrays returned by parse function (from wildcard paths) if (Array.isArray(condition.parsed)) { - if (!Array.isArray(nestedResultArr)) nestedResultArr = []; - let propertyArr = property.split('.'); - propertyArr.shift(); - property = propertyArr.join('.'); - condition.property = property; - if (condition.op !== 'CONTAINS' || condition.op !== 'NOTCONTAINS') { - condition.parsed.forEach(parsed => { - if (property.includes('[*]')) { - runValidation(parsed, condition, inputResultsArr, nestedResultArr); - } else { - let localConditionResult = validate(condition, conditionResult, inputResultsArr, message, property, parsed); - nestedResultArr.push(localConditionResult); - } - // [0,2,0,2,0,0,2,2] - }); - } else { - runValidation(condition.parsed, condition, inputResultsArr, nestedResultArr); - } - // NestedCompositeResult - if (nestedResultArr && nestedResultArr.length) { - if (!condition.nested) condition.nested = 'ONE'; - let resultObj; - if ((condition.nested.toUpperCase() === 'ONE' && nestedResultArr.indexOf(0) > -1) || (condition.nested.toUpperCase() === 'ALL' && nestedResultArr.indexOf(2) === 0)) { - resultObj = { - status: 0, - message: message.join(', ') - }; + let anyMatch = false; + let anyNotSet = false; + let allNotSet = true; + let arrayMessages = []; + condition.parsed.forEach(function(item, index) { + let itemMatch = false; + if (item === 'not set') { + arrayMessages.push(`Item ${index}: not set`); + anyNotSet = true; } else { - resultObj = { - status: 2, - message: message.join(', ') - }; + allNotSet = false; + } + if (condition.op && item !== 'not set') { + if (condition.op == 'EQ') { + itemMatch = (item == condition.value); + } else if (condition.op == 'NE') { + itemMatch = (item !== condition.value); + } else if (condition.op == 'CONTAINS') { + if (condition.transform == 'IPRANGE') { + var valueRange = transformToIpRange(condition.value); + if (valueRange.error) { + arrayMessages.push('Item ' + index + ': ' + valueRange.error); + itemMatch = false; + } else { + var cidrResult = inCidr(condition.value, item); + if (cidrResult.error) { + arrayMessages.push('Item ' + index + ': ' + cidrResult.error); + itemMatch = false; + } else { + itemMatch = cidrResult.result; + var resultMsg = cidrResult.result ? 'allows access from ' + condition.value : 'does not allow access from ' + condition.value; + arrayMessages.push('Item ' + index + ': ' + item + ' ' + resultMsg); + } + } + } else { + itemMatch = (item && item.includes && item.includes(condition.value)); + } + } else if (condition.op == 'MATCHES') { + let userRegex = RegExp(condition.value); + itemMatch = userRegex.test(item); + } else if (condition.op == 'EXISTS') { + itemMatch = (item !== 'not set'); + } else if (condition.op == 'ISTRUE') { + itemMatch = !!item; + } else if (condition.op == 'ISFALSE') { + itemMatch = !item; + } else if (condition.op == 'ISEMPTY') { + if (item === 'not set') { + itemMatch = false; + arrayMessages.push(`Item ${index}: not set`); + } else if (typeof item === 'boolean' || typeof item === 'number') { + itemMatch = false; + arrayMessages.push(`Item ${index}: is of type ${typeof item}, which cannot be empty`); + } else { + itemMatch = (item === '' || (Array.isArray(item) && item.length === 0) || + (typeof item === 'object' && item !== null && Object.keys(item).length === 0)); + } + } + } + if (itemMatch) { + arrayMessages.push(`Item ${index}: ${item} matched condition`); + anyMatch = true; + } else if (item !== 'not set') { + arrayMessages.push(`Item ${index}: ${item} did not match condition`); } + }); + if (condition.parsed.length === 0 || allNotSet) { + message.push(`${condition.property}: ${arrayMessages.join(', ')}`); + let resultObj = { + status: 2, // FAIL if array is empty or all items are not set (property missing everywhere) + message: message.join(', ') + }; + inputResultsArr.push(resultObj); + return resultObj; + } else if (anyMatch) { + message.push(`${condition.property}: ${arrayMessages.join(', ')}`); + let resultObj = { + status: 0, // PASS if any item matches and at least one is set + message: message.join(', ') + }; + inputResultsArr.push(resultObj); + return resultObj; + } else if (anyNotSet) { + message.push(`${condition.property}: ${arrayMessages.join(', ')}`); + let resultObj = { + status: 2, // FAIL if any item is not set + message: message.join(', ') + }; + inputResultsArr.push(resultObj); + return resultObj; + } else { + message.push(`${condition.property}: ${arrayMessages.join(', ')}`); + let resultObj = { + status: 2, // FAIL if none match and all are set + message: message.join(', ') + }; inputResultsArr.push(resultObj); return resultObj; } - } else { - if (!Array.isArray(nestedResultArr)) nestedResultArr = []; - let propertyArr = property.split('.'); - propertyArr.shift(); - property = propertyArr.join('.'); - let localConditionResult = validate(condition, conditionResult, inputResultsArr, message, condition.property, condition.parsed); - - let resultObj = { - status: localConditionResult, - message: message.join(', ') - }; + } + if (condition.transform && condition.transform == 'EACH' && condition) { + if (condition.op == 'CONTAINS') { + var stringifiedCondition = JSON.stringify(condition.parsed); + if (condition.value && condition.value.includes(':')) { + var key = condition.value.split(/:(?!.*:)/)[0]; + var value = condition.value.split(/:(?!.*:)/)[1]; + if (stringifiedCondition.includes(key) && stringifiedCondition.includes(value)){ + message.push(`${property}: ${condition.value} found in ${stringifiedCondition}`); + conditionResult = 0; + } else { + message.push(`${condition.value} not found in ${stringifiedCondition}`); + conditionResult = 2; + } + } else if (stringifiedCondition && stringifiedCondition.includes(condition.value)) { + message.push(`${property}: ${condition.value} found in ${stringifiedCondition}`); + conditionResult = 0; + } else if (stringifiedCondition && stringifiedCondition.length){ + message.push(`${condition.value} not found in ${stringifiedCondition}`); + conditionResult = 2; + } else { + message.push(`${condition.parsed} is not the right property type for this operation`); + conditionResult = 2; + } + } else if (condition.op == 'NOTCONTAINS') { + var conditionStringified = JSON.stringify(condition.parsed); + if (condition.value && condition.value.includes(':')) { - inputResultsArr.push(resultObj); - return resultObj; + var conditionKey = condition.value.split(/:(?!.*:)/)[0]; + var conditionValue = condition.value.split(/:(?!.*:)/)[1]; + if (conditionStringified.includes(conditionKey) && !conditionStringified.includes(conditionValue)){ + message.push(`${property}: ${condition.value} not found in ${conditionStringified}`); + return 0; + } else { + message.push(`${condition.value} found in ${conditionStringified}`); + return 2; + } + } else if (conditionStringified && !conditionStringified.includes(condition.value)) { + message.push(`${property}: ${condition.value} not found in ${conditionStringified}`); + return 0; + } else if (conditionStringified && conditionStringified.length){ + message.push(`${condition.value} found in ${conditionStringified}`); + return 2; + } else { + message.push(`${condition.parsed} is not the right property type for this operation`); + return 2; + } + } else { + // Recurse into the same function + var subProcessed = []; + if (!condition.parsed.length) { + conditionResult = 2; + message.push(`${property}: is not iterable using EACH transformation`); + } else { + condition.parsed.forEach(function(parsed) { + subProcessed.push(runValidation(parsed, condition, inputResultsArr, null, region, cloud, accountId, resourceId)); + }); + subProcessed.forEach(function(sub) { + if (sub.status) conditionResult = sub.status; + if (sub.message) message.push(sub.message); + }); + } + } + } else if (condition.op == 'EQ') { + if (condition.parsed == condition.value) { + message.push(`${property}: ${condition.parsed} matched: ${condition.value}`); + conditionResult = 0; + } else { + // Check if we're comparing an object to a string - common user error + if (typeof condition.parsed === 'object' && condition.parsed !== null && typeof condition.value === 'string') { + message.push(`${property}: is an object but compared to string "${condition.value}". Consider using a more specific property path like "${property}.propertyName"`); + } else { + message.push(`${property}: ${condition.parsed} did not match: ${condition.value}`); + } + conditionResult = 2; + } + } else if (condition.op == 'GT') { + // Convert to numbers for comparison if they are numeric strings + let parsedVal = condition.parsed; + let comparisonVal = condition.value; + + // Force numeric conversion + parsedVal = Number(parsedVal); + comparisonVal = Number(comparisonVal); + + if (parsedVal > comparisonVal) { + message.push(`${property}: count of ${condition.parsed} was greater than: ${condition.value}`); + conditionResult = 0; + } else { + conditionResult = 2; + message.push(`${property}: count of ${condition.parsed} was not greater than: ${condition.value}`); + } + } else if (condition.op == 'LT') { + // Convert to numbers for comparison if they are numeric strings + let parsedVal = condition.parsed; + let comparisonVal = condition.value; + + // Force numeric conversion + parsedVal = Number(parsedVal); + comparisonVal = Number(comparisonVal); + + if (parsedVal < comparisonVal) { + message.push(`${property}: count of ${condition.parsed} was less than: ${condition.value}`); + conditionResult = 0; + } else { + conditionResult = 2; + message.push(`${property}: count of ${condition.parsed} was not less than: ${condition.value}`); + } + } else if (condition.op == 'NE') { + if (condition.parsed !== condition.value) { + message.push(`${property}: ${condition.parsed} is not: ${condition.value}`); + conditionResult = 0; + } else { + conditionResult = 2; + // Check if we're comparing an object to a string - common user error + if (typeof condition.parsed === 'object' && condition.parsed !== null && typeof condition.value === 'string') { + message.push(`${property}: is an object but compared to string "${condition.value}". Consider using a more specific property path like "${property}.propertyName"`); + } else { + message.push(`${property}: ${condition.parsed} is: ${condition.value}`); + } + } + } else if (condition.op == 'MATCHES') { + if (userRegex.test(condition.parsed)) { + message.push(`${property}: ${condition.parsed} matches the regex: ${condition.value}`); + conditionResult = 0; + } else { + conditionResult = 2; + message.push(`${property}: ${condition.parsed} does not match the regex: ${condition.value}`); + } + } else if (condition.op == 'NOTMATCHES') { + if (!userRegex.test(condition.parsed)) { + message.push(`${condition.property}: ${condition.parsed} does not match the regex: ${condition.value}`); + conditionResult = 0; + } else { + conditionResult = 2; + message.push(`${condition.property}: ${condition.parsed} matches the regex : ${condition.value}`); + } + } else if (condition.op == 'EXISTS') { + if (condition.parsed !== 'not set') { + message.push(`${property}: set to ${condition.parsed}`); + conditionResult = 0; + } else { + message.push(`${property}: ${condition.parsed}`); + conditionResult = 2; + } + } else if (condition.op == 'ISTRUE') { + if (typeof condition.parsed == 'boolean' && condition.parsed) { + message.push(`${property} is true`); + conditionResult = 0; + } else if (typeof condition.parsed == 'boolean' && !condition.parsed) { + conditionResult = 2; + message.push(`${property} is false`); + } else { + message.push(`${property} is not a boolean value`); + conditionResult = 2; + } + } else if (condition.op == 'ISFALSE') { + if (typeof condition.parsed == 'boolean' && !condition.parsed) { + message.push(`${property} is false`); + conditionResult = 0; + } else if (typeof condition.parsed == 'boolean' && condition.parsed) { + conditionResult = 2; + message.push(`${property} is true`); + } else { + message.push(`${property} is not a boolean value`); + conditionResult = 2; + } + } else if (condition.op == 'ISEMPTY') { + if (condition.parsed === 'not set') { + message.push(`${property} is not set`); + conditionResult = 2; + } else if (typeof condition.parsed === 'boolean' || typeof condition.parsed === 'number') { + message.push(`${property} is of type ${typeof condition.parsed}, which cannot be empty`); + conditionResult = 2; + } else if (condition.parsed === '' || + (Array.isArray(condition.parsed) && condition.parsed.length === 0) || + (typeof condition.parsed === 'object' && condition.parsed !== null && Object.keys(condition.parsed).length === 0)) { + message.push(`${property} is empty`); + conditionResult = 0; + } else { + message.push(`${property} is not empty`); + conditionResult = 2; + } + } else if (condition.op == 'CONTAINS' && condition.transform == 'IPRANGE') { + if (typeof condition.parsed !== 'string') { + message.push(property + ': IPRANGE requires property to be an IP address string, got ' + typeof condition.parsed); + conditionResult = 2; + } else { + var valueRange = transformToIpRange(condition.value); + if (valueRange.error) { + message.push(property + ': ' + valueRange.error); + conditionResult = 2; + } else { + var cidrResult = inCidr(condition.value, condition.parsed); + + if (cidrResult.error) { + message.push(property + ': ' + cidrResult.error); + conditionResult = 2; + } else if (cidrResult.result) { + message.push(property + ': ' + cidrResult.message + ' (' + condition.parsed + ' allows access from ' + condition.value + ')'); + conditionResult = 0; + } else { + message.push(property + ': ' + cidrResult.message + ' (' + condition.parsed + ' does not allow access from ' + condition.value + ')'); + conditionResult = 2; + } + } + } + } else if (condition.op == 'CONTAINS') { + if (condition.parsed && condition.parsed.length && condition.parsed.includes(condition.value)) { + message.push(`${property}: ${condition.value} found in ${condition.parsed}`); + conditionResult = 0; + } else if (condition.parsed && condition.parsed.length){ + message.push(`${condition.value} not found in ${condition.parsed}`); + conditionResult = 2; + } else { + // Check if we're trying to use CONTAINS on an object - common user error + if (typeof condition.parsed === 'object' && condition.parsed !== null && !Array.isArray(condition.parsed)) { + message.push(`${property}: is an object, not a string or array. CONTAINS operation requires a string or array. Consider using a more specific property path like "${property}.propertyName"`); + } else { + message.push(`${condition.parsed} is not the right property type for this operation`); + } + conditionResult = 2; + } + } else if (condition.op == 'NOTCONTAINS') { + if (condition.parsed && condition.parsed.length && !condition.parsed.includes(condition.value)) { + message.push(`${property}: ${condition.value} not found in ${condition.parsed}`); + conditionResult = 0; + } else if (condition.parsed && condition.parsed.length){ + message.push(`${condition.value} found in ${condition.parsed}`); + conditionResult = 2; + } else { + message.push(`${condition.parsed} is not the right property type for this operation`); + conditionResult = 2; + } } - } else { - // Transform the property if required - conditionResult = validate(condition, conditionResult, inputResultsArr, message, property); - if (conditionResult) result = conditionResult; } } @@ -436,7 +795,7 @@ var runValidation = function(obj, condition, inputResultsArr, nestedResultArr) { } let resultObj = { - status: result, + status: conditionResult, message: message.join(', ') }; @@ -445,92 +804,283 @@ var runValidation = function(obj, condition, inputResultsArr, nestedResultArr) { }; var runConditions = function(input, data, results, resourcePath, resourceName, region, cloud, accountId) { - let dataToValidate; - let newPath; - let newData; - let validated; let parsedResource = resourceName; - let inputResultsArr = []; let logical; let localInput = JSON.parse(JSON.stringify(input)); // to check if top level * matches. ex: Instances[*] should be // present in each condition if not its impossible to compare resources - let resourceConditionArr = []; + localInput.conditions.forEach(condition => { logical = condition.logical; - var conditionPropArr = condition.property.split('.'); - if (condition.property && condition.property.includes('[*]')) { - if (conditionPropArr.length > 1 && conditionPropArr[1].includes('[*]')) { - resourceConditionArr.push(conditionPropArr[0]); - var firstProperty = conditionPropArr.shift(); - dataToValidate = parse(data, firstProperty.split('[*]')[0])[0]; - condition.property = conditionPropArr.join('.'); - if (dataToValidate.length) { - dataToValidate.forEach(newData => { - condition.validated = runValidation(newData, condition, inputResultsArr); - parsedResource = parse(newData, resourcePath, region, cloud, accountId, resourceName)[0]; - if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = resourceName; - }); - } else { - condition.validated = runValidation([], condition, inputResultsArr); - parsedResource = parse([], resourcePath, region, cloud, accountId, resourceName)[0]; - if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = resourceName; - } - // result per resource - } else { - dataToValidate = parse(data, condition.property); - newPath = dataToValidate[1]; - newData = dataToValidate[0]; - if (newPath && newData.length){ - newData.forEach(dataElm =>{ - if (newPath) condition.property = JSON.parse(JSON.stringify(newPath)); - condition.validated = runValidation(dataElm, condition, inputResultsArr); - parsedResource = parse(dataElm, resourcePath, region, cloud, accountId, resourceName)[0]; - if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = resourceName; - }); - } else if (newPath && !newData.length) { - condition.property = JSON.parse(JSON.stringify(newPath)); - condition.validated = runValidation(newData, condition, inputResultsArr); - parsedResource = parse(newData, resourcePath, region, cloud, accountId, resourceName)[0]; - if (parsedResource === 'not set' || typeof parsedResource !== 'string') parsedResource = resourceName; - } else if (!newPath) { - // no path returned. means it has fully parsed and got the value. - // save the value - newPath = JSON.parse(JSON.stringify(condition.property)); - if (condition.property.includes('.')){ - condition.property = condition.property.split('.')[condition.property.split('.').length -1 ]; + + // Special handling for ResourceRecordSets[*].AliasTarget.* properties + if (condition.property && condition.property.includes('ResourceRecordSets[*].AliasTarget')) { + let foundMatch = false; + let matchResults = []; + let nonMatchResults = []; + + if (data && data.ResourceRecordSets && Array.isArray(data.ResourceRecordSets)) { + // Directly access ResourceRecordSets if it exists at the top level + for (let i = 0; i < data.ResourceRecordSets.length; i++) { + let record = data.ResourceRecordSets[i]; + if (record && record.AliasTarget) { + // Extract just the AliasTarget part of the property path + const aliasProperty = condition.property.split('AliasTarget.')[1]; + + if (aliasProperty && record.AliasTarget[aliasProperty]) { + let propValue = record.AliasTarget[aliasProperty]; + let result = 2; // Default to fail + let message = ''; + + // Perform the actual comparison + if (condition.op === 'CONTAINS' && + (typeof propValue === 'string' || Array.isArray(propValue)) && + propValue.includes(condition.value)) { + result = 0; + message = `${aliasProperty}: ${condition.value} found in ${propValue}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'NOTCONTAINS' && !propValue.includes(condition.value)) { + result = 0; + message = `${aliasProperty}: ${condition.value} not found in ${propValue}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'EQ' && propValue === condition.value) { + result = 0; + message = `${aliasProperty}: ${propValue} matched: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'NE' && propValue !== condition.value) { + result = 0; + message = `${aliasProperty}: ${propValue} is not: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'GT') { + // Convert to numbers for comparison if they are numeric strings + let parsedVal = Number(propValue); + let comparisonVal = Number(condition.value); + + if (!isNaN(parsedVal) && !isNaN(comparisonVal) && parsedVal > comparisonVal) { + result = 0; + message = `${aliasProperty}: ${propValue} was greater than: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + message = `${aliasProperty}: ${propValue} was not greater than: ${condition.value}`; + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (condition.op === 'LT') { + // Convert to numbers for comparison if they are numeric strings + let parsedVal = Number(propValue); + let comparisonVal = Number(condition.value); + + if (!isNaN(parsedVal) && !isNaN(comparisonVal) && parsedVal < comparisonVal) { + result = 0; + message = `${aliasProperty}: ${propValue} was less than: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + message = `${aliasProperty}: ${propValue} was not less than: ${condition.value}`; + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (condition.op === 'ISTRUE') { + if (typeof propValue === 'boolean' && propValue === true) { + result = 0; + message = `${aliasProperty} is true`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (typeof propValue === 'string' && + (propValue.toLowerCase() === 'true' || propValue === '1')) { + result = 0; + message = `${aliasProperty} is true (${propValue})`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + message = `${aliasProperty} is not true`; + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (condition.op === 'ISFALSE') { + if (typeof propValue === 'boolean' && propValue === false) { + result = 0; + message = `${aliasProperty} is false`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (typeof propValue === 'string' && + (propValue.toLowerCase() === 'false' || propValue === '0')) { + result = 0; + message = `${aliasProperty} is false (${propValue})`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + message = `${aliasProperty} is not false`; + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (condition.op === 'EXISTS') { + result = 0; + message = `${aliasProperty}: set to ${propValue}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'MATCHES' && new RegExp(condition.value).test(propValue)) { + result = 0; + message = `${aliasProperty}: ${propValue} matches the regex: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else if (condition.op === 'NOTMATCHES' && !new RegExp(condition.value).test(propValue)) { + result = 0; + message = `${aliasProperty}: ${propValue} does not match the regex: ${condition.value}`; + foundMatch = true; + matchResults.push({ + status: result, + message: message, + resource: record.Name || resourceName + }); + } else { + if (condition.op === 'CONTAINS') { + message = `${condition.value} not found in ${propValue}`; + } else if (condition.op === 'NOTCONTAINS') { + message = `${condition.value} found in ${propValue}`; + } else if (condition.op === 'EQ') { + message = `${aliasProperty}: ${propValue} did not match: ${condition.value}`; + } else if (condition.op === 'NE') { + message = `${aliasProperty}: ${propValue} is: ${condition.value}`; + } else if (condition.op === 'GT') { + message = `${aliasProperty}: ${propValue} was not greater than: ${condition.value}`; + } else if (condition.op === 'LT') { + message = `${aliasProperty}: ${propValue} was not less than: ${condition.value}`; + } else if (condition.op === 'ISTRUE') { + message = `${aliasProperty} is not true`; + } else if (condition.op === 'ISFALSE') { + message = `${aliasProperty} is not false`; + } else if (condition.op === 'MATCHES') { + message = `${aliasProperty}: ${propValue} does not match the regex: ${condition.value}`; + } else if (condition.op === 'NOTMATCHES') { + message = `${aliasProperty}: ${propValue} matches the regex: ${condition.value}`; + } + + nonMatchResults.push({ + status: 2, + message: message, + resource: record.Name || resourceName + }); + } + } else if (!aliasProperty) { + // Handle the entire AliasTarget object + matchResults.push({ + status: 0, + message: `AliasTarget: exists for record ${record.Name}`, + resource: record.Name || resourceName + }); + foundMatch = true; + } } - condition.validated = runValidation(newData, condition, inputResultsArr); - condition.property = JSON.parse(JSON.stringify(newPath)); - parsedResource = parse(newData, resourcePath, region, cloud, accountId, resourceName)[0]; - if (parsedResource === 'not set' || typeof parsedResource !== 'string') parsedResource = resourceName; } } - } else { - dataToValidate = parse(data, condition.property); - if (dataToValidate.length === 1) { - validated = runValidation(data, condition, inputResultsArr); - parsedResource = parse(data, resourcePath, region, cloud, accountId, resourceName)[0]; - if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = resourceName; - } else { - newPath = dataToValidate[1]; - newData = dataToValidate[0]; - condition.property = newPath; - newData.forEach(element =>{ - condition.validated = runValidation(element, condition, inputResultsArr); - parsedResource = parse(data, resourcePath, region, cloud, accountId, resourceName)[0]; - if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = null; - - results.push({ - status: validated.status, - resource: parsedResource ? parsedResource : resourceName, - message: validated.message, - region: region + + // After checking all records, add the appropriate results to inputResultsArr + if (foundMatch) { + // If any record matched, add all matching results + matchResults.forEach(result => { + inputResultsArr.push({ + status: result.status, + message: result.message }); + parsedResource = result.resource; }); + } else { + // If no records matched, add a failure result + if (nonMatchResults.length > 0) { + // Use the first non-matching result as the representative failure + inputResultsArr.push({ + status: 2, + message: nonMatchResults[0].message + }); + parsedResource = nonMatchResults[0].resource; + } else { + // No records with AliasTarget found + inputResultsArr.push({ + status: 2, + message: `No matching records with AliasTarget.${condition.property.split('AliasTarget.')[1] || ''} found` + }); + } } + } else if (condition.property && condition.property.includes('[*]')) { + // For wildcard properties, parse once and validate the result + const parseResult = parse(data, condition.property, region, cloud, accountId, resourceName); + condition.parsed = parseResult; + condition.validated = runValidation(data, condition, inputResultsArr, null, region, cloud, accountId, resourceName); + parsedResource = parse(data, resourcePath, region, cloud, accountId, resourceName); + if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = resourceName; + } else { + // For non-wildcard properties, use the same logic as wildcard + condition.validated = runValidation(data, condition, inputResultsArr, null, region, cloud, accountId, resourceName); + parsedResource = parse(data, resourcePath, region, cloud, accountId, resourceName); + if (typeof parsedResource !== 'string' || parsedResource === 'not set') parsedResource = resourceName; } }); @@ -568,13 +1118,13 @@ var asl = function(source, input, resourceMap, cloud, accountId, callback) { message: regionVal.err.message || 'Error', region: region }); - } else if (regionVal.data && regionVal.data.length) { + } else if (regionVal.data && regionVal.data.length) { regionVal.data.forEach(function(regionData) { var resourceName = parse(regionData, resourcePath, region, cloud, accountId)[0]; runConditions(input, regionData, results, resourcePath, resourceName, region, cloud, accountId); }); } else if (regionVal.data && Object.keys(regionVal.data).length) { - runConditions(input, regionVal.data, results, resourcePath, '', region); + runConditions(input, regionVal.data, results, resourcePath, '', region, cloud, accountId); } else { if (!Object.keys(regionVal).length || (regionVal.data && (!regionVal.data.length || !Object.keys(regionVal.data).length))) { results.push({ @@ -599,7 +1149,6 @@ var asl = function(source, input, resourceMap, cloud, accountId, callback) { runConditions(input, regionData, results, resourcePath, resourceName, region, cloud, accountId); }); } else { - runConditions(input, resourceObj.data, results, resourcePath, resourceName, region, cloud, accountId); } } @@ -611,4 +1160,4 @@ var asl = function(source, input, resourceMap, cloud, accountId, callback) { callback(null, results, data); }; -module.exports = asl; +module.exports = asl; \ No newline at end of file diff --git a/helpers/aws/api.js b/helpers/aws/api.js index 3cc10d91..1f328c90 100644 --- a/helpers/aws/api.js +++ b/helpers/aws/api.js @@ -144,15 +144,6 @@ var serviceMap = { BridgeResourceNameIdentifier: 'DomainName', BridgeExecutionService: 'ES', BridgeCollectionService: 'es', DataIdentifier: 'DomainStatus', }, - 'QLDB': - { - enabled: true, isSingleSource: true, InvAsset: 'ledger', InvService: 'qldb', - InvResourceCategory: 'database', InvResourceType: 'qldb_ledger', BridgeServiceName: 'qldb', - BridgePluginCategoryName: 'QLDB', BridgeProvider: 'aws', BridgeCall: 'describeLedger', - BridgeArnIdentifier: 'Arn', BridgeIdTemplate: '', BridgeResourceType: 'ledger', - BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'QLDB', - BridgeCollectionService: 'qldb', DataIdentifier: 'data', - }, 'DynamoDB': { enabled: true, isSingleSource: true, InvAsset: 'table', InvService: 'dynamodb', @@ -216,6 +207,7 @@ var serviceMap = { BridgeResourceNameIdentifier: 'logGroupName', BridgeExecutionService: 'CloudWatchLogs', BridgeCollectionService: 'cloudwatchlogs', DataIdentifier: 'data', }, + 'EventBridge': { enabled: true, isSingleSource: true, InvAsset: 'bus', InvService: 'eventbridge', @@ -225,6 +217,15 @@ var serviceMap = { BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'EventBridge', BridgeCollectionService: 'eventbridge', DataIdentifier: 'data', }, + 'ECR': + { + enabled: true, isSingleSource: true, InvAsset: 'registry', InvService: 'ecr', + InvResourceCategory: 'cloud_resources', InvResourceType: 'ecr_repository', + BridgeServiceName: 'ecr', BridgePluginCategoryName: 'ECR', BridgeProvider: 'aws', BridgeCall: 'describeRepositories', + BridgeArnIdentifier: 'repositoryArn', BridgeIdTemplate: '', BridgeResourceType: 'repository', + BridgeResourceNameIdentifier:'repositoryName' , BridgeExecutionService: 'ECR', + BridgeCollectionService: 'ecr', DataIdentifier: 'data', + }, 'App Mesh': { enabled: true, isSingleSource: true, InvAsset: 'mesh', InvService: 'appmesh', @@ -461,15 +462,6 @@ var serviceMap = { BridgeResourceNameIdentifier: 'EnvironmentName', BridgeExecutionService: 'ElasticBeanstalk', BridgeCollectionService: 'elasticbeanstalk', BridgeCall: 'describeEnvironments', DataIdentifier: 'data', }, - 'Elastic Transcoder': - { - enabled: true, isSingleSource: true, InvAsset: 'transcoder', InvService: 'elasticTranscoder', - InvResourceCategory: 'cloud_resources', InvResourceType: 'transcoder pipeline', - BridgeProvider: 'aws', BridgeServiceName: 'elastictranscoder', BridgePluginCategoryName: 'Elastic Transcoder', - BridgeArnIdentifier: 'Arn', BridgeIdTemplate: '', BridgeResourceType: 'pipeline', - BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'Elastic Transcoder', - BridgeCollectionService: 'elastictranscoder', BridgeCall: 'listPipelines', DataIdentifier: 'data', - }, 'ELBv2': { enabled: true, isSingleSource: true, InvAsset: 'loadbalancer', InvService: 'elbv2', @@ -557,7 +549,7 @@ var serviceMap = { InvResourceCategory: 'ai&ml', InvResourceType: 'Lookout Metrics', BridgeProvider: 'aws', BridgeServiceName: 'lookoutmetrics', BridgePluginCategoryName: 'AI & ML', BridgeArnIdentifier: 'AnomalyDetectorArn', BridgeIdTemplate: '', - BridgeResourceType: 'lookoutmetrics', BridgeResourceNameIdentifier: 'AnomalyDetectorName', BridgeExecutionService: 'AI & ML', + BridgeResourceType: 'AnomalyDetector', BridgeResourceNameIdentifier: 'AnomalyDetectorName', BridgeExecutionService: 'AI & ML', BridgeCollectionService: 'lookoutmetrics', BridgeCall: 'listAnomalyDetectors', DataIdentifier: 'data', }, { @@ -569,6 +561,123 @@ var serviceMap = { BridgeCollectionService: 'sagemaker', BridgeCall: 'describeNotebookInstance', DataIdentifier: 'data', }, ], + 'Guard Duty': + { + enabled: true, isSingleSource: true, InvAsset: 'detector', InvService: 'guardduty', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Guardduty Detector', + BridgeProvider: 'aws', BridgeServiceName: 'guardduty', BridgePluginCategoryName: 'GuardDuty', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:guardduty:{region}:{cloudAccount}:detector/{id}', BridgeResourceType: 'detector', + BridgeResourceNameIdentifier: 'id', BridgeExecutionService: 'GuardDuty', + BridgeCollectionService: 'guardduty', BridgeCall: 'getDetector', DataIdentifier: 'data', + }, + 'WorkSpaces': + { + enabled: true, isSingleSource: true, InvAsset: 'instance', InvService: 'workspaces', + InvResourceCategory: 'cloud_resources', InvResourceType: 'WorkSpace Instance', + BridgeProvider: 'aws', BridgeServiceName: 'workspaces', BridgePluginCategoryName: 'WorkSpaces', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:workspaces:{region}:{cloudAccount}:workspace/{name}', BridgeResourceType: 'workspace', + BridgeResourceNameIdentifier: 'WorkspaceId', BridgeExecutionService: 'WorkSpaces', + BridgeCollectionService: 'workspaces', BridgeCall: 'describeWorkspaces', DataIdentifier: 'data', + }, + 'Transfer': + { + enabled: true, isSingleSource: true, InvAsset: 'server', InvService: 'transfer', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Transfer Server', + BridgeProvider: 'aws', BridgeServiceName: 'transfer', BridgePluginCategoryName: 'Transfer', + BridgeArnIdentifier: 'Arn', BridgeIdTemplate: '', BridgeResourceType: 'server', + BridgeResourceNameIdentifier: 'ServerId', BridgeExecutionService: 'Transfer', + BridgeCollectionService: 'transfer', BridgeCall: 'listServers', DataIdentifier: 'data', + }, + 'AppFlow': + { + enabled: true, isSingleSource: true, InvAsset: 'flow', InvService: 'appflow', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Appflow', + BridgeProvider: 'aws', BridgeServiceName: 'appflow', BridgePluginCategoryName: 'AppFlow', + BridgeArnIdentifier: 'flowArn', BridgeIdTemplate: '', BridgeResourceType: 'flow', + BridgeResourceNameIdentifier: 'flowName', BridgeExecutionService: 'AppFlow', + BridgeCollectionService: 'appflow', BridgeCall: 'listFlows', DataIdentifier: 'data', + }, + 'Cognito': + { + enabled: true, isSingleSource: true, InvAsset: 'userpool', InvService: 'cognitoidentityserviceprovider', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Cognito Userpool', + BridgeProvider: 'aws', BridgeServiceName: 'cognitoidentityserviceprovider', BridgePluginCategoryName: 'Cognito', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:cognito-idp:{region}:{cloudAccount}:userpool/{id}', BridgeResourceType: 'userpool', + BridgeResourceNameIdentifier: 'Id', BridgeExecutionService: 'Cognito', + BridgeCollectionService: 'cognitoidentityserviceprovider', BridgeCall: 'listUserPools', DataIdentifier: 'data', + }, + 'WAF': + { + enabled: true, isSingleSource: true, InvAsset: 'webacl', InvService: 'wafv2', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Web ACL', + BridgeProvider: 'aws', BridgeServiceName: 'wafv2', BridgePluginCategoryName: 'WAF', + BridgeArnIdentifier: 'ARN', BridgeIdTemplate: '', BridgeResourceType: 'webacl', + BridgeResourceNameIdentifier: 'Id', BridgeExecutionService: 'WAF', + BridgeCollectionService: 'wafv2', BridgeCall: 'listWebACLs', DataIdentifier: 'data', + }, + 'Glue': + { + enabled: true, isSingleSource: true, InvAsset: 'glue', InvService: 'glue', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Glue SecurityConfigurations', + BridgeProvider: 'aws', BridgeServiceName: 'glue', BridgePluginCategoryName: 'Glue', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:glue:{region}:{cloudAccount}:/securityConfiguration/{name}', BridgeResourceType: 'securityConfiguration', + BridgeResourceNameIdentifier: 'Name', BridgeExecutionService: 'Glue', + BridgeCollectionService: 'glue', BridgeCall: 'getSecurityConfigurations', DataIdentifier: 'data', + }, + 'ConfigService': + { + enabled: true, isSingleSource: true, InvAsset: 'configservice', InvService: 'configservice', + InvResourceCategory: 'cloud_resources', InvResourceType: 'ConfigService', + BridgeProvider: 'aws', BridgeServiceName: 'configservice', BridgePluginCategoryName: 'ConfigService', + BridgeArnIdentifier: 'ConfigRuleArn', BridgeIdTemplate: '', BridgeResourceType: 'config-rule', + BridgeResourceNameIdentifier: 'ConfigRuleName', BridgeExecutionService: 'ConfigService', + BridgeCollectionService: 'configservice', BridgeCall: 'describeConfigRules', DataIdentifier: 'data', + }, + 'Firehose': + { + enabled: true, isSingleSource: true, InvAsset: 'firehose', InvService: 'firehose', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Firehose', + BridgeProvider: 'aws', BridgeServiceName: 'firehose', BridgePluginCategoryName: 'Firehose', + BridgeArnIdentifier: 'DeliveryStreamARN', BridgeIdTemplate: '', BridgeResourceType: 'deliverystream', + BridgeResourceNameIdentifier: 'DeliveryStreamName', BridgeExecutionService: 'Firehose', + BridgeCollectionService: 'firehose', BridgeCall: 'describeDeliveryStream', DataIdentifier: 'DeliveryStreamDescription', + }, + 'SES': + { + enabled: true, isSingleSource: true, InvAsset: 'ses', InvService: 'SES', + InvResourceCategory: 'cloud_resource', InvResourceType: 'ses_emails', + BridgeProvider: 'aws', BridgeServiceName: 'ses', BridgePluginCategoryName: 'SES', + BridgeArnIdentifier: '', BridgeIdTemplate: 'arn:aws:ses:{region}:{cloudAccount}:identity/{name}', BridgeResourceType: 'identity', + BridgeResourceNameIdentifier: 'identityName', BridgeExecutionService: 'SES', + BridgeCollectionService: 'ses', BridgeCall: 'getIdentityDkimAttributes', DataIdentifier: 'DkimAttributes', + }, + 'FSx': + { + enabled: true, isSingleSource: true, InvAsset: 'filesystem', InvService: 'fsx', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Fsx Filesystem', + BridgeProvider: 'aws', BridgeServiceName: 'fsx', BridgePluginCategoryName: 'FSx', + BridgeArnIdentifier: 'ResourceARN', BridgeIdTemplate: '', BridgeResourceType: 'file-system', + BridgeResourceNameIdentifier: 'FileSystemId', BridgeExecutionService: 'FSx', + BridgeCollectionService: 'fsx', BridgeCall: 'describeFileSystems', DataIdentifier: 'data', + }, + 'OpenSearch': [ + { + enabled: true, isSingleSource: true, InvAsset: 'domain', InvService: 'opensearch', + InvResourceCategory: 'database', InvResourceType: 'OpenSearch Domain', + BridgeProvider: 'aws', BridgeServiceName: 'opensearch', BridgePluginCategoryName: 'OpenSearch', + BridgeArnIdentifier: 'ARN', BridgeIdTemplate: '', BridgeResourceType: 'domain', + BridgeResourceNameIdentifier: 'DomainName', BridgeExecutionService: 'OpenSearch', + BridgeCollectionService: 'opensearch', BridgeCall: 'describeDomain', DataIdentifier: 'DomainStatus', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'collection', InvService: 'opensearch', + InvResourceCategory: 'database', InvResourceType: 'OpenSearch Serverless', + BridgeProvider: 'aws', BridgeServiceName: 'opensearchserverless', BridgePluginCategoryName: 'OpenSearch', + BridgeArnIdentifier: 'arn', BridgeIdTemplate: '', BridgeResourceType: 'collection', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'OpenSearch', + BridgeCollectionService: 'opensearchserverless', BridgeCall: 'listCollections', DataIdentifier: 'data', + }, + ], }; var calls = { @@ -1146,14 +1255,6 @@ var calls = { paginate: 'NextToken' } }, - ElasticTranscoder: { - // TODO: Pagination via NextPageToken and PageToken - listPipelines: { - property: 'Pipelines', - paginate: 'NextPageToken', - paginateReqProp: 'PageToken' - } - }, ELB: { describeLoadBalancers: { property: 'LoadBalancerDescriptions', @@ -1479,12 +1580,6 @@ var calls = { paginate: 'nextToken' } }, - QLDB: { - listLedgers: { - property: 'Ledgers', - paginate: 'NextToken' - } - }, RDS: { describeDBInstances: { property: 'DBInstances', @@ -1818,6 +1913,21 @@ var postcalls = [ IoTSiteWise: { sendIntegration: serviceMap['IoT SiteWise'] }, + Workspaces: { + sendIntegration: serviceMap['WorkSpaces'] + }, + Transfer: { + sendIntegration: serviceMap['Transfer'] + }, + Glue: { + sendIntegration: serviceMap['Glue'], + }, + SecurityHub: { + sendIntegration: serviceMap['SecurityHub'] + }, + FSx:{ + sendIntegration: serviceMap['FSx'] + }, ACM: { describeCertificate: { @@ -1825,7 +1935,10 @@ var postcalls = [ reliesOnCall: 'listCertificates', filterKey: 'CertificateArn', filterValue: 'CertificateArn' - } + }, + sendIntegration: { + enabled: true + }, }, AccessAnalyzer: { listFindings: { @@ -1913,7 +2026,8 @@ var postcalls = [ reliesOnCall: 'listFlows', filterKey: 'flowName', filterValue: 'flowName' - } + }, + sendIntegration: serviceMap['AppFlow'] }, Athena: { getWorkGroup: { @@ -2102,7 +2216,8 @@ var postcalls = [ reliesOnCall: 'describeConfigRules', filterKey: 'ConfigRuleName', filterValue: 'ConfigRuleName' - } + }, + sendIntegration: serviceMap['ConfigService'] }, CodeStar: { describeProject: { @@ -2227,7 +2342,8 @@ var postcalls = [ reliesOnCall: 'listDomainNames', filterKey: 'DomainName', filterValue: 'DomainName' - } + }, + sendIntegration: serviceMap['OpenSearch'][0] }, S3: { getBucketLogging: { @@ -2342,7 +2458,8 @@ var postcalls = [ reliesOnCall: 'listUserPools', filterKey: 'UserPoolId', filterValue: 'Id' - } + }, + sendIntegration: serviceMap['Cognito'] }, EC2: { describeSubnets: { @@ -2388,9 +2505,7 @@ var postcalls = [ filterKey: 'resourceArn', filterValue: 'repositoryArn' }, - sendIntegration: { - enabled: true - } + sendIntegration: serviceMap['ECR'] }, ECRPUBLIC: { describeRepositories: { @@ -2437,15 +2552,6 @@ var postcalls = [ }, sendIntegration: serviceMap['ElasticBeanstalk'] }, - ElasticTranscoder: { - listJobsByPipeline: { - reliesOnService: 'elastictranscoder', - reliesOnCall: 'listPipelines', - filterKey: 'PipelineId', - filterValue: 'Id' - }, - sendIntegration: serviceMap['Elastic Transcoder'] - }, ELB: { describeLoadBalancerPolicies: { reliesOnService: 'elb', @@ -2645,7 +2751,8 @@ var postcalls = [ reliesOnService: 'firehose', reliesOnCall: 'listDeliveryStreams', override: true - } + }, + sendIntegration: serviceMap['Firehose'], }, KMS: { describeKey: { @@ -2684,7 +2791,7 @@ var postcalls = [ reliesOnCall: 'listFunctions', filterKey: 'FunctionName', filterValue: 'FunctionName', - rateLimit: 100, // it's not documented but experimentially 10/second works. + rateLimit: 100, // it's not documented but experimental 10/second works. }, getFunction: { reliesOnService: 'lambda', @@ -2766,15 +2873,6 @@ var postcalls = [ filterValue: 'botAliasId' } }, - QLDB: { - describeLedger: { - reliesOnService: 'qldb', - reliesOnCall: 'listLedgers', - filterKey: 'Name', - filterValue: 'Name' - }, - sendIntegration: serviceMap['QLDB'] - }, ManagedBlockchain: { listMembers: { reliesOnService: 'managedblockchain', @@ -2897,7 +2995,8 @@ var postcalls = [ reliesOnCall: 'listIdentities', override: true, rateLimit: 1000 - } + }, + sendIntegration: serviceMap['SES'] }, SNS: { getTopicAttributes: { @@ -2951,7 +3050,8 @@ var postcalls = [ reliesOnService: 'wafv2', reliesOnCall: 'listWebACLs', override: true - } + }, + sendIntegration: serviceMap['WAF'] }, GuardDuty: { getDetector: { @@ -2974,6 +3074,7 @@ var postcalls = [ reliesOnCall: 'listDetectors', override: true, }, + sendIntegration: serviceMap['Guard Duty'], }, }, { @@ -3130,7 +3231,8 @@ var postcalls = [ reliesOnService: 'opensearchserverless', reliesOnCall: 'listNetworkSecurityPolicies', override: true - } + }, + sendIntegration: serviceMap['OpenSearch'][1] } } ]; @@ -3141,4 +3243,4 @@ module.exports = { calls: calls, postcalls: postcalls, integrationSendLast: integrationSendLast -}; +}; \ No newline at end of file diff --git a/helpers/aws/api_multipart.js b/helpers/aws/api_multipart.js index 4590b8ff..27653836 100644 --- a/helpers/aws/api_multipart.js +++ b/helpers/aws/api_multipart.js @@ -573,14 +573,6 @@ var calls = [ paginate: 'NextToken' } }, - ElasticTranscoder: { - // TODO: Pagination via NextPageToken and PageToken - listPipelines: { - property: 'Pipelines', - paginate: 'NextPageToken', - paginateReqProp: 'PageToken' - } - }, ELB: { describeLoadBalancers: { property: 'LoadBalancerDescriptions', @@ -862,12 +854,6 @@ var calls = [ paginate: 'nextToken' } }, - QLDB: { - listLedgers: { - property: 'Ledgers', - paginate: 'NextToken' - } - }, RDS: { describeDBInstances: { property: 'DBInstances', @@ -1666,14 +1652,6 @@ var postcalls = [ override: true } }, - ElasticTranscoder: { - listJobsByPipeline: { - reliesOnService: 'elastictranscoder', - reliesOnCall: 'listPipelines', - filterKey: 'PipelineId', - filterValue: 'Id' - } - }, ELB: { describeLoadBalancerPolicies: { reliesOnService: 'elb', @@ -2018,7 +1996,7 @@ var postcalls = [ reliesOnCall: 'listFunctions', filterKey: 'FunctionName', filterValue: 'FunctionName', - rateLimit: 500, // it's not documented but experimentally 10/second works. + rateLimit: 500, // it's not documented but experimental 10/second works. }, getFunction: { reliesOnService: 'lambda', @@ -2092,14 +2070,6 @@ var postcalls = [ filterValue: 'botId' } }, - QLDB: { - describeLedger: { - reliesOnService: 'qldb', - reliesOnCall: 'listLedgers', - filterKey: 'Name', - filterValue: 'Name' - } - }, ManagedBlockchain: { listMembers: { reliesOnService: 'managedblockchain', @@ -2440,4 +2410,4 @@ module.exports = { callsMultipart: calls, postcallsMultipart: postcalls, integrationSendLast: integrationSendLast -}; +}; \ No newline at end of file diff --git a/helpers/azure/api.js b/helpers/azure/api.js index baf16480..b05c1a76 100644 --- a/helpers/azure/api.js +++ b/helpers/azure/api.js @@ -53,7 +53,7 @@ var serviceMap = { BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Redis Cache', BridgeCollectionService: 'rediscaches', DataIdentifier: 'data', }, - 'CDN Profiles': + 'CDN Profiles': [ { enabled: true, isSingleSource: true, InvAsset: 'cdnProfiles', InvService: 'cdnProfiles', InvResourceCategory: 'cloud_resources', InvResourceType: 'CDN_Profiles', BridgeServiceName: 'profiles', @@ -62,6 +62,15 @@ var serviceMap = { BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'CDN Profiles', BridgeCollectionService: 'profiles', DataIdentifier: 'data', }, + { + enabled: true, isSingleSource: true, InvAsset: 'endpoint', InvService: 'cdnProfiles', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Endpoints', BridgeServiceName: 'endpoints', + BridgePluginCategoryName: 'CDN Profiles', BridgeProvider: 'Azure', BridgeCall: 'listByProfile', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'endpoints', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'CDN Profiles', + BridgeCollectionService: 'endpoints', DataIdentifier: 'data', + } + ], 'Cosmos DB': { enabled: true, isSingleSource: true, InvAsset: 'cosmosdb', InvService: 'cosmosDB', @@ -116,7 +125,7 @@ var serviceMap = { BridgeResourceNameIdentifier: 'displayName', BridgeExecutionService: 'Azure Policy', BridgeCollectionService: 'policyassignments', DataIdentifier: 'data', }, - 'Virtual Networks': + 'Virtual Networks':[ { enabled: true, isSingleSource: true, InvAsset: 'virtual_network', InvService: 'virtual_network', InvResourceCategory: 'cloud_resources', InvResourceType: 'Virtual Network', BridgeServiceName: 'virtualnetworks', @@ -125,6 +134,15 @@ var serviceMap = { BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Virtual Networks', BridgeCollectionService: 'virtualnetworks', DataIdentifier: 'data', }, + { + enabled: true, isSingleSource: true, InvAsset: 'vn_routeTables', InvService: 'virtual_network', + InvResourceCategory: 'cloud_resources', InvResourceType: 'VN_RouteTables', BridgeServiceName: 'routetables', + BridgePluginCategoryName: 'Virtual Networks', BridgeProvider: 'Azure', BridgeCall: 'listAll', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'routeTables', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Virtual Networks', + BridgeCollectionService: 'routetables', DataIdentifier: 'data', + } + ], 'Queue Service': { enabled: true, isSingleSource: true, InvAsset: 'queueService', InvService: 'queueService', @@ -170,6 +188,96 @@ var serviceMap = { BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'AI & ML', BridgeCollectionService: 'openai', BridgeCall: 'listAccounts', DataIdentifier: 'data', }, + 'Blob Service': + { + enabled: true, isSingleSource: true, InvAsset: 'blob_container', InvService: 'blobservice', + InvResourceCategory: 'cloud_resources', InvResourceType: 'blob_container', BridgeServiceName: 'blobcontainers', + BridgePluginCategoryName: 'Blob Service', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'containers', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Blob Service', + BridgeCollectionService: 'blobcontainers', DataIdentifier: 'data', + }, + 'Virtual Machines': + { + enabled: true, isSingleSource: true, InvAsset: 'vm_scaleset', InvService: 'virtualmachines', + InvResourceCategory: 'cloud_resources', InvResourceType: 'VM_ScaleSet', BridgeServiceName: 'virtualmachinescalesets', + BridgePluginCategoryName: 'Virtual Machines', BridgeProvider: 'Azure', BridgeCall: 'listAll', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'virtualMachineScaleSets', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Virtual Machines', + BridgeCollectionService: 'virtualmachinescalesets', DataIdentifier: 'data', + }, + 'Event Grid': + { + enabled: true, isSingleSource: true, InvAsset: 'domain', InvService: 'eventgrid', + InvResourceCategory: 'cloud_resources', InvResourceType: 'EventGrid Domain', BridgeServiceName: 'eventgrid', + BridgePluginCategoryName: 'Event Grid', BridgeProvider: 'Azure', BridgeCall: 'listDomains', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'domains', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Event Grid', + BridgeCollectionService: 'eventgrid', DataIdentifier: 'data', + }, + 'Event Hubs': + { + enabled: true, isSingleSource: true, InvAsset: 'namespace', InvService: 'eventhubs', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Eventhubs Namespace', BridgeServiceName: 'eventhub', + BridgePluginCategoryName: 'Event Hubs', BridgeProvider: 'Azure', BridgeCall: 'listEventHub', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'namespaces', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Event Hubs', + BridgeCollectionService: 'eventhub', DataIdentifier: 'data', + }, + 'Defender': [ + { + enabled: true, isSingleSource: true, InvAsset: 'defender', InvService: 'defender', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Defender', BridgeServiceName: 'pricings', + BridgePluginCategoryName: 'Defender', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'pricings', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Defender', + BridgeCollectionService: 'pricings', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'defender', InvService: 'defender', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Defender Settings', BridgeServiceName: 'securitycenter', + BridgePluginCategoryName: 'Defender', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'settings', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Defender', + BridgeCollectionService: 'securitycenter', DataIdentifier: 'data', + } + ], + 'Application Gateway': [ + { + enabled: true, isSingleSource: true, InvAsset: 'applicationGateway', InvService: 'applicationGateway', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Application Gateway', BridgeServiceName: 'applicationgateway', + BridgePluginCategoryName: 'Application Gateway', BridgeProvider: 'Azure', BridgeCall: 'listAll', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'applicationGateways', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Application Gateway', + BridgeCollectionService: 'applicationgateway', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'policy', InvService: 'applicationGateway', + InvResourceCategory: 'cloud_resources', InvResourceType: 'wafpolicies', BridgeServiceName: 'wafpolicies', + BridgePluginCategoryName: 'Application Gateway', BridgeProvider: 'Azure', BridgeCall: 'listAll', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'ApplicationGatewayWebApplicationFirewallPolicies', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Application Gateway', + BridgeCollectionService: 'wafpolicies', DataIdentifier: 'data', + } + ], + 'Entra ID': [ + { + enabled: true, isSingleSource: true, InvAsset: 'entraId', InvService: 'entraId', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Roles', BridgeServiceName: 'roledefinitions', + BridgePluginCategoryName: 'Entra ID', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'roleDefinitions', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Entra ID', + BridgeCollectionService: 'roledefinitions', DataIdentifier: 'data', + }, + { + enabled: true, isSingleSource: true, InvAsset: 'entraId', InvService: 'entraId', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Application', BridgeServiceName: 'applications', + BridgePluginCategoryName: 'Entra ID', BridgeProvider: 'Azure', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: '', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'Entra ID', + BridgeCollectionService: 'applications', DataIdentifier: 'data', + } + ] }; // Standard calls that contain top-level operations @@ -215,7 +323,7 @@ var calls = { listAll: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/virtualNetworks?api-version=2020-03-01' }, - sendIntegration: serviceMap['Virtual Networks'] + sendIntegration: serviceMap['Virtual Networks'][0] }, natGateways: { listBySubscription: { @@ -267,7 +375,7 @@ var calls = { }, vaults: { list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.KeyVault/vaults?api-version=2019-09-01' + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.KeyVault/vaults?api-version=2023-07-01' }, sendIntegration: serviceMap['Key Vaults'], }, @@ -290,7 +398,8 @@ var calls = { routeTables: { listAll: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/routeTables?api-version=2022-07-01' - } + }, + sendIntegration: serviceMap['Virtual Networks'][1] }, managedClusters: { list: { @@ -338,7 +447,7 @@ var calls = { list: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Cdn/profiles?api-version=2024-02-01' }, - sendIntegration: serviceMap['CDN Profiles'] + sendIntegration: serviceMap['CDN Profiles'][0] }, autoProvisioningSettings: { list: { @@ -348,7 +457,8 @@ var calls = { applicationGateway: { listAll: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/applicationGateways?api-version=2022-07-01' - } + }, + sendIntegration: serviceMap['Application Gateway'][0] }, securityContacts: { list: { @@ -375,7 +485,8 @@ var calls = { roleDefinitions: { list: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/roleDefinitions?api-version=2015-07-01' - } + }, + sendIntegration: serviceMap['Entra ID'][0] }, managementLocks: { listAtSubscriptionLevel: { @@ -407,7 +518,8 @@ var calls = { list: { url: 'https://graph.microsoft.com/v1.0/applications/', graph: true, - } + }, + sendIntegration: serviceMap['Entra ID'][1] }, automationAccounts: { list: { @@ -422,7 +534,8 @@ var calls = { pricings: { list: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Security/pricings?api-version=2018-06-01' - } + }, + sendIntegration: serviceMap['Defender'][0] }, availabilitySets: { listBySubscription: { @@ -432,7 +545,8 @@ var calls = { virtualMachineScaleSets: { listAll: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/virtualMachineScaleSets?api-version=2023-07-01' - } + }, + sendIntegration: serviceMap['Virtual Machines'] }, bastionHosts: { listAll: { @@ -442,7 +556,8 @@ var calls = { wafPolicies: { listAll: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies?api-version=2022-07-01' - } + }, + sendIntegration: serviceMap['Application Gateway'][1] }, autoscaleSettings: { listBySubscription: { @@ -462,7 +577,7 @@ var calls = { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.DBforMySQL/servers?api-version=2017-12-01' }, listMysqlFlexibleServer: { - url : 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.DBforMySQL/flexibleServers?api-version=2021-05-01' + url : 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.DBforMySQL/flexibleServers?api-version=2023-12-30' }, listPostgres: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.DBforPostgreSQL/servers?api-version=2017-12-01' @@ -480,7 +595,8 @@ var calls = { securityCenter: { list: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Security/settings?api-version=2021-06-01' - } + }, + sendIntegration: serviceMap['Defender'][1] }, publicIPAddresses: { listAll: { @@ -500,12 +616,14 @@ var calls = { eventGrid: { listDomains: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.EventGrid/domains?api-version=2023-12-15-preview' - } + }, + sendIntegration: serviceMap['Event Grid'] }, eventHub: { listEventHub: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.EventHub/namespaces?api-version=2022-10-01-preview' - } + }, + sendIntegration: serviceMap['Event Hubs'] }, serviceBus: { listNamespacesBySubscription: { @@ -815,7 +933,8 @@ var postcalls = { url: 'https://management.azure.com/{id}/blobServices/default/containers?api-version=2019-06-01', rateLimit: 3000, limit: 20000 - } + }, + sendIntegration: serviceMap['Blob Service'] }, blobServices: { list: { @@ -891,6 +1010,14 @@ var postcalls = { properties: ['id'], url: 'https://management.azure.com/{id}/config/backup/list?api-version=2021-02-01', post: true + }, + getWebAppDetails: { + reliesOnPath: 'webApps.list', + properties: ['id'], + url: 'https://management.azure.com/{id}?api-version=2022-03-01' + }, + sendIntegration: { + enabled: true } }, containerApps: { @@ -905,7 +1032,8 @@ var postcalls = { reliesOnPath: 'profiles.list', properties: ['id'], url: 'https://management.azure.com/{id}/endpoints?api-version=2019-04-15' - } + }, + sendIntegration: serviceMap['CDN Profiles'][1] }, customDomain: { listByFrontDoorProfiles: { @@ -976,6 +1104,11 @@ var postcalls = { reliesOnPath: 'servers.listPostgresFlexibleServer', properties: ['id'], url: 'https://management.azure.com/{id}/firewallRules?api-version=2022-12-01' + }, + listByFlexibleServerMysql: { + reliesOnPath: 'servers.listMysqlFlexibleServer', + properties: ['id'], + url: 'https://management.azure.com/{id}/firewallRules?api-version=2021-05-01' } }, outboundFirewallRules: { @@ -1131,6 +1264,13 @@ var postcalls = { url: 'https://management.azure.com/{id}/encryptionScopes?api-version=2023-01-01' } }, + eventHub: { + listNetworkRuleSet: { + reliesOnPath: 'eventHub.listEventHub', + properties: ['id'], + url: 'https://management.azure.com/{id}/networkRuleSets/default?api-version=2022-10-01-preview' + } + } }; var tertiarycalls = { @@ -1428,4 +1568,4 @@ module.exports = { tertiarycalls: tertiarycalls, specialcalls: specialcalls, serviceMap: serviceMap -}; +}; \ No newline at end of file diff --git a/helpers/azure/auth.js b/helpers/azure/auth.js index d4ad17a4..99279413 100644 --- a/helpers/azure/auth.js +++ b/helpers/azure/auth.js @@ -1,5 +1,5 @@ -var request = require('request'); var locations = require(__dirname + '/locations.js'); +var axios = require('axios'); var locations_gov = require(__dirname + '/locations_gov.js'); var dontReplace = { @@ -36,57 +36,66 @@ module.exports = { if (!azureConfig.KeyValue) return callback('No KeyValue provided'); if (!azureConfig.DirectoryID) return callback('No DirectoryID provided'); if (!azureConfig.SubscriptionID) return callback('No SubscriptionID provided'); + var { ClientSecretCredential } = require('@azure/identity'); - var msRestAzure = require('ms-rest-azure'); - - function performLogin(tokenAudience, cb) { - msRestAzure.loginWithServicePrincipalSecret( - azureConfig.ApplicationID, - azureConfig.KeyValue, - azureConfig.DirectoryID, - tokenAudience, function(err, credentials) { - if (err) return cb(err); - if (!credentials) return cb('Unable to log into Azure using provided credentials.'); - if (!credentials.environment) return cb('Unable to obtain environment from Azure application'); - if (!credentials.tokenCache || - !credentials.tokenCache._entries || - !credentials.tokenCache._entries[0] || - !credentials.tokenCache._entries[0].accessToken) { - return cb('Unable to obtain token from Azure.'); - } - - cb(null, credentials); + function getToken(credential, scopes, cb) { + credential.getToken(scopes) + .then(response => { + cb(null, response.token); + }) + .catch(error => { + cb(error); }); } + const credential = new ClientSecretCredential( + azureConfig.DirectoryID, + azureConfig.ApplicationID, + azureConfig.KeyValue + ); + if (azureConfig.Govcloud) { - performLogin({ environment: msRestAzure.AzureEnvironment.AzureUSGovernment }, function(err, credentials) { + const armScope = 'https://management.usgovcloudapi.net/.default'; + const graphScope = 'https://graph.microsoft.us/.default'; + const vaultScope = 'https://vault.azure.us/.default'; + + getToken(credential, [armScope], function(err, armToken) { if (err) return callback(err); - performLogin({ tokenAudience: 'https://graph.microsoft.us', environment: msRestAzure.AzureEnvironment.AzureUSGovernment }, function(graphErr, graphCredentials) { + getToken(credential, [graphScope], function(graphErr, graphToken) { if (graphErr) return callback(graphErr); - performLogin({ tokenAudience: 'https://vault.azure.us', environment: msRestAzure.AzureEnvironment.AzureUSGovernment }, function(vaultErr, vaultCredentials) { + getToken(credential, [vaultScope], function(vaultErr, vaultToken) { if (vaultErr) console.log('No vault'); callback(null, { - environment: credentials.environment, - token: credentials.tokenCache._entries[0].accessToken, - graphToken: graphCredentials ? graphCredentials.tokenCache._entries[0].accessToken : null, - vaultToken: vaultCredentials ? vaultCredentials.tokenCache._entries[0].accessToken : null + environment: { + name: 'AzureUSGovernment', + portalUrl: 'https://portal.azure.us' + }, + token: armToken, + graphToken: graphToken, + vaultToken: vaultToken }); }); }); }); } else { - performLogin(null, function(err, credentials) { + const armScope = 'https://management.azure.com/.default'; + const graphScope = 'https://graph.microsoft.com/.default'; + const vaultScope = 'https://vault.azure.net/.default'; + + getToken(credential, [armScope], function(err, armToken) { if (err) return callback(err); - performLogin({ tokenAudience: 'https://graph.microsoft.com' }, function(graphErr, graphCredentials) { + getToken(credential, [graphScope], function(graphErr, graphToken) { if (graphErr) return callback(graphErr); - performLogin({ tokenAudience: 'https://vault.azure.net' }, function(vaultErr, vaultCredentials) { + getToken(credential, [vaultScope], function(vaultErr, vaultToken) { if (vaultErr) return callback(vaultErr); callback(null, { - environment: credentials.environment, - token: credentials.tokenCache._entries[0].accessToken, - graphToken: graphCredentials.tokenCache._entries[0].accessToken, - vaultToken: vaultCredentials.tokenCache._entries[0].accessToken + environment: { + name: 'AzureCloud', + portalUrl: 'https://portal.azure.com' + }, + token: armToken, + graphToken: graphToken, + vaultToken: vaultToken }); }); }); @@ -99,89 +108,162 @@ module.exports = { 'Authorization': `Bearer ${params.token}` }; + var requestData = null; if (params.body && Object.keys(params.body).length) { - headers['Content-Length'] = JSON.stringify(params.body).length; + requestData = JSON.stringify(params.body); + headers['Content-Length'] = requestData.length; headers['Content-Type'] = 'application/json;charset=UTF-8'; } if (params.govcloud) params.url = params.url.replace('management.azure.com', 'management.usgovcloudapi.net'); - - request({ + var axiosOptions = { method: params.method ? params.method : params.post ? 'POST' : 'GET', - uri: params.url, + url: params.url, headers: headers, - body: params.body ? JSON.stringify(params.body) : null - }, function(error, response, body) { - if (response && [200, 202].includes(response.statusCode) && body) { + data: requestData, + // Handle response as text first, then parse manually to match original behavior + transformResponse: [(data) => data] + }; + + axios(axiosOptions) + .then(function(response) { + var body = response.data; + + if (response && [200, 202].includes(response.status) && body) { + try { + body = JSON.parse(body); + } catch (e) { + return callback(`Error parsing response from Azure API: ${e}`); + } + return callback(null, body); + } else { + handleErrorResponse(body, response, callback); + } + }) + .catch(function(error) { + if (error.response) { + // The request was made and the server responded with a status code outside 2xx + handleErrorResponse(error.response.data, error.response, callback); + } else if (error.request) { + // The request was made but no response was received + if (error.code === 'ECONNRESET') { + console.log('[ERROR] Unhandled error from Azure API: Error: ECONNRESET'); + return callback('Unknown error occurred while calling the Azure API: ECONNRESET'); + } + console.log(`[ERROR] Unhandled error from Azure API: Error: ${error}`); + return callback('Unknown error occurred while calling the Azure API'); + } else { + // Something happened in setting up the request + console.log(`[ERROR] Unhandled error from Azure API: Error: ${error}`); + return callback('Unknown error occurred while calling the Azure API'); + } + }); + + function handleErrorResponse(body, response, callback) { + if (body) { try { body = JSON.parse(body); } catch (e) { - return callback(`Error parsing response from Azure API: ${e}`); + return callback(`Error parsing error response from Azure API: ${e}`); } - return callback(null, body); - } else { - if (body) { + + if (typeof body == 'string') { + // Need to double parse it try { body = JSON.parse(body); } catch (e) { - return callback(`Error parsing error response from Azure API: ${e}`); + return callback(`Error parsing error response string from Azure API: ${e}`); } + } - if (typeof body == 'string') { - // Need to double parse it - try { - body = JSON.parse(body); - } catch (e) { - return callback(`Error parsing error response string from Azure API: ${e}`); - } + if (response && ((response.statusCode && response.statusCode === 429) || (response.status && response.status === 429)) && + body && + body.error && + body.error.message && + typeof body.error.message == 'string') { + var errorMessage = `TooManyRequests: ${body.error.message}`; + return callback(errorMessage, null, response); + } else if (body && + body.error && + body.error.message && + typeof body.error.message == 'string') { + return callback(body.error.message); + } else if (body && + body['odata.error'] && + body['odata.error'].message && + body['odata.error'].message.value && + typeof body['odata.error'].message.value == 'string') { + if (body['odata.error'].requestId) { + body['odata.error'].message.value += ` RequestId: ${body['odata.error'].requestId}`; } - if (response && - response.statusCode && - response.statusCode === 429 && - body && - body.error && - body.error.message && - typeof body.error.message == 'string') { - var errorMessage = `TooManyRequests: ${body.error.message}`; - return callback(errorMessage, null, response); - } else if (body && - body.error && - body.error.message && - typeof body.error.message == 'string') { - return callback(body.error.message); - } else if (body && - body['odata.error'] && - body['odata.error'].message && - body['odata.error'].message.value && - typeof body['odata.error'].message.value == 'string') { - if (body['odata.error'].requestId) { - body['odata.error'].message.value += ` RequestId: ${body['odata.error'].requestId}`; - } - return callback(body['odata.error'].message.value); - } else if (body && - body.message && - typeof body.message == 'string') { - if (body.code && typeof body.code == 'string') { - body.message = (body.code + ': ' + body.message); - } - return callback(body.message); - } else if (body && - body.Message && - typeof body.Message == 'string') { - if (body.Code && typeof body.Code == 'string') { - body.Message = (body.Code + ': ' + body.Message); - } - return callback(body.Message); + return callback(body['odata.error'].message.value); + } else if (body && + body.message && + typeof body.message == 'string') { + if (body.code && typeof body.code == 'string') { + body.message = (body.code + ': ' + body.message); + } + return callback(body.message); + } else if (body && + body.Message && + typeof body.Message == 'string') { + if (body.Code && typeof body.Code == 'string') { + body.Message = (body.Code + ': ' + body.Message); + } + return callback(body.Message); + } + if (typeof body == 'string') { + // Need to double parse it + try { + body = JSON.parse(body); + } catch (e) { + return callback(`Error parsing error response string from Azure API: ${e}`); } - - console.log(`[ERROR] Unhandled error from Azure API: Body: ${JSON.stringify(body)}`); + } + if (response && ((response.statusCode && response.statusCode === 429) || (response.status && response.status === 429)) && + body && + body.error && + body.error.message && + typeof body.error.message == 'string') { + errorMessage = `TooManyRequests: ${body.error.message}`; + return callback(errorMessage, null, response); + } else if (body && + body.error && + body.error.message && + typeof body.error.message == 'string') { + return callback(body.error.message); + } else if (body && + body['odata.error'] && + body['odata.error'].message && + body['odata.error'].message.value && + typeof body['odata.error'].message.value == 'string') { + if (body['odata.error'].requestId) { + body['odata.error'].message.value += ` RequestId: ${body['odata.error'].requestId}`; + } + return callback(body['odata.error'].message.value); + } else if (body && + body.message && + typeof body.message == 'string') { + if (body.code && typeof body.code == 'string') { + body.message = (body.code + ': ' + body.message); + } + return callback(body.message); + } else if (body && + body.Message && + typeof body.Message == 'string') { + if (body.Code && typeof body.Code == 'string') { + body.Message = (body.Code + ': ' + body.Message); + } + return callback(body.Message); } - console.log(`[ERROR] Unhandled error from Azure API: Error: ${error}`); - return callback('Unknown error occurred while calling the Azure API'); + console.log(`[ERROR] Unhandled error from Azure API: Body: ${JSON.stringify(body)}`); } - }); + + console.log('[ERROR] Unhandled error from Azure API'); + return callback('Unknown error occurred while calling the Azure API'); + } }, addLocations: function(obj, service, collection, err, data, skip_locations) { @@ -207,7 +289,8 @@ module.exports = { } }); }, - addGovLocations: function(obj, service, collection, err, data , skip_locations) { + + addGovLocations: function(obj, service, collection, err, data, skip_locations) { if (!service || !locations_gov[service]) return; locations_gov[service].forEach(function(location) { if (skip_locations.includes(location)) return; @@ -232,5 +315,4 @@ module.exports = { }, reduceProperties: reduceProperties -}; - +}; \ No newline at end of file diff --git a/helpers/shared.js b/helpers/shared.js index 1222c84e..beeeb4e2 100644 --- a/helpers/shared.js +++ b/helpers/shared.js @@ -3,7 +3,6 @@ var async = require('async'); var ONE_DAY = 24*60*60*1000; var ONE_HOUR = 60*60*1000; -let identityServices = ['iam', 'aad']; var daysBetween = function(date1, date2) { return Math.round(Math.abs((new Date(date1).getTime() - new Date(date2).getTime())/(ONE_DAY))); }; @@ -21,11 +20,6 @@ var processIntegration = function(serviceName, settings, collection, calls, post let localSettings = {}; localSettings = settings; - if (settings.identifier.new_inventory_enabled && - (identityServices.includes(serviceName.toLowerCase()) || (calls[serviceName] && calls[serviceName].sendIntegration && calls[serviceName].sendIntegration.isIdentity))) { - console.log(`Not sending ${serviceName} because new inventory ff is enabled`); - return iCb(); - } if (settings.govcloud) { localEvent.awsOrGov = 'aws-us-gov'; } @@ -33,7 +27,7 @@ var processIntegration = function(serviceName, settings, collection, calls, post localEvent.scanTriggeredFromEventsFlow = settings.scanTriggeredFromEventsFlow; localEvent.collection = {}; localEvent.previousCollection = {}; - + localEvent.cloud_account_identifier = settings.identifier.cloud_account_identifier; localEvent.lastScanId = settings.lastScanId; localEvent.collection[serviceName.toLowerCase()] = {}; @@ -433,5 +427,4 @@ module.exports = { } return 0; } -}; - +}; \ No newline at end of file diff --git a/index.js b/index.js index 65230eb1..8ab5f23c 100755 --- a/index.js +++ b/index.js @@ -14,7 +14,7 @@ console.log(` | | |_| - CloudSploit by Khulnasoft Security, Ltd. + CloudExploit by Khulnasoft Security, Ltd. Cloud security auditing for AWS, Azure, GCP, Oracle, and GitHub `); diff --git a/plugins/aws/ec2/ebsRecentSnapshots.js b/plugins/aws/ec2/ebsRecentSnapshots.js index 4e8d9016..a0ee149f 100644 --- a/plugins/aws/ec2/ebsRecentSnapshots.js +++ b/plugins/aws/ec2/ebsRecentSnapshots.js @@ -6,14 +6,26 @@ module.exports = { category: 'EC2', domain: 'Compute', severity: 'Medium', - description: 'Ensures that EBS volume has had a snapshot within the last 7 days', + description: 'Ensures that EBS volume has had a recent snapshot within the configured time period', more_info: 'EBS volumes without recent snapshots may be at risk of data loss or recovery issues.', link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSSnapshots.html', - recommended_action: 'Create a new snapshot for EBS volume weekly.', + recommended_action: 'Create a new snapshot for EBS volume within the configured time period.', apis: ['EC2:describeSnapshots','STS:getCallerIdentity'], + settings: { + ebs_recent_snapshot_days: { + name: 'EBS Recent Snapshot Days', + description: 'Number of days to consider a snapshot as recent. Snapshots older than this will be flagged as FAIL.', + regex: '^[1-9]{1}[0-9]{0,2}$', + default: '7' + } + }, realtime_triggers: ['ec2:CreateSnapshot', 'ec2:DeleteSnapshot'], run: function(cache, settings, callback) { + var config = { + ebs_recent_snapshot_days: parseInt(settings.ebs_recent_snapshot_days || this.settings.ebs_recent_snapshot_days.default) + }; + var results = []; var source = {}; var regions = helpers.regions(settings); @@ -44,7 +56,7 @@ module.exports = { var snapshotTime = new Date(snapshot.StartTime); var difference = Math.floor((today -snapshotTime) / (1000 * 60 * 60 * 24)); - if (difference > 7){ + if (difference > config.ebs_recent_snapshot_days){ helpers.addResult(results, 2, 'EBS volume does not have a recent snapshot', region,resource); } else { @@ -58,5 +70,4 @@ module.exports = { callback(null, results, source); }); } -}; - +}; \ No newline at end of file diff --git a/plugins/aws/ec2/ebsRecentSnapshots.spec.js b/plugins/aws/ec2/ebsRecentSnapshots.spec.js index 686316e9..0f4bd7dc 100644 --- a/plugins/aws/ec2/ebsRecentSnapshots.spec.js +++ b/plugins/aws/ec2/ebsRecentSnapshots.spec.js @@ -7,6 +7,9 @@ snapshotPass.setDate(snapshotPass.getDate() - 1); var snapshotFail = new Date(); snapshotFail.setDate(snapshotFail.getDate() - 10); +var snapshotCustom = new Date(); +snapshotCustom.setDate(snapshotCustom.getDate() - 15); + const describeSnapshots = [ { "Description": "", @@ -47,6 +50,18 @@ const describeSnapshots = [ "VolumeId": "vol-02c402f5a6a02c6e7", "VolumeSize": 1, "Tags": [] + }, + { + "Description": "Custom test snapshot", + "Encrypted": false, + "OwnerId": "112233445566", + "Progress": "100%", + "SnapshotId": "snap-04custom567890abc", + "StartTime": snapshotCustom, + "State": "completed", + "VolumeId": "vol-03custom567890def", + "VolumeSize": 10, + "Tags": [] } ]; @@ -135,5 +150,40 @@ describe('ebsRecentSnapshots', function () { done(); }); }); + + it('should use custom snapshot age threshold when setting is provided', function (done) { + const cache = createCache([describeSnapshots[3]]); // 15-day old snapshot + const settings = { ebs_recent_snapshot_days: '20' }; + ebsRecentSnapshots.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EBS volume has a recent snapshot'); + done(); + }); + }); + + it('should FAIL when snapshot is older than custom threshold', function (done) { + const cache = createCache([describeSnapshots[3]]); // 15-day old snapshot + const settings = { ebs_recent_snapshot_days: '10' }; + ebsRecentSnapshots.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EBS volume does not have a recent snapshot'); + done(); + }); + }); + + it('should use default 7 days when no setting is provided', function (done) { + const cache = createCache([describeSnapshots[1]]); // 10-day old snapshot + ebsRecentSnapshots.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('EBS volume does not have a recent snapshot'); + done(); + }); + }); }); }); \ No newline at end of file diff --git a/plugins/aws/kms/kmsDefaultKeyUsage.js b/plugins/aws/kms/kmsDefaultKeyUsage.js index 8a309c86..495613bb 100644 --- a/plugins/aws/kms/kmsDefaultKeyUsage.js +++ b/plugins/aws/kms/kmsDefaultKeyUsage.js @@ -11,7 +11,7 @@ module.exports = { link: 'http://docs.aws.amazon.com/kms/latest/developerguide/concepts.html', recommended_action: 'Avoid using the default KMS key', apis: ['KMS:listKeys', 'KMS:describeKey', 'KMS:listAliases', 'CloudTrail:describeTrails', - 'EC2:describeVolumes', 'ElasticTranscoder:listPipelines', 'RDS:describeDBInstances', + 'EC2:describeVolumes', 'RDS:describeDBInstances', 'Redshift:describeClusters', 'S3:listBuckets', 'S3:getBucketEncryption', 'SES:describeActiveReceiptRuleSet', 'Workspaces:describeWorkspaces', 'Lambda:listFunctions', 'CloudWatchLogs:describeLogGroups', 'EFS:describeFileSystems', 'STS:getCallerIdentity'], @@ -21,7 +21,7 @@ module.exports = { 'passwords, it is still strongly encouraged to use a ' + 'customer-provided CMK rather than the default KMS key.' }, - realtime_triggers: ['cloudtrail:CreateTrail','cloudtrail:UpdateTrail','cloudtrail:DeleteTrail','ec2:CreateVolume','ec2:DeleteVolume','elastictranscoder:UpdatePipeline','elastictranscoder:CreatePipeline','elastictranscoder:DeletePipeline','rds:CreateDBInstance','rds:ModifyDBInstance','rds:DeleteDBInstance','redshift:CreateCluster','redshift:ModifyCluster','redshift:DeleteCluster','s3:CreateBucket','s3:DeleteBucket','s3:PutBucketEncryption','ses:CreateReceiptRule','ses:DeleteReceiptRule','ses:UpdateReceiptRule','workspaces:CreateWorkspaces','workspaces:TerminateWorkspaces','lambda:UpdateFunctionConfiguration','lambda:CreateFunction','lambda:DeleteFunction','cloudwatchlogs:CreateLogGroup','cloudwatchlogs:DeleteLogGroup','cloudwatchlogs:AssociateKmsKey','efs:CreateFileSystem',':efs:DeleteFileSystem'], + realtime_triggers: ['cloudtrail:CreateTrail','cloudtrail:UpdateTrail','cloudtrail:DeleteTrail','ec2:CreateVolume','ec2:DeleteVolume','rds:CreateDBInstance','rds:ModifyDBInstance','rds:DeleteDBInstance','redshift:CreateCluster','redshift:ModifyCluster','redshift:DeleteCluster','s3:CreateBucket','s3:DeleteBucket','s3:PutBucketEncryption','ses:CreateReceiptRule','ses:DeleteReceiptRule','ses:UpdateReceiptRule','workspaces:CreateWorkspaces','workspaces:TerminateWorkspaces','lambda:UpdateFunctionConfiguration','lambda:CreateFunction','lambda:DeleteFunction','cloudwatchlogs:CreateLogGroup','cloudwatchlogs:DeleteLogGroup','cloudwatchlogs:AssociateKmsKey','efs:CreateFileSystem',':efs:DeleteFileSystem'], run: function(cache, settings, callback) { var results = []; @@ -100,28 +100,6 @@ module.exports = { } } } - } - - // For ElasticTranscoder - if (region in regions.elastictranscoder) { - var listPipelines = helpers.addSource(cache, source, ['elastictranscoder', 'listPipelines', region]); - - if (listPipelines) { - if (listPipelines.err || !listPipelines.data) { - helpers.addResult(results, 3, - 'Unable to query for ElasticTranscoder pipelines: ' + helpers.addError(listPipelines), region); - } else { - for (var k in listPipelines.data){ - if (listPipelines.data[k].AwsKmsKeyArn) { - services.push({ - serviceName: 'ElasticTranscoder', - resource: listPipelines.data[k].Arn, - KMSKey: listPipelines.data[k].AwsKmsKeyArn - }); - } - } - } - } } // For RDS diff --git a/plugins/aws/s3/bucketSecureTransportEnabled.spec.js b/plugins/aws/s3/bucketSecureTransportEnabled.spec.js index 7694f859..dbbf055e 100644 --- a/plugins/aws/s3/bucketSecureTransportEnabled.spec.js +++ b/plugins/aws/s3/bucketSecureTransportEnabled.spec.js @@ -21,7 +21,7 @@ const getBucketPolicy = [ Policy: '{"Version":"2012-10-17","Id":"ExamplePolicy","Statement":[{"Sid":"","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::00000011111:root"},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::staging-01-sd-logs/*"]},{"Sid":"","Effect":"Deny","Principal":"*","Action":"s3:*","Resource":["arn:aws:s3:::staging-01-sd-logs/*","arn:aws:s3:::staging-01-sd-logs"],"Condition":{"Bool":{"aws:SecureTransport":"false"}}}]}' }, { - Policy: '{"Version":"2008-10-17","Statement":[{"Sid":"Stmt1537431944913","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::00001111122:root"},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::alqemy-upwork/*"]},{"Sid":"Stmt1537431944211","Effect":"Deny","Principal":"*","Action":"s3:*","Resource":["arn:aws:s3:::alqemy-upwork/*","arn:aws:s3:::alqemy-upwork"],"Condition":{"Bool":{"aws:SecureTransport":"false"}}}]}' + Policy: '{"Version":"2008-10-17","Statement":[{"Sid":"Stmt1537431944913","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::00001111122:root"},"Action":["s3:PutObject"],"Resource":["arn:aws:s3:::cloudexploit-test-secure-transport/*"]},{"Sid":"Stmt1537431944211","Effect":"Deny","Principal":"*","Action":"s3:*","Resource":["arn:aws:s3:::cloudexploit-test-secure-transport/*","arn:aws:s3:::cloudexploit-test-secure-transport"],"Condition":{"Bool":{"aws:SecureTransport":"false"}}}]}' }, { Policy: '{"Version":"2012-10-17","Id":"ExamplePolicy","Statement":[]}' diff --git a/plugins/aws/s3/s3BucketHasTags.js b/plugins/aws/s3/s3BucketHasTags.js index d225153d..26ed3121 100644 --- a/plugins/aws/s3/s3BucketHasTags.js +++ b/plugins/aws/s3/s3BucketHasTags.js @@ -9,7 +9,7 @@ module.exports = { more_info: 'Tags help you to group resources together that are related to or associated with each other. It is a best practice to tag cloud resources to better organize and gain visibility into their usage.', recommended_action: 'Modify S3 buckets and add tags.', link: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/CostAllocTagging.html', - apis: ['S3:listBuckets', 'ResourceGroupsTaggingAPI:getResources', 'S3:getBucketLocation'], + apis: ['S3:listBuckets', 'S3:getBucketTagging', 'S3:getBucketLocation'], realtime_triggers: ['s3:CreateBucket', 's3:PutBucketTagging','s3:DeleteBucket'], run: function(cache, settings, callback) { @@ -32,20 +32,43 @@ module.exports = { return callback(null, results, source); } - var bucketsByRegion= {}; listBuckets.data.forEach(function(bucket) { if (!bucket.Name) return; + var bucketLocation = helpers.getS3BucketLocation(cache, defaultRegion, bucket.Name); - if (!bucketsByRegion[bucketLocation]) { - bucketsByRegion[bucketLocation] = []; + var bucketArn = `arn:${awsOrGov}:s3:::${bucket.Name}`; + + // Try the bucket's actual region first, then fall back to default region + var getBucketTagging = helpers.addSource(cache, source, + ['s3', 'getBucketTagging', bucketLocation, bucket.Name]); + + // If not found in bucket's region, try default region (where collector runs) + if (!getBucketTagging) { + getBucketTagging = helpers.addSource(cache, source, + ['s3', 'getBucketTagging', defaultRegion, bucket.Name]); + } + + + if (!getBucketTagging || getBucketTagging.err) { + if (getBucketTagging && getBucketTagging.err && + (getBucketTagging.err.code === 'NoSuchTagSet' || + getBucketTagging.err.message && getBucketTagging.err.message.includes('does not exist'))) { + // No tags exist for this bucket + helpers.addResult(results, 2, 'S3 bucket does not have any tags', bucketLocation, bucketArn); + } else { + helpers.addResult(results, 3, + 'Unable to query S3 bucket tags: ' + helpers.addError(getBucketTagging), + bucketLocation, bucketArn); + } + return; + } + + if (getBucketTagging.data && getBucketTagging.data.TagSet && getBucketTagging.data.TagSet.length > 0) { + helpers.addResult(results, 0, 'S3 bucket has tags', bucketLocation, bucketArn); + } else { + helpers.addResult(results, 2, 'S3 bucket does not have any tags', bucketLocation, bucketArn); } - bucketsByRegion[bucketLocation].push(`arn:${awsOrGov}:s3:::${bucket.Name}`); }); - - for (var region in bucketsByRegion) { - var bucketNames = bucketsByRegion[region] || []; - helpers.checkTags(cache, 'S3 bucket', bucketNames, region, results, settings); - } callback(null, results, source); } -}; +}; \ No newline at end of file diff --git a/plugins/aws/s3/s3BucketHasTags.spec.js b/plugins/aws/s3/s3BucketHasTags.spec.js index c5d16a43..5eefe110 100644 --- a/plugins/aws/s3/s3BucketHasTags.spec.js +++ b/plugins/aws/s3/s3BucketHasTags.spec.js @@ -1,7 +1,7 @@ var expect = require('chai').expect; var s3BucketHasTags = require('./s3BucketHasTags'); -const createCache = (bucketData, bucketDataErr, rgData, rgDataErr) => { +const createCache = (bucketData, bucketDataErr, bucketTaggingData, bucketTaggingErr) => { var bucketName = (bucketData && bucketData.length) ? bucketData[0].Name : null; return { s3: { @@ -19,13 +19,13 @@ const createCache = (bucketData, bucketDataErr, rgData, rgDataErr) => { } } } - } - }, - resourcegroupstaggingapi: { - getResources: { - 'us-east-1':{ - err: rgDataErr, - data: rgData + }, + getBucketTagging: { + 'us-east-1': { + [bucketName]: { + err: bucketTaggingErr, + data: bucketTaggingData + } } } } @@ -58,19 +58,46 @@ describe('s3BucketHasTags', function () { s3BucketHasTags.run(cache, {}, callback); }); - it('should give unknown result if unable to query resource group tagging api', function (done) { + it('should give unknown result if unable to query bucket tagging', function (done) { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(3); - expect(results[0].message).to.include('Unable to query all resources from group tagging api:'); + expect(results[0].message).to.include('Unable to query S3 bucket tags:'); done(); }; - const cache = createCache([{ - "Name": "test-bucket", - "CreationDate": "November 22, 2021, 15:51:19 (UTC+05:00)", - }], null, [],{ - message: "Unable to query for Resource group tags" - }); + // Create cache with error in both potential lookup locations (bucket region and default region) + const cache = { + s3: { + listBuckets: { + 'us-east-1': { + err: null, + data: [{ + "Name": "test-bucket", + "CreationDate": "November 22, 2021, 15:51:19 (UTC+05:00)", + }] + } + }, + getBucketLocation: { + 'us-east-1': { + 'test-bucket': { + data: { + LocationConstraint: null // us-east-1 + } + } + } + }, + getBucketTagging: { + 'us-east-1': { + 'test-bucket': { + err: { + message: "Unable to query bucket tags" + }, + data: null + } + } + } + } + }; s3BucketHasTags.run(cache, {}, callback); }); @@ -86,11 +113,13 @@ describe('s3BucketHasTags', function () { [{ "Name": "test-bucket", "CreationDate": "November 22, 2021, 15:51:19 (UTC+05:00)", - }],null, - [{ - "ResourceARN": "arn:aws:s3:::test-bucket", - "Tags": [{key:"key1", value:"value"}], - }],null + }], null, + { + "TagSet": [ + {"Key": "key1", "Value": "value1"}, + {"Key": "key2", "Value": "value2"} + ] + }, null ); s3BucketHasTags.run(cache, {}, callback); }); @@ -107,11 +136,32 @@ describe('s3BucketHasTags', function () { [{ "Name": "test-bucket", "CreationDate": "November 22, 2021, 15:51:19 (UTC+05:00)", - }],null, + }], null, + null, { + code: "NoSuchTagSet", + message: "The TagSet does not exist" + } + ); + + s3BucketHasTags.run(cache, {}, callback); + }); + + it('should give failing result if s3 has empty tag set', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('S3 bucket does not have any tags'); + done(); + }; + + const cache = createCache( [{ - "ResourceARN": "arn:aws:s3:::test-bucket", - "Tags": [], - }],null + "Name": "test-bucket", + "CreationDate": "November 22, 2021, 15:51:19 (UTC+05:00)", + }], null, + { + "TagSet": [] + }, null ); s3BucketHasTags.run(cache, {}, callback); diff --git a/plugins/azure/apiManagement/apiInstanceManagedIdentity.js b/plugins/azure/apiManagement/apiInstanceManagedIdentity.js index 57557b01..af403139 100644 --- a/plugins/azure/apiManagement/apiInstanceManagedIdentity.js +++ b/plugins/azure/apiManagement/apiInstanceManagedIdentity.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Developer Tools', severity: 'Medium', description: 'Ensures that Azure API Management instance has managed identity enabled.', - more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Active Directory (Azure AD) tokens.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', link: 'https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-use-managed-service-identity', recommended_action: 'Modify API Management instance and add managed identity.', apis: ['apiManagementService:list'], @@ -49,4 +49,4 @@ module.exports = { callback(null, results, source); }); } -}; +}; \ No newline at end of file diff --git a/plugins/azure/appConfigurations/appConfigAccessKeyAuthDisabled.js b/plugins/azure/appConfigurations/appConfigAccessKeyAuthDisabled.js index 922bc1e6..be24ed0d 100644 --- a/plugins/azure/appConfigurations/appConfigAccessKeyAuthDisabled.js +++ b/plugins/azure/appConfigurations/appConfigAccessKeyAuthDisabled.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Developer Tools', severity: 'Low', description: 'Ensures that access key authentication is disabled for App Configuration.', - more_info: 'By default, requests can be authenticated with either Microsoft Entra credentials, or by using an access key. For enhanced security, centralized identity management, and seamless integration with Azure\'s authentication and authorization services, it is recommended to rely on Azure Active Directory (Azure AD) and disable local authentication for Azure App Configurations.', + more_info: 'By default, requests can be authenticated with either Microsoft Entra credentials, or by using an access key. For enhanced security, centralized identity management, and seamless integration with Azure\'s authentication and authorization services, it is recommended to rely on Azure Entra ID and disable local authentication for Azure App Configurations.', link: 'https://learn.microsoft.com/en-us/azure/azure-app-configuration/howto-disable-access-key-authentication', recommended_action: 'Ensure that Azure App Configurations have access key authentication disabled.', apis: ['appConfigurations:list'], @@ -49,4 +49,4 @@ module.exports = { callback(null, results, source); }); } -}; +}; \ No newline at end of file diff --git a/plugins/azure/appConfigurations/appConfigManagedIdentity.js b/plugins/azure/appConfigurations/appConfigManagedIdentity.js index 1989c003..a76bfe0e 100644 --- a/plugins/azure/appConfigurations/appConfigManagedIdentity.js +++ b/plugins/azure/appConfigurations/appConfigManagedIdentity.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Developer Tools', severity: 'Medium', description: 'Ensures that Azure App Configurations have managed identity enabled.', - more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Active Directory (Azure AD) tokens.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', link: 'https://learn.microsoft.com/en-us/azure/azure-app-configuration/overview-managed-identity', recommended_action: 'Modify App Configuration store and add managed identity.', apis: ['appConfigurations:list'], @@ -49,4 +49,4 @@ module.exports = { callback(null, results, source); }); } -}; +}; \ No newline at end of file diff --git a/plugins/azure/appservice/appServiceAccessRestriction.js b/plugins/azure/appservice/appServiceAccessRestriction.js index da71ba7b..618e8145 100644 --- a/plugins/azure/appservice/appServiceAccessRestriction.js +++ b/plugins/azure/appservice/appServiceAccessRestriction.js @@ -7,8 +7,7 @@ module.exports = { domain: 'Application Integration', severity: 'Medium', description: 'Ensure that Azure App Services have access restriction configured to control network access to your app.', - more_info: 'By setting up access restrictions, you can define a priority-ordered allow/deny list that controls network access to your app. ' + - 'The list can include IP addresses or Azure Virtual Network subnets. When there are one or more entries, an implicit deny all exists at the end of the list.', + more_info: 'By setting up access restrictions, you can define a priority-ordered allow/deny list that controls network access to your app. The list can include IP addresses or Azure Virtual Network subnets. When there are one or more entries, an implicit deny all exists at the end of the list. The most secure configuration is to disable public network access entirely. If public access is enabled, this plugin checks for explicit access restrictions with an "Any" IP address and "Deny" action rule.', recommended_action: 'Add access restriction rules under network settings for the app services', link: 'https://learn.microsoft.com/en-us/azure/app-service/app-service-ip-restrictions#set-up-azure-functions-access-restrictions', apis: ['webApps:list', 'webApps:listConfigurations'], @@ -50,20 +49,30 @@ module.exports = { 'Unable to query App Service configuration: ' + helpers.addError(webConfigs), location, webApp.id); } else { - let denyAllIp; - if (webConfigs.data[0].ipSecurityRestrictions && webConfigs.data[0].ipSecurityRestrictions.length) { - denyAllIp = webConfigs.data[0].ipSecurityRestrictions.find(ipSecurityRestriction => - ipSecurityRestriction.ipAddress && ipSecurityRestriction.ipAddress.toUpperCase() === 'ANY' && - ipSecurityRestriction.action && ipSecurityRestriction.action.toUpperCase() === 'DENY' - ); - } + const config = webConfigs.data[0]; - if (denyAllIp) { + if (config.publicNetworkAccess && config.publicNetworkAccess.toLowerCase() === 'disabled') { helpers.addResult(results, 0, 'App Service has access restriction enabled', location, webApp.id); } else { - helpers.addResult(results, 2, 'App Service does not have access restriction enabled', location, webApp.id); + let denyAllIp; + if (config.ipSecurityRestrictions && config.ipSecurityRestrictions.length) { + denyAllIp = config.ipSecurityRestrictions.find(ipSecurityRestriction => + ipSecurityRestriction.ipAddress && ipSecurityRestriction.ipAddress.toUpperCase() === 'ANY' && + ipSecurityRestriction.action && ipSecurityRestriction.action.toUpperCase() === 'DENY' + ); + } + + if (denyAllIp) { + helpers.addResult(results, 0, + 'App Service has access restriction enabled', + location, webApp.id); + } else { + helpers.addResult(results, 2, + 'App Service does not have access restriction enabled', + location, webApp.id); + } } } }); @@ -97,7 +106,7 @@ module.exports = { 'action': 'Deny', 'name': 'Deny All Access', 'ipAddress': '0.0.0.0/0', - 'description': 'Khulnasoft CSPM Auto Remediation', + 'description': 'KhulnaSoft CSPM Auto Remediation', 'priority': 2147483647 } ] @@ -130,4 +139,4 @@ module.exports = { callback('No region found'); } } -}; +}; \ No newline at end of file diff --git a/plugins/azure/appservice/appServiceAccessRestriction.spec.js b/plugins/azure/appservice/appServiceAccessRestriction.spec.js index 99377004..d6c7520d 100644 --- a/plugins/azure/appservice/appServiceAccessRestriction.spec.js +++ b/plugins/azure/appservice/appServiceAccessRestriction.spec.js @@ -11,6 +11,7 @@ const webApps = [ const configurations = [ { 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Enabled', 'ipSecurityRestrictions': [ { 'ipAddress': 'Any', @@ -23,6 +24,7 @@ const configurations = [ }, { 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Enabled', 'ipSecurityRestrictions': [ { 'ipAddress': '208.130.0.0/16', @@ -39,6 +41,22 @@ const configurations = [ 'description': 'Deny all access' } ] + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Disabled' + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Enabled', + 'ipSecurityRestrictions': [ + { + 'ipAddress': '192.168.1.0/24', + 'action': 'Allow', + 'priority': 100, + 'name': 'Office Network' + } + ], } ]; @@ -123,7 +141,18 @@ describe('appServiceAccessRestriction', function() { }); }); - it('should give passing result if app Service has access restriction enabled', function(done) { + it('should give passing result if public network access is disabled (most secure)', function(done) { + const cache = createCache([webApps[0]], [configurations[2]]); + appServiceAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has access restriction enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if app Service has explicit Any/Deny rule', function(done) { const cache = createCache([webApps[0]], [configurations[1]]); appServiceAccessRestriction.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); @@ -134,7 +163,7 @@ describe('appServiceAccessRestriction', function() { }); }); - it('should give failing result if App Service does not have access restriction enabled', function(done) { + it('should give failing result if App Service has allow all rule', function(done) { const cache = createCache([webApps[0]], [configurations[0]]); appServiceAccessRestriction.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); @@ -144,5 +173,16 @@ describe('appServiceAccessRestriction', function() { done(); }); }); + + it('should give failing result if App Service has specific IP restrictions but no Any/Deny rule', function(done) { + const cache = createCache([webApps[0]], [configurations[3]]); + appServiceAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('App Service does not have access restriction enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); }); }); \ No newline at end of file diff --git a/plugins/azure/appservice/authEnabled.js b/plugins/azure/appservice/authEnabled.js index 6c2cec0d..4140da11 100644 --- a/plugins/azure/appservice/authEnabled.js +++ b/plugins/azure/appservice/authEnabled.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Application Integration', severity: 'Medium', description: 'Ensures Authentication is enabled for App Services, redirecting unauthenticated users to the login page.', - more_info: 'Enabling authentication will redirect all unauthenticated requests to the login page. It also handles authentication of users with specific providers (Azure Active Directory, Facebook, Google, Microsoft Account, and Twitter).', + more_info: 'Enabling authentication will redirect all unauthenticated requests to the login page. It also handles authentication of users with specific providers (Azure Entra ID, Facebook, Google, Microsoft Account, and Twitter).', recommended_action: 'Enable App Service Authentication for all App Services.', link: 'https://learn.microsoft.com/en-us/azure/app-service/overview-authentication-authorization', apis: ['webApps:list', 'webApps:getAuthSettings'], @@ -134,4 +134,4 @@ module.exports = { callback('No region found'); } } -}; +}; \ No newline at end of file diff --git a/plugins/azure/appservice/automatedBackupsEnabled.spec.js b/plugins/azure/appservice/automatedBackupsEnabled.spec.js index c257c8b9..cd3bda1d 100644 --- a/plugins/azure/appservice/automatedBackupsEnabled.spec.js +++ b/plugins/azure/appservice/automatedBackupsEnabled.spec.js @@ -27,12 +27,12 @@ const backupConfigs = { enabled: true, storageAccountUrl: 'https://akhtarrgdiag.blob.core.windows.net/appbackup?sp=rwdl&st=2022-03-16T07:51:37Z&se=2295-12-29T08:51:37Z&sv=2020-08-04&sr=c&sig=FeC0hGUrqJb6b%2Bh5qbIif84725sMjeqyNUzWa4tL3L4%3D', backupSchedule: { - frequencyInterval: 7, - frequencyUnit: 'Day', - keepAtLeastOneBackup: true, - retentionPeriodInDays: 7, - startTime: '2022-03-16T07:51:38.699', - lastExecutionTime: '2022-03-16T07:53:38.4131659' + frequencyInterval: 7, + frequencyUnit: 'Day', + keepAtLeastOneBackup: true, + retentionPeriodInDays: 7, + startTime: '2022-03-16T07:51:38.699', + lastExecutionTime: '2022-03-16T07:53:38.4131659' }, databases: [], mySqlDumpParams: null @@ -128,7 +128,7 @@ describe('automatedBackupsEnabled', function() { automatedBackupsEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Backups are not configured for WebApp'); + expect(results[0].message).to.include('Custom Backups are not configured for WebApp'); expect(results[0].region).to.equal('eastus'); done(); }); @@ -139,10 +139,10 @@ describe('automatedBackupsEnabled', function() { automatedBackupsEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Backups are configured for WebApp'); + expect(results[0].message).to.include('Custom Backups are configured for WebApp'); expect(results[0].region).to.equal('eastus'); done(); }); }); }); -}); +}); \ No newline at end of file diff --git a/plugins/azure/appservice/functionPrivilegeAnalysis.js b/plugins/azure/appservice/functionPrivilegeAnalysis.js new file mode 100644 index 00000000..3b8585eb --- /dev/null +++ b/plugins/azure/appservice/functionPrivilegeAnalysis.js @@ -0,0 +1,24 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'App Service', + domain: 'Web Apps', + severity: 'Info', + description: 'Ensures that no Azure Functions in your environment have excessive permissions.', + more_info: 'Azure Functions that use managed identities or service principals with excessive Azure AD permissions may pose security risks. It is a best practice to assign only the necessary permissions to the identities attached to functions.', + link: 'https://docs.microsoft.com/en-us/azure/app-service/overview-managed-identity', + recommended_action: 'Review and restrict the Azure AD roles associated with managed identities used by Azure Functions to follow the principle of least privilege.', + apis: [''], + realtime_triggers: [ + 'Microsoft.Web/sites/write', + 'Microsoft.Web/sites/delete', + 'Microsoft.Web/sites/functions/write', + 'Microsoft.Web/sites/functions/delete', + ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + }, +}; \ No newline at end of file diff --git a/plugins/azure/appservice/privateEndpointsEnabled.js b/plugins/azure/appservice/privateEndpointsEnabled.js index 45421640..9e56d843 100644 --- a/plugins/azure/appservice/privateEndpointsEnabled.js +++ b/plugins/azure/appservice/privateEndpointsEnabled.js @@ -6,11 +6,11 @@ module.exports = { category: 'App Service', domain: 'Application Integration', severity: 'Medium', - description: 'Ensures that Web Apps are accessible only through private endpoints.', - more_info: 'Enabling private endpoints for Azure App Service enhances security by allowing access exclusively through a private network, minimizing the risk of public internet exposure and protecting against external attacks.', - recommended_action: 'Ensure that Private Endpoints are configured properly and Public Network Access is disabled for Web Apps.', + description: 'Ensures that Web Apps and Function Apps are accessible only through private endpoints.', + more_info: 'Enabling private endpoints for Azure App Service and Function Apps enhances security by allowing access exclusively through a private network, minimizing the risk of public internet exposure and protecting against external attacks.', + recommended_action: 'Ensure that Private Endpoints are configured properly and Public Network Access is disabled for Web Apps and Function Apps.', link: 'https://learn.microsoft.com/en-us/azure/app-service/overview-private-endpoint', - apis: ['webApps:list'], + apis: ['webApps:list', 'webApps:getWebAppDetails'], realtime_triggers: ['microsoftweb:sites:write', 'microsoftweb:sites:privateendpointconnectionproxies:write', 'microsoftweb:sites:privateendpointconnectionproxies:delete', 'microsoftweb:sites:delete'], run: function(cache, settings, callback) { @@ -35,18 +35,41 @@ module.exports = { } webApps.data.forEach(function(webApp) { - if (webApp && webApp.kind && webApp.kind === 'functionapp') { - helpers.addResult(results, 0, 'Private Endpoints can not be configured for function apps', location, webApp.id); - } else if (webApp && webApp.privateLinkIdentifiers) { - helpers.addResult(results, 0, 'App Service has Private Endpoints configured', location, webApp.id); + if (!webApp || !webApp.id) return; + + const webAppDetails = helpers.addSource(cache, source, + ['webApps', 'getWebAppDetails', location, webApp.id]); + + let hasPrivateEndpoints = false; + + if (webAppDetails && !webAppDetails.err && webAppDetails.data && webAppDetails.data.privateEndpointConnections) { + if (Array.isArray(webAppDetails.data.privateEndpointConnections) && webAppDetails.data.privateEndpointConnections.length > 0) { + hasPrivateEndpoints = true; + } + } + + if (!hasPrivateEndpoints && webApp.privateEndpointConnections && webApp.privateEndpointConnections.length > 0) { + hasPrivateEndpoints = true; + } + + if (hasPrivateEndpoints) { + if (webApp.kind && webApp.kind.toLowerCase().includes('functionapp')) { + helpers.addResult(results, 0, 'Function App has Private Endpoints configured', location, webApp.id); + } else { + helpers.addResult(results, 0, 'App Service has Private Endpoints configured', location, webApp.id); + } } else { - helpers.addResult(results, 2, 'App Service does not have Private Endpoints configured', location, webApp.id); + // No private endpoints configured + if (webApp.kind && webApp.kind.toLowerCase().includes('functionapp')) { + helpers.addResult(results, 2, 'Function App does not have Private Endpoints configured', location, webApp.id); + } else { + helpers.addResult(results, 2, 'App Service does not have Private Endpoints configured', location, webApp.id); + } } }); rcb(); }, function() { - // Global checking goes here callback(null, results, source); }); } -}; +}; \ No newline at end of file diff --git a/plugins/azure/appservice/privateEndpointsEnabled.spec.js b/plugins/azure/appservice/privateEndpointsEnabled.spec.js index 2482ca00..efce9f52 100644 --- a/plugins/azure/appservice/privateEndpointsEnabled.spec.js +++ b/plugins/azure/appservice/privateEndpointsEnabled.spec.js @@ -5,25 +5,67 @@ const webApps = [ { 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', 'name': 'app1', - 'privateLinkIdentifiers': '123456' + 'kind': 'app', + 'privateEndpointConnections': [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/privateEndpointConnections/test-endpoint', + 'name': 'test-endpoint' + } + ] }, { - 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1', - 'name': 'app1', - 'privateLinkIdentifiers': '' + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app2', + 'name': 'app2', + 'kind': 'app', + 'privateEndpointConnections': [] + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/func1', + 'name': 'func1', + 'kind': 'functionapp', + 'privateEndpointConnections': [ + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/func1/privateEndpointConnections/func-endpoint', + 'name': 'func-endpoint' + } + ] + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/func2', + 'name': 'func2', + 'kind': 'functionapp', + 'privateEndpointConnections': [] } ]; -const createCache = (webApps) => { - return { +const createCache = (webApps, privateEndpointConnections) => { + let cache = { webApps: { list: { - 'eastus':{ + 'eastus': { data: webApps } } } }; + + if (privateEndpointConnections && webApps) { + cache.webApps.getWebAppDetails = { + 'eastus': {} + }; + webApps.forEach((webApp, index) => { + if (webApp && webApp.id) { + cache.webApps.getWebAppDetails['eastus'][webApp.id] = { + data: { + ...webApp, + privateEndpointConnections: privateEndpointConnections[index] || [] + } + }; + } + }); + } + + return cache; }; const createErrorCache = () => { @@ -61,7 +103,7 @@ describe('privateEndpointsEnabled', function() { }); it('should give passing result if app service has Private Endpoints configured', function(done) { - const cache = createCache([webApps[0]]); + const cache = createCache([webApps[0]], [[{id: 'endpoint1', name: 'test-endpoint'}]]); privateEndpointsEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); @@ -71,8 +113,8 @@ describe('privateEndpointsEnabled', function() { }); }); - it('should give failing result if app service app service does not have Private Endpoints configured', function(done) { - const cache = createCache([webApps[1]]); + it('should give failing result if app service does not have Private Endpoints configured', function(done) { + const cache = createCache([webApps[1]], [[]]); privateEndpointsEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); @@ -81,5 +123,27 @@ describe('privateEndpointsEnabled', function() { done(); }); }); + + it('should give passing result if function app has Private Endpoints configured', function(done) { + const cache = createCache([webApps[2]], [[{id: 'func-endpoint', name: 'func-test-endpoint'}]]); + privateEndpointsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Function App has Private Endpoints configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if function app does not have Private Endpoints configured', function(done) { + const cache = createCache([webApps[3]], [[]]); + privateEndpointsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Function App does not have Private Endpoints configured'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); }); }); \ No newline at end of file diff --git a/plugins/azure/appservice/scmSiteAccessRestriction.js b/plugins/azure/appservice/scmSiteAccessRestriction.js index 5539f199..56d2a7cb 100644 --- a/plugins/azure/appservice/scmSiteAccessRestriction.js +++ b/plugins/azure/appservice/scmSiteAccessRestriction.js @@ -45,20 +45,28 @@ module.exports = { 'Unable to query App Service configuration: ' + helpers.addError(webConfigs), location, webApp.id); } else { - let denyAllIp; - if (webConfigs.data[0].scmIpSecurityRestrictions && webConfigs.data[0].scmIpSecurityRestrictions.length) { - denyAllIp = webConfigs.data[0].scmIpSecurityRestrictions.find(ipSecurityRestriction => - ipSecurityRestriction.ipAddress && ipSecurityRestriction.ipAddress.toUpperCase() === 'ANY' && - ipSecurityRestriction.action && ipSecurityRestriction.action.toUpperCase() === 'DENY' - ); - } + const config = webConfigs.data[0]; - if (denyAllIp) { + if (config.publicNetworkAccess && config.publicNetworkAccess.toLowerCase() === 'disabled') { helpers.addResult(results, 0, 'App Service has access restriction enabled for scm site', location, webApp.id); } else { - helpers.addResult(results, 2, 'App Service does not have access restriction enabled for scm site', location, webApp.id); + let denyAllIp; + if (config.scmIpSecurityRestrictions && config.scmIpSecurityRestrictions.length) { + denyAllIp = config.scmIpSecurityRestrictions.find(ipSecurityRestriction => + ipSecurityRestriction.ipAddress && ipSecurityRestriction.ipAddress.toUpperCase() === 'ANY' && + ipSecurityRestriction.action && ipSecurityRestriction.action.toUpperCase() === 'DENY' + ); + } + + if (denyAllIp) { + helpers.addResult(results, 0, + 'App Service has access restriction enabled for scm site', + location, webApp.id); + } else { + helpers.addResult(results, 2, 'App Service does not have access restriction enabled for scm site', location, webApp.id); + } } } }); diff --git a/plugins/azure/appservice/scmSiteAccessRestriction.spec.js b/plugins/azure/appservice/scmSiteAccessRestriction.spec.js index a1e128da..8d8847f1 100644 --- a/plugins/azure/appservice/scmSiteAccessRestriction.spec.js +++ b/plugins/azure/appservice/scmSiteAccessRestriction.spec.js @@ -39,6 +39,24 @@ const configurations = [ 'description': 'Deny all access' } ] + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Disabled', + 'scmIpSecurityRestrictions': [] + }, + { + 'id': '/subscriptions/123/resourceGroups/khulnasoft-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Disabled', + 'scmIpSecurityRestrictions': [ + { + 'ipAddress': 'Any', + 'action': 'Allow', + 'priority': 1, + 'name': 'Allow all', + 'description': 'Allow all access' + } + ] } ]; @@ -144,5 +162,27 @@ describe('scmSiteAccessRestriction', function() { done(); }); }); + + it('should give passing result if App Service has public network access disabled with no IP restrictions', function(done) { + const cache = createCache([webApps[0]], [configurations[2]]); + scmSiteAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has access restriction enabled for scm site'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if App Service has public network access disabled even with allow all IP restrictions', function(done) { + const cache = createCache([webApps[0]], [configurations[3]]); + scmSiteAccessRestriction.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('App Service has access restriction enabled for scm site'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); }); }); \ No newline at end of file diff --git a/plugins/azure/appservice/webAppsADEnabled.js b/plugins/azure/appservice/webAppsADEnabled.js index 6c4caf0d..71625e3b 100644 --- a/plugins/azure/appservice/webAppsADEnabled.js +++ b/plugins/azure/appservice/webAppsADEnabled.js @@ -2,13 +2,13 @@ var async = require('async'); var helpers = require('../../../helpers/azure'); module.exports = { - title: 'Web Apps Active Directory Enabled', + title: 'Web Apps Entra ID Enabled', category: 'App Service', domain: 'Application Integration', severity: 'Medium', - description: 'Ensures that Azure Web Apps have registration with Azure Active Directory.', - more_info: 'Registration with Azure Active Directory (AAD) enables App Service web applications to connect to other Azure cloud services securely without the need of access credentials such as user names and passwords.', - recommended_action: 'Enable registration with Azure Active Directory for Azure Web Apps.', + description: 'Ensures that Azure Web Apps have registration with Azure Entra ID.', + more_info: 'Registration with Azure Entra ID enables App Service web applications to connect to other Azure cloud services securely without the need of access credentials such as user names and passwords.', + recommended_action: 'Enable registration with Azure Entra ID for Azure Web Apps.', link: 'https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity?tabs=portal%2Chttp#add-a-system-assigned-identity', apis: ['webApps:list'], realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete'], @@ -36,9 +36,9 @@ module.exports = { for (let app of webApps.data) { if (app.identity && app.identity.principalId) { - helpers.addResult(results, 0, 'Registration with Azure Active Directory is enabled for the Web App', location, app.id); + helpers.addResult(results, 0, 'Registration with Azure Entra ID is enabled for the Web App', location, app.id); } else { - helpers.addResult(results, 2, 'Registration with Azure Active Directory is disabled for the Web App', location, app.id); + helpers.addResult(results, 2, 'Registration with Azure Entra ID is disabled for the Web App', location, app.id); } } @@ -47,4 +47,4 @@ module.exports = { callback(null, results, source); }); } -}; +}; \ No newline at end of file diff --git a/plugins/azure/appservice/webAppsADEnabled.spec.js b/plugins/azure/appservice/webAppsADEnabled.spec.js index 06b8be84..530e55d0 100644 --- a/plugins/azure/appservice/webAppsADEnabled.spec.js +++ b/plugins/azure/appservice/webAppsADEnabled.spec.js @@ -72,26 +72,26 @@ describe('webAppsADEnabled', function() { }); }); - it('should give passing result if Registration with Azure Active Directory is enabled', function(done) { + it('should give passing result if Registration with Azure Entra ID is enabled', function(done) { const cache = createCache([webApps[1]]); webAppsADEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Registration with Azure Active Directory is enabled for the Web App'); + expect(results[0].message).to.include('Registration with Azure Entra ID is enabled for the Web App'); expect(results[0].region).to.equal('eastus'); done(); }); }); - it('should give failing result if Registration with Azure Active Directory is disabled', function(done) { + it('should give failing result if Registration with Azure Entra ID is disabled', function(done) { const cache = createCache([webApps[0]]); webAppsADEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Registration with Azure Active Directory is disabled for the Web App'); + expect(results[0].message).to.include('Registration with Azure Entra ID is disabled for the Web App'); expect(results[0].region).to.equal('eastus'); done(); }); }); }); -}); +}); \ No newline at end of file diff --git a/plugins/azure/automationAccounts/automationAcctManagedIdentity.js b/plugins/azure/automationAccounts/automationAcctManagedIdentity.js index bf3fbe44..b946d8c4 100644 --- a/plugins/azure/automationAccounts/automationAcctManagedIdentity.js +++ b/plugins/azure/automationAccounts/automationAcctManagedIdentity.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Management and Governance', severity: 'Medium', description: 'Ensure that Azure Automation accounts have managed identity enabled.', - more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Active Directory (Azure AD) tokens.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', recommended_action: 'Modify automation account and enable managed identity.', link: 'https://learn.microsoft.com/en-us/azure/automation/quickstarts/enable-managed-identity', apis: ['automationAccounts:list'], @@ -50,5 +50,4 @@ module.exports = { callback(null, results, source); }); } -}; - +}; \ No newline at end of file diff --git a/plugins/azure/batchAccounts/batchAccountsAADEnabled.js b/plugins/azure/batchAccounts/batchAccountsAADEnabled.js index cf9cabc3..fc2839ec 100644 --- a/plugins/azure/batchAccounts/batchAccountsAADEnabled.js +++ b/plugins/azure/batchAccounts/batchAccountsAADEnabled.js @@ -2,13 +2,13 @@ var async = require('async'); var helpers = require('../../../helpers/azure/'); module.exports = { - title: 'Batch Account AAD Auth Enabled', + title: 'Batch Account Entra ID Auth Enabled', category: 'Batch', domain: 'Compute', severity: 'Medium', - description: 'Ensures that Batch account has Azure Active Directory (AAD) authentication mode enabled.', - more_info: 'Enabling Azure Active Directory (AAD) authentication for Batch account ensures enhanced security by restricting the service API authentication to Microsoft Entra ID that prevents access through less secure shared key methods, thereby safeguarding batch resources from unauthorized access.', - recommended_action: 'Enable Active Directory authentication mode for all Batch accounts.', + description: 'Ensures that Batch account has Azure Entra ID authentication mode enabled.', + more_info: 'Enabling Azure Entra ID authentication for Batch account ensures enhanced security by restricting the service API authentication to Microsoft Entra ID that prevents access through less secure shared key methods, thereby safeguarding batch resources from unauthorized access.', + recommended_action: 'Enable Entra ID authentication mode for all Batch accounts.', link: 'https://learn.microsoft.com/en-us/azure/batch/batch-aad-auth', apis: ['batchAccounts:list'], realtime_triggers: ['microsoftbatch:batchaccounts:write', 'microsoftbatch:batchaccounts:delete'], @@ -42,9 +42,9 @@ module.exports = { batchAccount.allowedAuthenticationModes.some(mode => mode.toUpperCase() === 'AAD') : false; if (found) { - helpers.addResult(results, 0, 'Batch account has Active Directory authentication enabled', location, batchAccount.id); + helpers.addResult(results, 0, 'Batch account has Entra ID authentication enabled', location, batchAccount.id); } else { - helpers.addResult(results, 2, 'Batch account does not have Active Directory authentication enabled', location, batchAccount.id); + helpers.addResult(results, 2, 'Batch account does not have Entra ID authentication enabled', location, batchAccount.id); } } diff --git a/plugins/azure/batchAccounts/batchAccountsAADEnabled.spec.js b/plugins/azure/batchAccounts/batchAccountsAADEnabled.spec.js index cd106bc0..508c96d6 100644 --- a/plugins/azure/batchAccounts/batchAccountsAADEnabled.spec.js +++ b/plugins/azure/batchAccounts/batchAccountsAADEnabled.spec.js @@ -69,23 +69,23 @@ describe('batchAccountsAADEnabled', function () { }); }); - it('should give passing result if Batch account is configured with AAD Authentication', function (done) { + it('should give passing result if Batch account is configured with Entra ID Authentication', function (done) { const cache = createCache([batchAccounts[0]]); batchAccountsAADEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Batch account has Active Directory authentication enabled'); + expect(results[0].message).to.include('Batch account has Entra ID authentication enabled'); expect(results[0].region).to.equal('eastus'); done(); }); }); - it('should give failing result if Batch account is not configured with AAD Authentication', function (done) { + it('should give failing result if Batch account is not configured with Entra ID Authentication', function (done) { const cache = createCache([batchAccounts[1]]); batchAccountsAADEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Batch account does not have Active Directory authentication enabled'); + expect(results[0].message).to.include('Batch account does not have Entra ID authentication enabled'); expect(results[0].region).to.equal('eastus'); done(); }); diff --git a/plugins/azure/batchAccounts/batchAccountsManagedIdentity.js b/plugins/azure/batchAccounts/batchAccountsManagedIdentity.js index 767c81eb..4b0821e7 100644 --- a/plugins/azure/batchAccounts/batchAccountsManagedIdentity.js +++ b/plugins/azure/batchAccounts/batchAccountsManagedIdentity.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Compute', severity: 'Medium', description: 'Ensures that Batch accounts have managed identity enabled.', - more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Active Directory (Azure AD) tokens.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure and using it to obtain Azure Entra Id tokens.', recommended_action: 'Modify Batch Account and enable managed identity.', link: 'https://learn.microsoft.com/en-us/troubleshoot/azure/hpc/batch/use-managed-identities-azure-batch-account-pool', apis: ['batchAccounts:list'], diff --git a/plugins/azure/containerapps/containerAppManagedIdentity.js b/plugins/azure/containerapps/containerAppManagedIdentity.js index 6fb376db..978ecbd0 100644 --- a/plugins/azure/containerapps/containerAppManagedIdentity.js +++ b/plugins/azure/containerapps/containerAppManagedIdentity.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Containers', severity: 'Medium', description: 'Ensure that Azure Container Apps has managed identity enabled.', - more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Active Directory (Azure AD) tokens.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', recommended_action: 'Modify Container apps and add managed identity.', link: 'https://learn.microsoft.com/en-us/azure/container-apps/managed-identity', apis: ['containerApps:list'], diff --git a/plugins/azure/containerregistry/acrManagedIdentityEnabled.js b/plugins/azure/containerregistry/acrManagedIdentityEnabled.js index a5396cec..6c6012aa 100644 --- a/plugins/azure/containerregistry/acrManagedIdentityEnabled.js +++ b/plugins/azure/containerregistry/acrManagedIdentityEnabled.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Containers', severity: 'Medium', description: 'Ensure that Azure container registries have managed identity enabled.', - more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Active Directory (Azure AD) tokens.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure AD and using it to obtain Azure Entra ID tokens.', recommended_action: 'Modify container registry and enable managed identity.', link: 'https://learn.microsoft.com/en-us/azure/container-registry/container-registry-authentication-managed-identity?tabs=azure-cli', apis: ['registries:list'], @@ -50,4 +50,4 @@ module.exports = { callback(null, results, source); }); } -}; +}; \ No newline at end of file diff --git a/plugins/azure/cosmosdb/cosmosdbLocalAuth.js b/plugins/azure/cosmosdb/cosmosdbLocalAuth.js index 8efe7ca7..7090696b 100644 --- a/plugins/azure/cosmosdb/cosmosdbLocalAuth.js +++ b/plugins/azure/cosmosdb/cosmosdbLocalAuth.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Databases', severity: 'Low', description: 'Ensures that local authentication is disabled for Cosmos DB accounts.', - more_info: 'For enhanced security, centralized identity management and seamless integration with Azure\'s authentication and authorization services, it is recommended to rely on Azure Active Directory (Azure AD) and disable local authentication for Azure Cosmos DB accounts.', + more_info: 'For enhanced security, centralized identity management and seamless integration with Azure\'s authentication and authorization services, it is recommended to rely on Azure Entra ID and disable local authentication for Azure Cosmos DB accounts.', recommended_action: 'Ensure that Cosmos DB accounts have local authentication disabled.', link: 'https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#disable-local-auth', apis: ['databaseAccounts:list'], diff --git a/plugins/azure/cosmosdb/cosmosdbManagedIdentity.spec.js b/plugins/azure/cosmosdb/cosmosdbManagedIdentity.spec.js index b1f8054a..2f3fd24d 100644 --- a/plugins/azure/cosmosdb/cosmosdbManagedIdentity.spec.js +++ b/plugins/azure/cosmosdb/cosmosdbManagedIdentity.spec.js @@ -1,137 +1,56 @@ -var expect = require('chai').expect; -var cosmosdbManagedIdentity = require('./cosmosdbManagedIdentity'); - -const databaseAccounts = [ - { - "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", - "name": "khulnasoft-cosmos", - "location": "East US", - "tags": {"key": "value"}, - "type": "Microsoft.DocumentDB/databaseAccounts", - "kind": "GlobalDocumentDB", - "provisioningState": "Succeeded", - "identity": { - "principalId":"e8c02afc-8fb0-43eb-985f-5bb60a87e7aa", - "type":"systemassigned" - }, - "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", - "publicNetworkAccess": "Enabled", - "enableAutomaticFailover": true, - "enableMultipleWriteLocations": false, - "enablePartitionKeyMonitor": false, - "isVirtualNetworkFilterEnabled": true, - "EnabledApiTypes": "Sql", - "disableKeyBasedMetadataWriteAccess": false, - "enableAnalyticalStorage": false, - "instanceId": "5f3e6edc-33c6-4a47-81aa-108af12d4fba", - "createMode": "Default", - "databaseAccountOfferType": "Standard", - }, - { - "id": "/subscriptions/123/resourceGroups/tets-rg/providers/Microsoft.DocumentDB/databaseAccounts/khulnasoft-cosmos", - "name": "khulnasoft-cosmos", - "location": "East US", - "tags": {}, - "type": "Microsoft.DocumentDB/databaseAccounts", - "kind": "GlobalDocumentDB", - "provisioningState": "Succeeded", - "documentEndpoint": "https://khulnasoft-cosmos.documents.azure.com:443/", - "publicNetworkAccess": "Enabled", - "identity": { - "type":"None" - }, - "enableAutomaticFailover": false, - "enableMultipleWriteLocations": false, - "enablePartitionKeyMonitor": false, - "isVirtualNetworkFilterEnabled": false, - "virtualNetworkRules": [], - "EnabledApiTypes": "Cassandra", - "disableKeyBasedMetadataWriteAccess": false, - "enableAnalyticalStorage": false, - "instanceId": "5f3e6edc-33c6-4a47-81aa-108af12d4fba", - "createMode": "Default", - "databaseAccountOfferType": "Standard" - } -]; - -const createCache = (accounts, accountsErr) => { - return { - databaseAccounts: { - list: { - 'eastus': { - err: accountsErr, - data: accounts - } +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Cosmos DB Managed Identity', + category: 'Cosmos DB', + domain: 'Databases', + severity: 'Medium', + description: 'Ensures that Azure Cosmos DB accounts have managed identity enabled.', + more_info: 'Enabling managed identities eliminate the need for developers having to manage credentials by providing an identity for the Azure resource in Azure Entra ID and using it to obtain Azure Entra ID tokens.', + link: 'https://learn.microsoft.com/en-us/azure/cosmos-db/managed-identity-based-authentication', + recommended_action: 'Enable system or user-assigned identities for all Azure Cosmos DB accounts.', + apis: ['databaseAccounts:list'], + realtime_triggers: ['microsoftdocumentdb:databaseaccounts:write','microsoftdocumentdb:databaseaccounts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databaseAccounts, function(location, rcb) { + var databaseAccounts = helpers.addSource(cache, source, + ['databaseAccounts', 'list', location]); + + if (!databaseAccounts) return rcb(); + + if (databaseAccounts.err || !databaseAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for Cosmos DB accounts: ' + helpers.addError(databaseAccounts), location); + return rcb(); } - } - } -}; - -describe('cosmosdbManagedIdentity', function() { - describe('run', function() { - it('should give passing result if no Cosmos DB accounts found', function(done) { - const callback = (err, results) => { - expect(results.length).to.equal(1); - expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('No Cosmos DB accounts found'); - expect(results[0].region).to.equal('eastus'); - done() - }; - - const cache = createCache( - [] - ); - - cosmosdbManagedIdentity.run(cache, {}, callback); - }); - - it('should give passing result if Cosmos db has managed identity', function(done) { - const callback = (err, results) => { - expect(results.length).to.equal(1); - expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Cosmos DB account has managed identity enabled'); - expect(results[0].region).to.equal('eastus'); - done() - }; - const cache = createCache( - [databaseAccounts[0]] - ); - - cosmosdbManagedIdentity.run(cache, {}, callback); - }); - - it('should give failing result if Azure Cosmos db does not have managed identity', function(done) { - const callback = (err, results) => { - expect(results.length).to.equal(1); - expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Cosmos DB account does not have managed identity enabled'); - expect(results[0].region).to.equal('eastus'); - done() - }; - - const cache = createCache( - [databaseAccounts[1]], - ); - - cosmosdbManagedIdentity.run(cache, {}, callback); - }); + if (!databaseAccounts.data.length) { + helpers.addResult(results, 0, 'No Cosmos DB accounts found', location); + return rcb(); + } - it('should give unknown result if unable to query for Cosmos DB accounts', function(done) { - const callback = (err, results) => { - expect(results.length).to.equal(1); - expect(results[0].status).to.equal(3); - expect(results[0].message).to.include('Unable to query for Cosmos DB accounts'); - expect(results[0].region).to.equal('eastus'); - done() - }; + databaseAccounts.data.forEach(account => { + if (!account.id) return; - const cache = createCache( - [], - { message: 'Unable to query Cosmos DB accounts'} - ); + if (account.identity && account.identity.type && + (account.identity.type.toLowerCase().includes('systemassigned') || account.identity.type.toLowerCase().includes('userassigned'))) { + helpers.addResult(results, 0, + 'Cosmos DB account has managed identity enabled', location, account.id); + } else { + helpers.addResult(results, 2, + 'Cosmos DB account does not have managed identity enabled', location, account.id); + } + }); - cosmosdbManagedIdentity.run(cache, {}, callback); + rcb(); + }, function() { + callback(null, results, source); }); - }) -}); + } +}; \ No newline at end of file diff --git a/postprocess/output.js b/postprocess/output.js index fdf30b4a..8242b490 100644 --- a/postprocess/output.js +++ b/postprocess/output.js @@ -293,23 +293,115 @@ module.exports = { }, /** - * Creates an output handler that writes collection in the JSON format. - * @param {fs.WriteSteam} stream The stream to write to or an object that + * Creates an output handler that writes collection in a format suitable for SaaS integration. + * @param {fs.WriteStream} stream The stream to write to or an object that * obeys the writeable stream contract. + * @param {Object} settings Configuration settings including scan metadata */ createCollection: function(stream, settings) { - var results = {}; + var scanData = { + // Standard metadata + metadata: { + version: '1.0', + scan_timestamp: new Date().toISOString(), + scan_id: settings.scanId || `local_${Date.now()}`, + account_id: settings.accountId || 'local', + cloud_provider: settings.cloud || 'unknown', + // Add any additional metadata from settings + ...(settings.metadata || {}) + }, + // Collection data will be stored here + resources: {}, + // Summary information + summary: { + total_resources: 0, + scan_duration: 0, + status: 'completed' + } + }; + return { stream: stream, write: function(collection) { - results = collection; + // Process the collection data + if (collection && typeof collection === 'object') { + // Add resources to the scan data + scanData.resources = collection; + + // Update summary information + if (collection.resources) { + scanData.summary.total_resources = collection.resources.length || 0; + } + } }, close: function() { - this.stream.write(JSON.stringify(results, null, 2)); + // Calculate scan duration if start time is available + if (settings.startTime) { + const endTime = new Date(); + scanData.summary.scan_duration = endTime - new Date(settings.startTime); + } + + // Write the formatted output + const output = JSON.stringify(scanData, null, 2); + this.stream.write(output); this.stream.end(); - log(`INFO: Collection file written to ${settings.collection}`, settings); + + log(`INFO: Collection data written to ${settings.collection}`, settings); + + // If SaaS endpoint is configured, send the data + if (settings.saas && settings.saas.endpoint) { + this._sendToSaaS(scanData, settings); + } + }, + + /** + * Sends the scan data to the SaaS endpoint + * @private + */ + _sendToSaaS: function(data, settings) { + const https = require('https'); + const url = require('url'); + + const saasUrl = url.parse(settings.saas.endpoint); + const postData = JSON.stringify(data); + + const options = { + hostname: saasUrl.hostname, + port: saasUrl.port || 443, + path: saasUrl.path, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + 'Authorization': settings.saas.apiKey ? `Bearer ${settings.saas.apiKey}` : '' + } + }; + + const req = https.request(options, (res) => { + let responseData = ''; + + res.on('data', (chunk) => { + responseData += chunk; + }); + + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + log('INFO: Successfully sent data to SaaS platform', settings); + } else { + log(`WARN: Failed to send data to SaaS platform: ${res.statusCode} - ${responseData}`, settings); + } + }); + }); + + req.on('error', (e) => { + log(`ERROR: Failed to send data to SaaS platform: ${e.message}`, settings); + }); + + // Write data to request body + req.write(postData); + req.end(); } }; },